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 {