@cryptotaxi247 / netdata / commits / a2337ab15

improve(go.d/ddsnmp): add alternatives support for virtual metrics (#21013)

Ilya Mashchenko committed Sep 20, 2025 at 16:32 UTC a2337ab15de2cd9cca0767e3c689a005793f8856
3 files changed +535 -121
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/virtual_metrics.go
+29 -15
@@ -5,25 +5,39 @@ import (
5 )
6
7 type VirtualMetricConfig struct {
8 - Name string `yaml:"name"`
9 - PerRow bool `yaml:"per_row"`
10 - GroupBy []string `yaml:"group_by"`
11 - Sources []VirtualMetricSourceConfig `yaml:"sources"`
12 - ChartMeta ChartMeta `yaml:"chart_meta"`
8 + Name string `yaml:"name"`
9 + PerRow bool `yaml:"per_row"`
10 + GroupBy []string `yaml:"group_by"`
11 + Sources []VirtualMetricSourceConfig `yaml:"sources"`
12 + Alternatives []VirtualMetricAlternativeSourcesConfig `yaml:"alternatives"`
13 + ChartMeta ChartMeta `yaml:"chart_meta"`
14 }
15
16 func (vm VirtualMetricConfig) Clone() VirtualMetricConfig {
17 + alts := make([]VirtualMetricAlternativeSourcesConfig, len(vm.Alternatives))
18 + for i, alt := range vm.Alternatives {
19 + alts[i] = VirtualMetricAlternativeSourcesConfig{
20 + Sources: slices.Clone(alt.Sources),
21 + }
22 + }
23 +
24 return VirtualMetricConfig{
17 - Name: vm.Name,
18 - PerRow: vm.PerRow,
19 - GroupBy: slices.Clone(vm.GroupBy),
20 - Sources: slices.Clone(vm.Sources),
21 - ChartMeta: vm.ChartMeta,
25 + Name: vm.Name,
26 + PerRow: vm.PerRow,
27 + GroupBy: slices.Clone(vm.GroupBy),
28 + Sources: slices.Clone(vm.Sources),
29 + Alternatives: alts,
30 + ChartMeta: vm.ChartMeta,
31 }
32 }
33
25 -type VirtualMetricSourceConfig struct {
26 - Metric string `yaml:"metric"`
27 - Table string `yaml:"table"` // Required for now
28 - As string `yaml:"as"` // dimension name for composite charts
29 -}
34 +type (
35 + VirtualMetricSourceConfig struct {
36 + Metric string `yaml:"metric"`
37 + Table string `yaml:"table"` // Required for now
38 + As string `yaml:"as"` // dimension name for composite charts
39 + }
40 + VirtualMetricAlternativeSourcesConfig struct {
41 + Sources []VirtualMetricSourceConfig `yaml:"sources"`
42 + }
43 +)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics.go
+244 -106
@@ -82,6 +82,20 @@ func (p *vmetricsCollector) emit(aggrs []*vmetricsAggregator) []ddsnmp.Metric {
82 // small pre-alloc: 1 total or ~groups count; start conservative
83 out := make([]ddsnmp.Metric, 0, len(aggrs))
84 for _, agg := range aggrs {
85 + // --- Alternatives parent: choose first child with data ---
86 + if len(agg.alts) > 0 {
87 + i := slices.IndexFunc(agg.alts, func(alt *vmetricsAggregator) bool { return alt.hadData })
88 + if i == -1 {
89 + p.log.Debugf("no alternative had data for virtual metric '%s'", agg.config.Name)
90 + continue
91 + }
92 + winner := agg.alts[i]
93 + // Emit using the parent's name/meta, but the child's accumulated values.
94 + winner.emitIntoAs(&out, agg.config.Name, agg.config.ChartMeta)
95 + continue
96 + }
97 +
98 + // --- Plain VM
99 if !agg.hadData {
100 p.log.Debugf("no source metrics found for virtual metric '%s'", agg.config.Name)
101 continue
@@ -94,26 +108,58 @@ func (p *vmetricsCollector) emit(aggrs []*vmetricsAggregator) []ddsnmp.Metric {
108 type (
109 // vmetricsAggregator holds accumulation state for a virtual metric
110 vmetricsAggregator struct {
97 - config ddprofiledefinition.VirtualMetricConfig
111 + // alts is non-empty only for an "alternatives parent" aggregator.
112 + // Children are plain aggregators (alts == nil) that actually receive samples.
113 + // At emit time, the parent picks the first child with hadData and emits that
114 + // child's accumulated values under the parent's (name, chart meta).
115 + alts []*vmetricsAggregator
116 +
117 + config ddprofiledefinition.VirtualMetricConfig
118 + // metricType is inferred from the first seen source sample for this aggregator.
119 + // If sources disagree, the first one wins (caller may log/debug mismatches).
120 metricType ddprofiledefinition.ProfileMetricType
121
122 // --- grouping controls ---
101 - grouped bool // len(GroupBy) > 0
102 - perRow bool // from cfg.PerRow
103 - groupBy []string // when perRow==true, groupBy are used as row-key hints
104 - groupTable string // sources must share the same table
105 - perGroup map[string]*vmetricsGroupBucket
123 + // grouped is true when PerRow is set OR len(GroupBy) > 0.
124 + // Grouped aggregators produce table rows keyed by vmBuildGroupKey(...).
125 + grouped bool
126 + // perRow mirrors cfg.PerRow. When true, each unique tag-set becomes a row key.
127 + // groupBy acts as ordered hints for building the key; if any hint is missing,
128 + // we fall back to a stable key built from all tags (sorted "k=v").
129 + perRow bool
130 + // groupBy mirrors cfg.GroupBy. When perRow==false and groupBy!=nil, all listed
131 + // labels must be present to form the row key; otherwise the sample is skipped.
132 + groupBy []string
133 + // groupTable is the SNMP table name all sources must share for grouped VMs.
134 + // This constraint is enforced per-aggregator; for alternatives, it is enforced
135 + // independently for each child. (No cross-table joins.)
136 + groupTable string
137 + // perGroup accumulates rows by group key.
138 + perGroup map[string]*vmetricsGroupBucket
139
140 // --- dimensions (composite) ---
141 + // dimNames defines the stable order of composite dimensions. It is built from
142 + // the 'as' names (or metric names when 'as' is empty) in first-seen order.
143 + // sink.dimIdx indexes into this slice.
144 dimNames []string
145
110 - // --- non-grouped accumulators (existing behavior) ---
111 - perDim map[string]int64 // composite total (dim -> sum)
112 - sum int64 // single-source total
113 - multiSum map[string]int64 // merged base MultiValue
114 -
146 + // --- non-grouped accumulators ---
147 + // perDim accumulates totals for composite (multi-source) non-grouped VMs:
148 + // dim name -> sum of values assigned to that dim.
149 + perDim map[string]int64
150 + // sum accumulates totals for single-source non-grouped VMs (no MultiValue).
151 + sum int64
152 + // multiSum merges MultiValue maps for single-source non-grouped VMs that
153 + // provide MultiValue; keys are preserved and values are summed per key.
154 + multiSum map[string]int64
155 +
156 + // hadData is set to true once at least one sample was accumulated into this
157 + // aggregator (for grouped or non-grouped). Parents look at children.hadData
158 + // to decide which alternative to emit.
159 hadData bool
116 - keyBuf strings.Builder
160 +
161 + // keyBuf is a reusable scratch buffer for building group keys. Not goroutine-safe.
162 + keyBuf strings.Builder
163 }
164
165 // vmetricsSourceKey identifies a metric source
@@ -182,24 +228,28 @@ func (agg *vmetricsAggregator) accumulateGroupedWithKey(sink vmetricsSink, gkey
228 }
229
230 func (agg *vmetricsAggregator) emitInto(out *[]ddsnmp.Metric) {
231 + agg.emitIntoAs(out, agg.config.Name, agg.config.ChartMeta)
232 +}
233 +
234 +func (agg *vmetricsAggregator) emitIntoAs(out *[]ddsnmp.Metric, name string, meta ddprofiledefinition.ChartMeta) {
235 if agg.grouped {
186 - agg.emitGrouped(out)
236 + agg.emitGroupedAs(out, name, meta)
237 } else {
188 - agg.emitTotal(out)
238 + agg.emitTotalAs(out, name, meta)
239 }
240 }
241
192 -func (agg *vmetricsAggregator) emitGrouped(out *[]ddsnmp.Metric) {
242 +func (agg *vmetricsAggregator) emitGroupedAs(out *[]ddsnmp.Metric, name string, meta ddprofiledefinition.ChartMeta) {
243 for _, b := range agg.perGroup {
244 vm := ddsnmp.Metric{
195 - Name: agg.config.Name,
196 - Description: agg.config.ChartMeta.Description,
197 - Family: agg.config.ChartMeta.Family,
198 - Unit: agg.config.ChartMeta.Unit,
199 - ChartType: agg.config.ChartMeta.Type,
245 + Name: name,
246 + Description: meta.Description,
247 + Family: meta.Family,
248 + Unit: meta.Unit,
249 + ChartType: meta.Type,
250 MetricType: agg.metricType,
251 IsTable: true,
202 - Table: agg.groupTable,
252 + Table: agg.groupTable, // winner's table (e.g., ifXTable or ifTable)
253 Tags: b.emitTags,
254 }
255 if len(agg.dimNames) > 0 {
@@ -217,13 +267,13 @@ func (agg *vmetricsAggregator) emitGrouped(out *[]ddsnmp.Metric) {
267 }
268 }
269
220 -func (agg *vmetricsAggregator) emitTotal(out *[]ddsnmp.Metric) {
270 +func (agg *vmetricsAggregator) emitTotalAs(out *[]ddsnmp.Metric, name string, meta ddprofiledefinition.ChartMeta) {
271 vm := ddsnmp.Metric{
222 - Name: agg.config.Name,
223 - Description: agg.config.ChartMeta.Description,
224 - Family: agg.config.ChartMeta.Family,
225 - Unit: agg.config.ChartMeta.Unit,
226 - ChartType: agg.config.ChartMeta.Type,
272 + Name: name,
273 + Description: meta.Description,
274 + Family: meta.Family,
275 + Unit: meta.Unit,
276 + ChartType: meta.Type,
277 MetricType: agg.metricType,
278 }
279 switch {
@@ -238,86 +288,9 @@ func (agg *vmetricsAggregator) emitTotal(out *[]ddsnmp.Metric) {
288 }
289
290 func (p *vmetricsCollector) buildAggregators(profDef *ddprofiledefinition.ProfileDefinition) (map[vmetricsSourceKey][]vmetricsSink, []*vmetricsAggregator) {
241 - sourceToSinks := make(map[vmetricsSourceKey][]vmetricsSink)
242 - aggregators := make([]*vmetricsAggregator, 0, len(profDef.VirtualMetrics))
243 -
291 existingNames := p.getDefinedMetricNames(profDef.Metrics)
245 -
246 - for _, cfg := range profDef.VirtualMetrics {
247 - if existingNames[cfg.Name] {
248 - p.log.Warningf("virtual metric '%s' conflicts with existing metric, skipping", cfg.Name)
249 - continue
250 - }
251 -
252 - agg := &vmetricsAggregator{config: cfg}
253 -
254 - agg.grouped = cfg.PerRow || len(cfg.GroupBy) > 0
255 -
256 - if agg.grouped {
257 - // require all sources from the same table
258 - var table string
259 - same := true
260 - for i, s := range cfg.Sources {
261 - if i == 0 {
262 - table = s.Table
263 - } else if s.Table != table {
264 - same = false
265 - break
266 - }
267 - }
268 - if !same || table == "" {
269 - p.log.Warningf("virtual metric '%s' uses group_by but sources span tables or have no table; skipping (no joins yet)", cfg.Name)
270 - continue
271 - }
272 -
273 - agg.perRow = cfg.PerRow
274 - agg.groupBy = cfg.GroupBy
275 - agg.groupTable = table
276 - agg.perGroup = make(map[string]*vmetricsGroupBucket, 64)
277 - }
278 -
279 - // --- composite dims? (multiple sources and at least one 'as') ---
280 - isComposite := len(cfg.Sources) > 1 &&
281 - slices.ContainsFunc(cfg.Sources, func(s ddprofiledefinition.VirtualMetricSourceConfig) bool {
282 - return s.As != ""
283 - })
284 -
285 - var dimsIdxByName map[string]int
286 -
287 - if isComposite {
288 - dimsIdxByName = make(map[string]int, len(cfg.Sources))
289 - agg.dimNames = make([]string, 0, len(cfg.Sources))
290 - for _, s := range cfg.Sources {
291 - name := ternary(s.As != "", s.As, s.Metric)
292 - if _, dup := dimsIdxByName[name]; !dup {
293 - dimsIdxByName[name] = len(agg.dimNames)
294 - agg.dimNames = append(agg.dimNames, name)
295 - }
296 - }
297 - }
298 -
299 - // register sinks
300 - for _, src := range cfg.Sources {
301 - key := vmetricsSourceKey{metricName: src.Metric, tableName: src.Table}
302 -
303 - dimIdx := int16(-1)
304 - if isComposite {
305 - name := ternary(src.As != "", src.As, src.Metric)
306 - if idx, ok := dimsIdxByName[name]; ok {
307 - dimIdx = int16(idx)
308 - }
309 - }
310 -
311 - sourceToSinks[key] = append(sourceToSinks[key], vmetricsSink{
312 - agg: agg,
313 - dimIdx: dimIdx,
314 - })
315 - }
316 -
317 - aggregators = append(aggregators, agg)
318 - }
319 -
320 - return sourceToSinks, aggregators
292 + builder := newAggregatorsBuilder(p.log, profDef, existingNames)
293 + return builder.build()
294 }
295
296 func (p *vmetricsCollector) getDefinedMetricNames(profMetrics []ddprofiledefinition.MetricsConfig) map[string]bool {
@@ -435,3 +408,168 @@ func vmCollapseMetricValue(m ddsnmp.Metric) (v int64, mv map[string]int64) {
408 }
409 return sum, m.MultiValue
410 }
411 +
412 +type aggregatorsBuilder struct {
413 + log *logger.Logger
414 + prof *ddprofiledefinition.ProfileDefinition
415 + existingNames map[string]bool
416 +
417 + sourceToSinks map[vmetricsSourceKey][]vmetricsSink
418 + aggregators []*vmetricsAggregator
419 +}
420 +
421 +func newAggregatorsBuilder(
422 + log *logger.Logger, prof *ddprofiledefinition.ProfileDefinition, existingNames map[string]bool) *aggregatorsBuilder {
423 + return &aggregatorsBuilder{
424 + log: log,
425 + prof: prof,
426 + existingNames: existingNames,
427 + sourceToSinks: make(map[vmetricsSourceKey][]vmetricsSink),
428 + aggregators: make([]*vmetricsAggregator, 0, len(prof.VirtualMetrics)),
429 + }
430 +}
431 +
432 +func (b *aggregatorsBuilder) build() (map[vmetricsSourceKey][]vmetricsSink, []*vmetricsAggregator) {
433 + for _, cfg := range b.prof.VirtualMetrics {
434 + b.buildOne(cfg)
435 + }
436 + return b.sourceToSinks, b.aggregators
437 +}
438 +
439 +func (b *aggregatorsBuilder) buildOne(cfg ddprofiledefinition.VirtualMetricConfig) {
440 + if b.existingNames[cfg.Name] {
441 + b.log.Warningf("virtual metric '%s' conflicts with existing metric, skipping", cfg.Name)
442 + return
443 + }
444 + if len(cfg.Sources) == 0 && len(cfg.Alternatives) == 0 {
445 + b.log.Warningf("virtual metric '%s' has no sources or alternatives; skipping", cfg.Name)
446 + return
447 + }
448 + if len(cfg.Sources) > 0 && len(cfg.Alternatives) > 0 {
449 + b.log.Warningf("virtual metric '%s' defines both 'sources' and 'alternatives'; using 'alternatives' only", cfg.Name)
450 + }
451 + if len(cfg.Alternatives) == 0 {
452 + b.buildPlain(cfg)
453 + } else {
454 + b.buildWithAlternatives(cfg)
455 + }
456 +}
457 +
458 +func (b *aggregatorsBuilder) buildPlain(cfg ddprofiledefinition.VirtualMetricConfig) {
459 + agg, isComposite, dimIdxByName, ok := b.prepareAggregator(cfg, cfg.Sources)
460 + if !ok {
461 + return
462 + }
463 + b.bindSinks(agg, cfg.Sources, dimIdxByName, isComposite)
464 + b.aggregators = append(b.aggregators, agg)
465 +}
466 +
467 +func (b *aggregatorsBuilder) buildWithAlternatives(cfg ddprofiledefinition.VirtualMetricConfig) {
468 + parent := &vmetricsAggregator{config: cfg}
469 +
470 + for _, alt := range cfg.Alternatives {
471 + childCfg := cfg.Clone()
472 + childCfg.Sources = slices.Clone(alt.Sources)
473 + childCfg.Alternatives = nil
474 +
475 + child, isComposite, dimIdxByName, ok := b.prepareAggregator(childCfg, childCfg.Sources)
476 + if !ok {
477 + continue
478 + }
479 + b.bindSinks(child, childCfg.Sources, dimIdxByName, isComposite)
480 + parent.alts = append(parent.alts, child)
481 + }
482 +
483 + if len(parent.alts) == 0 {
484 + b.log.Warningf("virtual metric '%s' has no valid alternatives; skipping", cfg.Name)
485 + return
486 + }
487 + b.aggregators = append(b.aggregators, parent)
488 +}
489 +
490 +func (b *aggregatorsBuilder) sameTableOrEmpty(sources []ddprofiledefinition.VirtualMetricSourceConfig) (table string, ok bool) {
491 + for i, s := range sources {
492 + if i == 0 {
493 + table = s.Table
494 + continue
495 + }
496 + if s.Table != table {
497 + return "", false
498 + }
499 + }
500 + return table, table != ""
501 +}
502 +
503 +func (b *aggregatorsBuilder) computeDimIndexMap(sources []ddprofiledefinition.VirtualMetricSourceConfig) (isComposite bool, dimIdxByName map[string]int, dimNames []string) {
504 + if len(sources) > 1 {
505 + for _, s := range sources {
506 + if s.As != "" {
507 + isComposite = true
508 + break
509 + }
510 + }
511 + }
512 + if !isComposite {
513 + return
514 + }
515 + dimIdxByName = make(map[string]int, len(sources))
516 + dimNames = make([]string, 0, len(sources))
517 + for _, s := range sources {
518 + name := s.As
519 + if name == "" {
520 + name = s.Metric
521 + }
522 + if _, exists := dimIdxByName[name]; !exists {
523 + dimIdxByName[name] = len(dimNames)
524 + dimNames = append(dimNames, name)
525 + }
526 + }
527 + return
528 +}
529 +
530 +func (b *aggregatorsBuilder) prepareAggregator(
531 + cfg ddprofiledefinition.VirtualMetricConfig,
532 + sources []ddprofiledefinition.VirtualMetricSourceConfig,
533 +) (agg *vmetricsAggregator, isComposite bool, dimIdxByName map[string]int, ok bool) {
534 + agg = &vmetricsAggregator{config: cfg}
535 +
536 + agg.grouped = cfg.PerRow || len(cfg.GroupBy) > 0
537 + if agg.grouped {
538 + table, same := b.sameTableOrEmpty(sources)
539 + if !same {
540 + b.log.Warningf("virtual metric '%s' uses group_by/per_row but sources span tables or have no table; skipping (no joins yet)", cfg.Name)
541 + return nil, false, nil, false
542 + }
543 + agg.perRow = cfg.PerRow
544 + agg.groupBy = cfg.GroupBy
545 + agg.groupTable = table
546 + agg.perGroup = make(map[string]*vmetricsGroupBucket, 64)
547 + }
548 +
549 + isComposite, dimIdxByName, dimNames := b.computeDimIndexMap(sources)
550 + if isComposite {
551 + agg.dimNames = dimNames
552 + }
553 + return agg, isComposite, dimIdxByName, true
554 +}
555 +
556 +func (b *aggregatorsBuilder) bindSinks(
557 + agg *vmetricsAggregator,
558 + sources []ddprofiledefinition.VirtualMetricSourceConfig,
559 + dimIdxByName map[string]int, isComposite bool,
560 +) {
561 + for _, src := range sources {
562 + key := vmetricsSourceKey{metricName: src.Metric, tableName: src.Table}
563 + dimIdx := int16(-1)
564 + if isComposite {
565 + name := src.As
566 + if name == "" {
567 + name = src.Metric
568 + }
569 + if idx, ok := dimIdxByName[name]; ok {
570 + dimIdx = int16(idx)
571 + }
572 + }
573 + b.sourceToSinks[key] = append(b.sourceToSinks[key], vmetricsSink{agg: agg, dimIdx: dimIdx})
574 + }
575 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics_test.go
+262
@@ -1174,6 +1174,268 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1174 },
1175 expected: []ddsnmp.Metric{}, // VM skipped in build phase
1176 },
1177 +
1178 + "alternatives prefer 64-bit ifXTable over 32-bit ifTable": {
1179 + profileDef: &ddprofiledefinition.ProfileDefinition{
1180 + Metrics: []ddprofiledefinition.MetricsConfig{
1181 + {
1182 + Table: ddprofiledefinition.SymbolConfig{Name: "ifXTable"},
1183 + Symbols: []ddprofiledefinition.SymbolConfig{
1184 + {Name: "ifHCInOctets"},
1185 + },
1186 + },
1187 + {
1188 + Table: ddprofiledefinition.SymbolConfig{Name: "ifTable"},
1189 + Symbols: []ddprofiledefinition.SymbolConfig{
1190 + {Name: "ifInOctets"},
1191 + },
1192 + },
1193 + },
1194 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1195 + {
1196 + Name: "ifTotalTrafficIn",
1197 + Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1198 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1199 + {Metric: "ifHCInOctets", Table: "ifXTable"},
1200 + }},
1201 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1202 + {Metric: "ifInOctets", Table: "ifTable"},
1203 + }},
1204 + },
1205 + ChartMeta: ddprofiledefinition.ChartMeta{
1206 + Description: "Total inbound traffic across all interfaces",
1207 + Family: "Network/Total/Traffic/In",
1208 + Unit: "bit/s",
1209 + },
1210 + },
1211 + },
1212 + },
1213 + collectedMetrics: []ddsnmp.Metric{
1214 + // both present; should choose ifXTable (64-bit) only
1215 + {Name: "ifHCInOctets", Value: 1000, IsTable: true, Table: "ifXTable"},
1216 + {Name: "ifHCInOctets", Value: 3000, IsTable: true, Table: "ifXTable"},
1217 + {Name: "ifInOctets", Value: 500, IsTable: true, Table: "ifTable"},
1218 + {Name: "ifInOctets", Value: 700, IsTable: true, Table: "ifTable"},
1219 + },
1220 + expected: []ddsnmp.Metric{
1221 + {
1222 + Name: "ifTotalTrafficIn",
1223 + Value: 4000, // 1000 + 3000 (32-bit ignored)
1224 + Description: "Total inbound traffic across all interfaces",
1225 + Family: "Network/Total/Traffic/In",
1226 + Unit: "bit/s",
1227 + },
1228 + },
1229 + },
1230 +
1231 + // fallback to second alternative (32-bit) when 64-bit missing
1232 + "alternatives fallback to 32-bit if only ifTable present": {
1233 + profileDef: &ddprofiledefinition.ProfileDefinition{
1234 + Metrics: []ddprofiledefinition.MetricsConfig{
1235 + {
1236 + Table: ddprofiledefinition.SymbolConfig{Name: "ifTable"},
1237 + Symbols: []ddprofiledefinition.SymbolConfig{
1238 + {Name: "ifInOctets"},
1239 + },
1240 + },
1241 + },
1242 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1243 + {
1244 + Name: "ifTotalTrafficIn",
1245 + Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1246 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1247 + {Metric: "ifHCInOctets", Table: "ifXTable"},
1248 + }},
1249 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1250 + {Metric: "ifInOctets", Table: "ifTable"},
1251 + }},
1252 + },
1253 + ChartMeta: ddprofiledefinition.ChartMeta{
1254 + Description: "Total inbound traffic across all interfaces",
1255 + Family: "Network/Total/Traffic/In",
1256 + Unit: "bit/s",
1257 + },
1258 + },
1259 + },
1260 + },
1261 + collectedMetrics: []ddsnmp.Metric{
1262 + // only 32-bit values exist
1263 + {Name: "ifInOctets", Value: 111, IsTable: true, Table: "ifTable"},
1264 + {Name: "ifInOctets", Value: 222, IsTable: true, Table: "ifTable"},
1265 + },
1266 + expected: []ddsnmp.Metric{
1267 + {
1268 + Name: "ifTotalTrafficIn",
1269 + Value: 333,
1270 + Description: "Total inbound traffic across all interfaces",
1271 + Family: "Network/Total/Traffic/In",
1272 + Unit: "bit/s",
1273 + },
1274 + },
1275 + },
1276 +
1277 + // composite dims: choose first alt that has *any* data, emit present dims only
1278 + "alternatives composite dims: pick 64-bit alt even if only one dim present": {
1279 + profileDef: &ddprofiledefinition.ProfileDefinition{
1280 + Metrics: []ddprofiledefinition.MetricsConfig{
1281 + {
1282 + Table: ddprofiledefinition.SymbolConfig{Name: "ifXTable"},
1283 + Symbols: []ddprofiledefinition.SymbolConfig{
1284 + {Name: "ifHCInOctets"},
1285 + // ifHCOutOctets intentionally not defined/collected
1286 + },
1287 + },
1288 + {
1289 + Table: ddprofiledefinition.SymbolConfig{Name: "ifTable"},
1290 + Symbols: []ddprofiledefinition.SymbolConfig{
1291 + {Name: "ifInOctets"},
1292 + {Name: "ifOutOctets"},
1293 + },
1294 + },
1295 + },
1296 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1297 + {
1298 + Name: "ifTotalTraffic",
1299 + Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1300 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1301 + {Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
1302 + {Metric: "ifHCOutOctets", Table: "ifXTable", As: "out"},
1303 + }},
1304 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1305 + {Metric: "ifInOctets", Table: "ifTable", As: "in"},
1306 + {Metric: "ifOutOctets", Table: "ifTable", As: "out"},
1307 + }},
1308 + },
1309 + ChartMeta: ddprofiledefinition.ChartMeta{
1310 + Description: "Total traffic by direction",
1311 + Family: "Network/Total/Traffic",
1312 + Unit: "bit/s",
1313 + },
1314 + },
1315 + },
1316 + },
1317 + collectedMetrics: []ddsnmp.Metric{
1318 + // only 64-bit IN present → first alt has data → choose it; OUT dimension omitted
1319 + {Name: "ifHCInOctets", Value: 9001, IsTable: true, Table: "ifXTable"},
1320 + // 32-bit values exist but must be ignored because first alt had data
1321 + {Name: "ifInOctets", Value: 1, IsTable: true, Table: "ifTable"},
1322 + {Name: "ifOutOctets", Value: 2, IsTable: true, Table: "ifTable"},
1323 + },
1324 + expected: []ddsnmp.Metric{
1325 + {
1326 + Name: "ifTotalTraffic",
1327 + MultiValue: map[string]int64{"in": 9001}, // only present dim
1328 + Description: "Total traffic by direction",
1329 + Family: "Network/Total/Traffic",
1330 + Unit: "bit/s",
1331 + },
1332 + },
1333 + },
1334 +
1335 + // no child alternative received data → VM skipped
1336 + "alternatives: no child has data -> no metric": {
1337 + profileDef: &ddprofiledefinition.ProfileDefinition{
1338 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1339 + {
1340 + Name: "ifTotalTrafficIn",
1341 + Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1342 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1343 + {Metric: "ifHCInOctets", Table: "ifXTable"},
1344 + }},
1345 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1346 + {Metric: "ifInOctets", Table: "ifTable"},
1347 + }},
1348 + },
1349 + ChartMeta: ddprofiledefinition.ChartMeta{Description: "Total inbound"},
1350 + },
1351 + },
1352 + },
1353 + collectedMetrics: nil,
1354 + expected: nil,
1355 + },
1356 +
1357 + // grouped/per_row: enforce same-table per alternative; bad child skipped, good child used
1358 + "alternatives: grouped per_row single-table rule enforced per child": {
1359 + profileDef: &ddprofiledefinition.ProfileDefinition{
1360 + Metrics: []ddprofiledefinition.MetricsConfig{
1361 + {
1362 + Table: ddprofiledefinition.SymbolConfig{Name: "ifXTable"},
1363 + Symbols: []ddprofiledefinition.SymbolConfig{
1364 + {Name: "ifHCInOctets"},
1365 + {Name: "ifHCOutOctets"},
1366 + },
1367 + },
1368 + },
1369 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1370 + {
1371 + Name: "ifTrafficPerRow",
1372 + PerRow: true,
1373 + // child #1 (bad): spans tables -> should be rejected by builder
1374 + Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1375 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1376 + {Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
1377 + {Metric: "ifOutOctets", Table: "ifTable", As: "out"}, // wrong table on purpose
1378 + }},
1379 + // child #2 (good): single table
1380 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1381 + {Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
1382 + {Metric: "ifHCOutOctets", Table: "ifXTable", As: "out"},
1383 + }},
1384 + },
1385 + ChartMeta: ddprofiledefinition.ChartMeta{
1386 + Description: "Per-row traffic (in/out)",
1387 + Family: "Network/Interface/Traffic",
1388 + Unit: "bit/s",
1389 + },
1390 + },
1391 + },
1392 + },
1393 + collectedMetrics: []ddsnmp.Metric{
1394 + {Name: "ifHCInOctets", Value: 10, IsTable: true, Table: "ifXTable",
1395 + Tags: map[string]string{"interface": "eth0", "ifType": "ethernetCsmacd", "ifIndex": "1"}},
1396 + {Name: "ifHCOutOctets", Value: 20, IsTable: true, Table: "ifXTable",
1397 + Tags: map[string]string{"interface": "eth0", "ifType": "ethernetCsmacd", "ifIndex": "1"}},
1398 + },
1399 + expected: []ddsnmp.Metric{
1400 + {
1401 + Name: "ifTrafficPerRow",
1402 + IsTable: true,
1403 + Table: "ifXTable",
1404 + Tags: map[string]string{"interface": "eth0", "ifType": "ethernetCsmacd", "ifIndex": "1"},
1405 + MultiValue: map[string]int64{"in": 10, "out": 20},
1406 + Description: "Per-row traffic (in/out)",
1407 + Family: "Network/Interface/Traffic",
1408 + Unit: "bit/s",
1409 + },
1410 + },
1411 + },
1412 +
1413 + // if both sources and alternatives are present, alternatives win (warning logged, deterministic)
1414 + "alternatives preferred over sources when both defined": {
1415 + profileDef: &ddprofiledefinition.ProfileDefinition{
1416 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1417 + {
1418 + Name: "ifTotalTrafficIn",
1419 + Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1420 + {Metric: "ifInOctets", Table: "ifTable"}, // would sum 32-bit
1421 + },
1422 + Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1423 + {Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1424 + {Metric: "ifHCInOctets", Table: "ifXTable"}, // preferred 64-bit
1425 + }},
1426 + },
1427 + ChartMeta: ddprofiledefinition.ChartMeta{Description: "Total inbound"},
1428 + },
1429 + },
1430 + },
1431 + collectedMetrics: []ddsnmp.Metric{
1432 + {Name: "ifHCInOctets", Value: 123, IsTable: true, Table: "ifXTable"},
1433 + {Name: "ifInOctets", Value: 999, IsTable: true, Table: "ifTable"},
1434 + },
1435 + expected: []ddsnmp.Metric{
1436 + {Name: "ifTotalTrafficIn", Value: 123, Description: "Total inbound"},
1437 + },
1438 + },
1439 }
1440
1441 for name, tc := range tests {