master
go 551 lines 15.1 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package chartengine
4
5 import (
6 "fmt"
7 "sort"
8 "strconv"
9 "strings"
10
11 "github.com/netdata/netdata/go/plugins/pkg/metrix"
12 metrixselector "github.com/netdata/netdata/go/plugins/pkg/metrix/selector"
13 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine/internal/program"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
15 )
16
17 // Compile converts a decoded/default-applied chart template spec into immutable
18 // chartengine IR.
19 //
20 // Callers should prefer charttpl.DecodeYAML, which applies chart_defaults
21 // inheritance before validation. Compile validates the provided spec but does
22 // not apply charttpl defaults or mutate the input.
23 func Compile(spec *charttpl.Spec, revision uint64) (*program.Program, error) {
24 if spec == nil {
25 return nil, fmt.Errorf("chartengine: nil template spec")
26 }
27 if err := spec.Validate(); err != nil {
28 return nil, fmt.Errorf("chartengine: invalid template spec: %w", err)
29 }
30
31 c := compiler{
32 metricsSet: make(map[string]struct{}),
33 }
34
35 rootCtx := normalizeOptional(spec.ContextNamespace)
36 for i := range spec.Groups {
37 groupPath := []int{i}
38 if err := c.compileGroup(spec.Groups[i], compileScope{
39 metrics: make(map[string]struct{}),
40 familyParts: nil,
41 contextParts: rootCtx,
42 }, groupPath); err != nil {
43 return nil, err
44 }
45 }
46
47 return program.New(spec.Version, revision, c.metricNames(), c.charts)
48 }
49
50 type compiler struct {
51 charts []program.Chart
52 metricsSet map[string]struct{}
53 }
54
55 type compileScope struct {
56 metrics map[string]struct{}
57 familyParts []string
58 contextParts []string
59 }
60
61 func (c *compiler) compileGroup(group charttpl.Group, parent compileScope, groupPath []int) error {
62 scope := compileScope{
63 metrics: cloneStringSet(parent.metrics),
64 familyParts: append(append([]string(nil), parent.familyParts...), strings.TrimSpace(group.Family)),
65 contextParts: append(append([]string(nil), parent.contextParts...), normalizeOptional(group.ContextNamespace)...),
66 }
67
68 for _, metric := range group.Metrics {
69 name := strings.TrimSpace(metric)
70 scope.metrics[name] = struct{}{}
71 c.metricsSet[name] = struct{}{}
72 }
73
74 for i := range group.Charts {
75 templateID := buildTemplateID(groupPath, i)
76 compiled, err := c.compileChart(group.Charts[i], scope, templateID)
77 if err != nil {
78 return fmt.Errorf("chartengine: compile group[%s] chart[%d]: %w", pathIndexes(groupPath), i, err)
79 }
80 c.charts = append(c.charts, compiled)
81 }
82
83 for i := range group.Groups {
84 nextPath := append(append([]int(nil), groupPath...), i)
85 if err := c.compileGroup(group.Groups[i], scope, nextPath); err != nil {
86 return err
87 }
88 }
89 return nil
90 }
91
92 func (c *compiler) compileChart(chart charttpl.Chart, scope compileScope, templateID string) (program.Chart, error) {
93 dimensions := make([]program.Dimension, 0, len(chart.Dimensions))
94 selectorKeySet := make(map[string]struct{})
95 dynamicDimensionKeys := make(map[string]struct{})
96
97 metricKinds := make(map[string]bool)
98 for i := range chart.Dimensions {
99 compiledDim, err := compileDimension(chart.Dimensions[i], scope.metrics)
100 if err != nil {
101 return program.Chart{}, fmt.Errorf("dimension[%d]: %w", i, err)
102 }
103 dimensions = append(dimensions, compiledDim.dimension)
104
105 for _, key := range compiledDim.selectorKeys {
106 selectorKeySet[key] = struct{}{}
107 }
108 for _, key := range compiledDim.dynamicLabelKeys {
109 dynamicDimensionKeys[key] = struct{}{}
110 }
111 for _, kind := range compiledDim.metricKinds {
112 metricKinds[kind] = true
113 }
114 }
115
116 algorithm, err := resolveAlgorithm(chart.Algorithm, metricKinds)
117 if err != nil {
118 return program.Chart{}, err
119 }
120
121 chartType, err := resolveChartType(chart.Type)
122 if err != nil {
123 return program.Chart{}, err
124 }
125
126 contextParts := append(append([]string(nil), scope.contextParts...), strings.TrimSpace(chart.Context))
127 context := strings.Join(filterEmpty(contextParts), ".")
128
129 baseID := strings.TrimSpace(chart.ID)
130 if baseID == "" {
131 // Phase-1 default when id is omitted: derive from chart context.
132 baseID = strings.ReplaceAll(context, ".", "_")
133 }
134
135 idTemplate, err := parseTemplate(baseID)
136 if err != nil {
137 return program.Chart{}, fmt.Errorf("id: %w", err)
138 }
139
140 instanceByLabels, err := compileInstanceByLabels(chart.Instances)
141 if err != nil {
142 return program.Chart{}, fmt.Errorf("instances.by_labels: %w", err)
143 }
144
145 labelMode := program.PromotionModeAutoIntersection
146 promote := normalizeUnique(chart.LabelPromoted)
147 if len(promote) > 0 {
148 labelMode = program.PromotionModeExplicitIntersection
149 }
150
151 identity := program.ChartIdentity{
152 IDTemplate: idTemplate,
153 InstanceByLabels: instanceByLabels,
154 ContextNamespace: append([]string(nil), scope.contextParts...),
155 Static: len(instanceByLabels) == 0,
156 }
157 metaFamily := composeFamily(scope.familyParts, chart.Family)
158
159 out := program.Chart{
160 TemplateID: templateID,
161 Meta: program.ChartMeta{
162 Title: strings.TrimSpace(chart.Title),
163 Family: metaFamily,
164 Context: context,
165 Units: strings.TrimSpace(chart.Units),
166 Algorithm: algorithm,
167 Type: chartType,
168 Priority: effectiveChartPriority(chart.Priority),
169 },
170 Identity: identity,
171 Labels: program.LabelPolicy{
172 Mode: labelMode,
173 PromoteKeys: promote,
174 Exclusions: program.LabelExclusions{
175 SelectorConstrainedKeys: mapKeysSorted(selectorKeySet),
176 DimensionKeyLabels: mapKeysSorted(dynamicDimensionKeys),
177 },
178 Precedence: program.DefaultLabelPrecedence(),
179 },
180 Lifecycle: compileLifecycle(chart.Lifecycle),
181 Dimensions: dimensions,
182 CollisionReduce: program.ReduceSum,
183 }
184
185 return out, nil
186 }
187
188 type compiledDimension struct {
189 dimension program.Dimension
190 selectorKeys []string
191 dynamicLabelKeys []string
192 metricKinds []string
193 }
194
195 func compileDimension(dim charttpl.Dimension, visibleMetrics map[string]struct{}) (compiledDimension, error) {
196 compiledSel, err := metrixselector.ParseCompiled(dim.Selector)
197 if err != nil {
198 return compiledDimension{}, fmt.Errorf("selector: %w", err)
199 }
200
201 meta := compiledSel.Meta()
202 if len(visibleMetrics) > 0 {
203 for _, metricName := range meta.MetricNames {
204 if _, ok := visibleMetrics[metricName]; !ok {
205 return compiledDimension{}, fmt.Errorf("selector: metric %q is not visible in current group scope", metricName)
206 }
207 }
208 }
209
210 name := strings.TrimSpace(dim.Name)
211 nameFromLabel := strings.TrimSpace(dim.NameFromLabel)
212 // If dimension naming is omitted, runtime planner resolves dynamic key source
213 // from series origin metadata (metrix.Reader.SeriesMeta on flattened series).
214 inferFromSeriesMeta := name == "" && nameFromLabel == ""
215 if inferFromSeriesMeta && !supportsRuntimeInferredDimension(meta) {
216 return compiledDimension{}, fmt.Errorf(
217 "name inference requires inferable selector (histogram bucket/summary quantile/stateset-like metric); set name or name_from_label",
218 )
219 }
220
221 nameTemplate := program.Template{}
222 dynamicLabelKeys := make([]string, 0, 1)
223 if name != "" {
224 nameTemplate, err = parseTemplate(name)
225 if err != nil {
226 return compiledDimension{}, fmt.Errorf("name: %w", err)
227 }
228 } else if nameFromLabel != "" {
229 dynamicLabelKeys = append(dynamicLabelKeys, nameFromLabel)
230 }
231
232 metricKinds := metricKindsFromNames(meta.MetricNames)
233 options := compileDimensionOptions(dim.Options)
234
235 return compiledDimension{
236 dimension: program.Dimension{
237 Selector: program.SelectorBinding{
238 Expression: strings.TrimSpace(dim.Selector),
239 Matcher: selectorMatcher{compiled: compiledSel},
240 MetricNames: append([]string(nil), meta.MetricNames...),
241 ConstrainedLabelKeys: append([]string(nil), meta.ConstrainedLabelKeys...),
242 },
243 NameTemplate: nameTemplate,
244 NameFromLabel: nameFromLabel,
245 InferNameFromSeriesMeta: inferFromSeriesMeta,
246 Hidden: options.hidden,
247 Multiplier: options.multiplier,
248 Divisor: options.divisor,
249 Float: options.float,
250 Dynamic: inferFromSeriesMeta || nameFromLabel != "",
251 },
252 selectorKeys: append([]string(nil), meta.ConstrainedLabelKeys...),
253 dynamicLabelKeys: normalizeUnique(dynamicLabelKeys),
254 metricKinds: metricKinds,
255 }, nil
256 }
257
258 type compiledDimensionOptions struct {
259 hidden bool
260 multiplier int
261 divisor int
262 float bool
263 }
264
265 func compileDimensionOptions(in *charttpl.DimensionOptions) compiledDimensionOptions {
266 out := compiledDimensionOptions{
267 multiplier: 1,
268 divisor: 1,
269 }
270 if in == nil {
271 return out
272 }
273 out.hidden = in.Hidden
274 out.float = in.Float
275 if in.Multiplier != 0 {
276 out.multiplier = in.Multiplier
277 }
278 if in.Divisor != 0 {
279 out.divisor = in.Divisor
280 }
281 return out
282 }
283
284 func resolveAlgorithm(raw string, metricKinds map[string]bool) (program.Algorithm, error) {
285 normalized := strings.TrimSpace(raw)
286 if normalized != "" {
287 switch normalized {
288 case string(program.AlgorithmAbsolute):
289 return program.AlgorithmAbsolute, nil
290 case string(program.AlgorithmIncremental):
291 return program.AlgorithmIncremental, nil
292 default:
293 return "", fmt.Errorf("invalid algorithm %q", raw)
294 }
295 }
296
297 // Inference baseline:
298 // - counter-like selectors => incremental
299 // - gauge-like selectors => absolute
300 // - mixed inferred kinds must be explicit.
301 if metricKinds["counter_like"] && metricKinds["gauge_like"] {
302 return "", fmt.Errorf("algorithm inference is ambiguous for mixed metric kinds; set algorithm explicitly")
303 }
304 if metricKinds["counter_like"] {
305 return program.AlgorithmIncremental, nil
306 }
307 return program.AlgorithmAbsolute, nil
308 }
309
310 func resolveChartType(raw string) (program.ChartType, error) {
311 normalized := strings.TrimSpace(raw)
312 if normalized == "" {
313 return program.ChartTypeLine, nil
314 }
315 switch normalized {
316 case string(program.ChartTypeLine):
317 return program.ChartTypeLine, nil
318 case string(program.ChartTypeArea):
319 return program.ChartTypeArea, nil
320 case string(program.ChartTypeStacked):
321 return program.ChartTypeStacked, nil
322 case string(program.ChartTypeHeatmap):
323 return program.ChartTypeHeatmap, nil
324 default:
325 return "", fmt.Errorf("invalid chart type %q", raw)
326 }
327 }
328
329 func compileLifecycle(in *charttpl.Lifecycle) program.LifecyclePolicy {
330 out := defaultChartLifecyclePolicyCopy()
331 if in == nil {
332 return out
333 }
334 out.MaxInstances = in.MaxInstances
335 if in.ExpireAfterCycles > 0 {
336 out.ExpireAfterCycles = in.ExpireAfterCycles
337 }
338 if in.Dimensions != nil {
339 out.Dimensions.MaxDims = in.Dimensions.MaxDims
340 out.Dimensions.ExpireAfterCycles = in.Dimensions.ExpireAfterCycles
341 }
342 return out
343 }
344
345 func compileInstanceByLabels(instances *charttpl.Instances) ([]program.InstanceLabelSelector, error) {
346 if instances == nil {
347 return nil, nil
348 }
349 out := make([]program.InstanceLabelSelector, 0, len(instances.ByLabels))
350 for _, token := range instances.ByLabels {
351 t := strings.TrimSpace(token)
352 switch {
353 case t == "*":
354 out = append(out, program.InstanceLabelSelector{IncludeAll: true})
355 case strings.HasPrefix(t, "!"):
356 key := strings.TrimSpace(strings.TrimPrefix(t, "!"))
357 if key == "" {
358 return nil, fmt.Errorf("exclude token must include label key")
359 }
360 out = append(out, program.InstanceLabelSelector{Exclude: true, Key: key})
361 default:
362 out = append(out, program.InstanceLabelSelector{Key: t})
363 }
364 }
365 return out, nil
366 }
367
368 func metricKindsFromNames(names []string) []string {
369 seen := make(map[string]struct{})
370 for _, name := range names {
371 switch {
372 case strings.HasSuffix(name, "_total"):
373 seen["counter_like"] = struct{}{}
374 case strings.HasSuffix(name, "_count"):
375 seen["counter_like"] = struct{}{}
376 case strings.HasSuffix(name, "_sum"):
377 seen["counter_like"] = struct{}{}
378 case strings.HasSuffix(name, "_bucket"):
379 seen["counter_like"] = struct{}{}
380 default:
381 seen["gauge_like"] = struct{}{}
382 }
383 }
384 return mapKeysSorted(seen)
385 }
386
387 func supportsRuntimeInferredDimension(meta metrixselector.Meta) bool {
388 for _, key := range meta.ConstrainedLabelKeys {
389 switch key {
390 case metrix.HistogramBucketLabel, metrix.SummaryQuantileLabel:
391 return true
392 }
393 }
394 for _, name := range meta.MetricNames {
395 name = strings.TrimSpace(name)
396 switch {
397 case strings.HasSuffix(name, "_bucket"):
398 return true
399 case strings.HasSuffix(name, "_state"):
400 return true
401 case strings.HasSuffix(name, "_status"):
402 return true
403 case strings.HasSuffix(name, "_mode"):
404 return true
405 }
406 if idx := strings.LastIndexByte(name, '.'); idx >= 0 && idx < len(name)-1 {
407 name = name[idx+1:]
408 }
409 if name == "state" || name == "status" || name == "mode" {
410 return true
411 }
412 }
413 return false
414 }
415
416 func composeFamily(parts []string, leaf string) string {
417 out := make([]string, 0, len(parts)+1)
418 for _, p := range parts {
419 if p = strings.TrimSpace(p); p != "" {
420 out = append(out, p)
421 }
422 }
423 if leaf = strings.TrimSpace(leaf); leaf != "" {
424 out = append(out, leaf)
425 }
426 return strings.Join(out, "/")
427 }
428
429 func buildTemplateID(groupPath []int, chartIndex int) string {
430 return fmt.Sprintf("g%s.c%d", pathIndexes(groupPath), chartIndex)
431 }
432
433 func pathIndexes(path []int) string {
434 if len(path) == 0 {
435 return "root"
436 }
437 var b strings.Builder
438 for i, idx := range path {
439 if i > 0 {
440 b.WriteByte('.')
441 }
442 b.WriteString(strconv.Itoa(idx))
443 }
444 return b.String()
445 }
446
447 func (c *compiler) metricNames() []string {
448 return mapKeysSorted(c.metricsSet)
449 }
450
451 func normalizeOptional(value string) []string {
452 value = strings.TrimSpace(value)
453 if value == "" {
454 return nil
455 }
456 return []string{value}
457 }
458
459 func filterEmpty(items []string) []string {
460 out := make([]string, 0, len(items))
461 for _, item := range items {
462 item = strings.TrimSpace(item)
463 if item == "" {
464 continue
465 }
466 out = append(out, item)
467 }
468 return out
469 }
470
471 func cloneStringSet(in map[string]struct{}) map[string]struct{} {
472 out := make(map[string]struct{}, len(in))
473 for k := range in {
474 out[k] = struct{}{}
475 }
476 return out
477 }
478
479 func normalizeUnique(values []string) []string {
480 set := make(map[string]struct{}, len(values))
481 for _, value := range values {
482 value = strings.TrimSpace(value)
483 if value == "" {
484 continue
485 }
486 set[value] = struct{}{}
487 }
488 return mapKeysSorted(set)
489 }
490
491 func mapKeysSorted(set map[string]struct{}) []string {
492 out := make([]string, 0, len(set))
493 for key := range set {
494 out = append(out, key)
495 }
496 sort.Strings(out)
497 return out
498 }
499
500 // selectorMatcher adapts prometheus selector API to chartengine selector binding.
501 type selectorMatcher struct {
502 compiled metrixselector.Compiled
503 }
504
505 func (m selectorMatcher) Matches(metricName string, lbs program.SelectorLabels) bool {
506 return m.compiled.Matches(metricName, selectorLabelView{labels: lbs})
507 }
508
509 type selectorLabelView struct {
510 labels program.SelectorLabels
511 }
512
513 func (v selectorLabelView) Len() int {
514 if v.labels == nil {
515 return 0
516 }
517 return v.labels.Len()
518 }
519
520 func (v selectorLabelView) Get(key string) (string, bool) {
521 if v.labels == nil {
522 return "", false
523 }
524 return v.labels.Get(key)
525 }
526
527 func (v selectorLabelView) Range(fn func(key, value string) bool) {
528 if v.labels == nil {
529 return
530 }
531 v.labels.Range(fn)
532 }
533
534 func (v selectorLabelView) CloneMap() map[string]string {
535 if v.labels == nil {
536 return nil
537 }
538 out := make(map[string]string, v.labels.Len())
539 v.labels.Range(func(key, value string) bool {
540 out[key] = value
541 return true
542 })
543 return out
544 }
545
546 func effectiveChartPriority(priority int) int {
547 if priority > 0 {
548 return priority
549 }
550 return Priority
551 }