go.d/snmp: extend profile engine contract (#22177)
Costa Tsaousis committed
Apr 10, 2026 at 04:39 UTC
c08124dea8fd725c1784f0c19951140f0ee09c32
34 files changed
+3427
-220
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metadata_test.go
+6
@@ -53,6 +53,12 @@ func makeMetadata() MetadataConfig {
53
ExtractValue: ".*",
54
ExtractValueCompiled: regexp.MustCompile(".*"),
55
},
56
+ LookupSymbol: SymbolConfigCompat{
57
+ OID: "9.8.7",
58
+ Name: "lookupSymbol",
59
+ ExtractValue: ".*",
60
+ ExtractValueCompiled: regexp.MustCompile(".*"),
61
+ },
62
IndexTransform: []MetricIndexTransform{
63
{
64
Start: 1,
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metrics.go
+9
-2
@@ -176,6 +176,11 @@ type MetricTagConfig struct {
176
// pattern is deprecated
177
Symbol SymbolConfigCompat `yaml:"symbol,omitempty" json:"symbol"`
178
179
+ // LookupSymbol optionally resolves cross-table tags by matching a value from the
180
+ // current row index against a column in the referenced table, then reading Symbol
181
+ // from the matched row in that table.
182
+ LookupSymbol SymbolConfigCompat `yaml:"lookup_symbol,omitempty" json:"lookup_symbol,omitempty"`
183
+
184
IndexTransform []MetricIndexTransform `yaml:"index_transform,omitempty" json:"index_transform,omitempty"`
185
186
MappingRef string `yaml:"mapping_ref,omitempty" json:"mapping_ref,omitempty"`
@@ -196,6 +201,7 @@ func (m MetricTagConfig) Clone() MetricTagConfig {
201
// deep copy symbols and structures
202
m2.Column = m.Column.Clone()
203
m2.Symbol = m.Symbol.Clone()
204
+ m2.LookupSymbol = m.LookupSymbol.Clone()
205
m2.IndexTransform = slices.Clone(m.IndexTransform)
206
m2.Mapping = maps.Clone(m.Mapping)
207
m2.Tags = maps.Clone(m.Tags)
@@ -219,8 +225,9 @@ type MetricTagConfigList []MetricTagConfig
225
226
// MetricIndexTransform holds configs for metric index transform
227
type MetricIndexTransform struct {
222
- Start uint `yaml:"start" json:"start"`
223
- End uint `yaml:"end" json:"end"`
228
+ Start uint `yaml:"start" json:"start"`
229
+ End uint `yaml:"end" json:"end"`
230
+ DropRight uint `yaml:"drop_right,omitempty" json:"drop_right,omitempty"`
231
}
232
233
// MetricsConfigOption holds config for metrics options
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metrics_test.go
+12
-2
@@ -63,6 +63,11 @@ func TestCloneMetricTagConfig(t *testing.T) {
63
},
64
OID: "2.4",
65
Symbol: SymbolConfigCompat{},
66
+ LookupSymbol: SymbolConfigCompat{
67
+ OID: "2.4.6.8",
68
+ ExtractValue: ".*",
69
+ ExtractValueCompiled: regexp.MustCompile(".*"),
70
+ },
71
IndexTransform: []MetricIndexTransform{
72
{
73
Start: 0,
@@ -83,7 +88,7 @@ func TestCloneMetricTagConfig(t *testing.T) {
88
c2 := c.Clone()
89
assert.Equal(t, c, c2)
90
c2.Tags["bar"] = "$2"
86
- c2.IndexTransform = append(c2.IndexTransform, MetricIndexTransform{1, 3})
91
+ c2.IndexTransform = append(c2.IndexTransform, MetricIndexTransform{Start: 1, End: 3})
92
c2.Mapping["3"] = "foo"
93
c2.Tag = "bar"
94
assert.NotEqual(t, c, c2)
@@ -98,6 +103,11 @@ func TestCloneMetricTagConfig(t *testing.T) {
103
},
104
OID: "2.4",
105
Symbol: SymbolConfigCompat{},
106
+ LookupSymbol: SymbolConfigCompat{
107
+ OID: "2.4.6.8",
108
+ ExtractValue: ".*",
109
+ ExtractValueCompiled: regexp.MustCompile(".*"),
110
+ },
111
IndexTransform: []MetricIndexTransform{
112
{
113
Start: 0,
@@ -161,7 +171,7 @@ func TestCloneMetricsConfig(t *testing.T) {
171
conf2 := conf.Clone()
172
assert.Equal(t, conf, conf2)
173
conf2.StaticTags[0] = StaticMetricTagConfig{Tag: "bar", Value: "baz"}
164
- conf2.MetricTags[0].IndexTransform = []MetricIndexTransform{{5, 7}}
174
+ conf2.MetricTags[0].IndexTransform = []MetricIndexTransform{{Start: 5, End: 7}}
175
conf2.Options.Placement = 2
176
conf2.Options.MetricSuffix = ".bar"
177
assert.Equal(t, unchanged, conf)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation.go
+188
-4
@@ -10,6 +10,7 @@ import (
10
"fmt"
11
"regexp"
12
"slices"
13
+ "strings"
14
)
15
16
var validMetadataResources = map[string]map[string]bool{
@@ -59,6 +60,7 @@ func ValidateEnrichProfile(p *ProfileDefinition) error {
60
validateEnrichSysobjectIDMetadata(p.SysobjectIDMetadata),
61
validateEnrichMetrics(p.Metrics),
62
validateEnrichMetricTags(p.MetricTags),
63
+ validateEnrichVirtualMetrics(p.Metrics, p.VirtualMetrics),
64
}
65
66
return errors.Join(errs...)
@@ -315,11 +317,41 @@ func validateEnrichMetricTag(metricTag *MetricTagConfig) error {
317
metricTag.Symbol.OID = metricTag.OID
318
metricTag.OID = ""
319
}
318
- if metricTag.Symbol.OID != "" || metricTag.Symbol.Name != "" {
320
+ if isRawIndexMetricTag(*metricTag) {
321
+ symbol := SymbolConfig(metricTag.Symbol)
322
+ if symbol.Name == "" && metricTag.Tag == "" {
323
+ errs = append(errs, errors.New("raw index metric tag requires `tag` or `symbol.name`"))
324
+ }
325
+ if symbol.ExtractValue != "" {
326
+ pattern, err := regexp.Compile(symbol.ExtractValue)
327
+ if err != nil {
328
+ errs = append(errs, fmt.Errorf("cannot compile `extract_value` (%s): %s", symbol.ExtractValue, err.Error()))
329
+ } else {
330
+ symbol.ExtractValueCompiled = pattern
331
+ }
332
+ }
333
+ if symbol.MatchPattern != "" {
334
+ pattern, err := regexp.Compile(symbol.MatchPattern)
335
+ if err != nil {
336
+ errs = append(errs, fmt.Errorf("cannot compile `match_pattern` (%s): %s", symbol.MatchPattern, err.Error()))
337
+ } else {
338
+ symbol.MatchPatternCompiled = pattern
339
+ }
340
+ }
341
+ metricTag.Symbol = SymbolConfigCompat(symbol)
342
+ } else if metricTag.Symbol.OID != "" || metricTag.Symbol.Name != "" {
343
symbol := SymbolConfig(metricTag.Symbol)
344
errs = append(errs, validateEnrichSymbol(&symbol, MetricTagSymbol))
345
metricTag.Symbol = SymbolConfigCompat(symbol)
346
}
347
+ if metricTag.LookupSymbol.OID != "" || metricTag.LookupSymbol.Name != "" {
348
+ symbol := SymbolConfig(metricTag.LookupSymbol)
349
+ errs = append(errs, validateEnrichSymbol(&symbol, MetricTagSymbol))
350
+ metricTag.LookupSymbol = SymbolConfigCompat(symbol)
351
+ if metricTag.Table == "" {
352
+ errs = append(errs, errors.New("`lookup_symbol` requires `table`"))
353
+ }
354
+ }
355
if metricTag.Match != "" {
356
pattern, err := regexp.Compile(metricTag.Match)
357
if err != nil {
@@ -331,14 +363,166 @@ func validateEnrichMetricTag(metricTag *MetricTagConfig) error {
363
errs = append(errs, fmt.Errorf("`tags` mapping must be provided if `match` (`%s`) is defined", metricTag.Match))
364
}
365
}
334
- if len(metricTag.Mapping) > 0 && metricTag.Tag == "" {
335
- errs = append(errs, fmt.Errorf("``tag` must be provided if `mapping` (`%s`) is defined", metricTag.Mapping))
366
+ if len(metricTag.Mapping) > 0 && metricTag.Tag == "" && metricTag.Symbol.Name == "" {
367
+ errs = append(errs, fmt.Errorf("`tag` or `symbol.name` must be provided if `mapping` (`%v`) is defined", metricTag.Mapping))
368
}
369
for _, transform := range metricTag.IndexTransform {
338
- if transform.Start > transform.End {
370
+ if transform.DropRight != 0 && transform.End != 0 {
371
+ errs = append(errs, fmt.Errorf("transform rule cannot define both end and drop_right. Invalid rule: %#v", transform))
372
+ }
373
+ if transform.DropRight == 0 && transform.Start > transform.End {
374
errs = append(errs, fmt.Errorf("transform rule end should be greater than start. Invalid rule: %#v", transform))
375
}
376
}
377
378
return errors.Join(errs...)
379
}
380
+
381
+func isRawIndexMetricTag(metricTag MetricTagConfig) bool {
382
+ if metricTag.Table != "" || metricTag.Symbol.OID != "" {
383
+ return false
384
+ }
385
+
386
+ if metricTag.Index != 0 {
387
+ return metricTag.Symbol.Format != "" ||
388
+ metricTag.Symbol.ExtractValue != "" ||
389
+ metricTag.Symbol.MatchPattern != "" ||
390
+ len(metricTag.Mapping) > 0
391
+ }
392
+
393
+ return len(metricTag.IndexTransform) > 0 ||
394
+ metricTag.Symbol.Format != "" ||
395
+ metricTag.Symbol.ExtractValue != "" ||
396
+ metricTag.Symbol.MatchPattern != "" ||
397
+ len(metricTag.Mapping) > 0
398
+}
399
+
400
+func validateEnrichVirtualMetrics(metrics []MetricsConfig, vmetrics []VirtualMetricConfig) error {
401
+ var errs []error
402
+
403
+ metricSources := collectVirtualMetricSourceSpecs(metrics)
404
+
405
+ seenNames := make(map[string]int)
406
+
407
+ for i := range vmetrics {
408
+ vm := &vmetrics[i]
409
+
410
+ if vm.Name == "" {
411
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d]: missing name", i))
412
+ } else {
413
+ if firstIdx, ok := seenNames[vm.Name]; ok {
414
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d]: duplicate name %q (first occurrence at index %d)", i, vm.Name, firstIdx))
415
+ } else {
416
+ seenNames[vm.Name] = i
417
+ }
418
+ if _, ok := metricSources[vm.Name]; ok {
419
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d]: name %q conflicts with an existing metric", i, vm.Name))
420
+ }
421
+ }
422
+
423
+ for j, label := range vm.GroupBy {
424
+ if label == "" {
425
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d].group_by[%d]: label cannot be empty", i, j))
426
+ }
427
+ }
428
+
429
+ for j, emitTag := range vm.EmitTags {
430
+ if emitTag.Tag == "" {
431
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d].emit_tags[%d]: missing tag", i, j))
432
+ }
433
+ if emitTag.From == "" {
434
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d].emit_tags[%d]: missing from", i, j))
435
+ }
436
+ }
437
+
438
+ grouped := vm.PerRow || len(vm.GroupBy) > 0
439
+
440
+ switch {
441
+ case len(vm.Sources) == 0 && len(vm.Alternatives) == 0:
442
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d]: must define sources or alternatives", i))
443
+ case len(vm.Alternatives) == 0:
444
+ errs = append(errs, validateVirtualMetricSources(fmt.Sprintf("virtual_metrics[%d].sources", i), vm.Sources, metricSources, grouped))
445
+ default:
446
+ for j, alt := range vm.Alternatives {
447
+ if len(alt.Sources) == 0 {
448
+ errs = append(errs, fmt.Errorf("virtual_metrics[%d].alternatives[%d]: must define sources", i, j))
449
+ continue
450
+ }
451
+ errs = append(errs, validateVirtualMetricSources(fmt.Sprintf("virtual_metrics[%d].alternatives[%d].sources", i, j), alt.Sources, metricSources, grouped))
452
+ }
453
+ }
454
+ }
455
+
456
+ return errors.Join(errs...)
457
+}
458
+
459
+func validateVirtualMetricSources(path string, sources []VirtualMetricSourceConfig, metricSources map[string]map[string]virtualMetricSourceSpec, grouped bool) error {
460
+ var errs []error
461
+
462
+ var groupTable string
463
+ for i, src := range sources {
464
+ if src.Metric == "" {
465
+ errs = append(errs, fmt.Errorf("%s[%d]: missing metric", path, i))
466
+ }
467
+ if grouped && src.Table == "" {
468
+ errs = append(errs, fmt.Errorf("%s[%d]: missing table", path, i))
469
+ }
470
+
471
+ if src.Metric != "" {
472
+ tables, ok := metricSources[src.Metric]
473
+ switch {
474
+ case !ok:
475
+ errs = append(errs, fmt.Errorf("%s[%d]: unknown metric source %q", path, i, src.Metric))
476
+ case src.Table == "":
477
+ if _, ok := tables[""]; !ok {
478
+ errs = append(errs, fmt.Errorf("%s[%d]: missing table for non-scalar source %q", path, i, src.Metric))
479
+ }
480
+ default:
481
+ if _, ok := tables[src.Table]; !ok {
482
+ errs = append(errs, fmt.Errorf("%s[%d]: unknown metric/table source %q/%q", path, i, src.Metric, src.Table))
483
+ }
484
+ }
485
+ }
486
+
487
+ if src.Dim != "" && src.Metric != "" {
488
+ tables, ok := metricSources[src.Metric]
489
+ if !ok {
490
+ continue
491
+ }
492
+
493
+ spec, ok := tables[src.Table]
494
+ if !ok && src.Table == "" {
495
+ spec, ok = tables[""]
496
+ }
497
+ if !ok {
498
+ continue
499
+ }
500
+
501
+ switch spec.dimSupport.mode {
502
+ case virtualMetricDimUnsupported:
503
+ errs = append(errs, fmt.Errorf("%s[%d]: dim %q requires a MultiValue source metric (%s)", path, i, src.Dim, formatVirtualMetricSourceRef(src)))
504
+ case virtualMetricDimKnown:
505
+ if !spec.dimSupport.dims[src.Dim] {
506
+ errs = append(errs, fmt.Errorf("%s[%d]: dim %q is not available on %s (available: %s)", path, i, src.Dim, formatVirtualMetricSourceRef(src), strings.Join(virtualMetricSourceAvailableDims(spec), ", ")))
507
+ }
508
+ }
509
+ }
510
+
511
+ if grouped && src.Table != "" {
512
+ if groupTable == "" {
513
+ groupTable = src.Table
514
+ } else if src.Table != groupTable {
515
+ errs = append(errs, fmt.Errorf("%s[%d]: grouped virtual metrics require all sources to use the same table (saw %q and %q)", path, i, groupTable, src.Table))
516
+ }
517
+ }
518
+ }
519
+
520
+ return errors.Join(errs...)
521
+}
522
+
523
+func formatVirtualMetricSourceRef(src VirtualMetricSourceConfig) string {
524
+ if src.Table == "" {
525
+ return fmt.Sprintf("metric %q", src.Metric)
526
+ }
527
+ return fmt.Sprintf("metric/table %q/%q", src.Metric, src.Table)
528
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation_test.go
+335
-2
@@ -434,8 +434,8 @@ func Test_validateEnrichMetrics(t *testing.T) {
434
},
435
},
436
},
437
- "mapping used without tag": {
438
- wantError: true,
437
+ "mapping used with symbol.name and no explicit tag": {
438
+ wantError: false,
439
metrics: []MetricsConfig{
440
{
441
Symbols: []SymbolConfig{
@@ -474,6 +474,310 @@ func Test_validateEnrichMetrics(t *testing.T) {
474
}
475
}
476
477
+func Test_validateEnrichMetricTag_MappingErrorUsesReadableFormat(t *testing.T) {
478
+ tag := MetricTagConfig{
479
+ Mapping: map[string]string{
480
+ "1": "up",
481
+ },
482
+ }
483
+
484
+ err := validateEnrichMetricTag(&tag)
485
+
486
+ if assert.Error(t, err) {
487
+ assert.Contains(t, err.Error(), "map[1:up]")
488
+ assert.NotContains(t, err.Error(), "%!s")
489
+ }
490
+}
491
+
492
+func Test_validateEnrichVirtualMetrics(t *testing.T) {
493
+ baseMetrics := []MetricsConfig{
494
+ {
495
+ Table: SymbolConfig{
496
+ OID: "1.3.6.1.2.1.31.1.1",
497
+ Name: "ifXTable",
498
+ },
499
+ Symbols: []SymbolConfig{
500
+ {OID: "1.3.6.1.2.1.31.1.1.1.6", Name: "ifHCInOctets"},
501
+ {OID: "1.3.6.1.2.1.31.1.1.1.10", Name: "ifHCOutOctets"},
502
+ },
503
+ MetricTags: MetricTagConfigList{
504
+ {Tag: "interface", Index: 1},
505
+ },
506
+ },
507
+ {
508
+ Table: SymbolConfig{
509
+ OID: "1.3.6.1.2.1.2.2",
510
+ Name: "ifTable",
511
+ },
512
+ Symbols: []SymbolConfig{
513
+ {OID: "1.3.6.1.2.1.2.2.1.14", Name: "ifInErrors"},
514
+ },
515
+ MetricTags: MetricTagConfigList{
516
+ {Tag: "interface", Index: 1},
517
+ },
518
+ },
519
+ }
520
+
521
+ tests := map[string]struct {
522
+ metrics []MetricsConfig
523
+ virtualMetrics []VirtualMetricConfig
524
+ wantErrContains []string
525
+ }{
526
+ "valid grouped virtual metric": {
527
+ metrics: baseMetrics,
528
+ virtualMetrics: []VirtualMetricConfig{
529
+ {
530
+ Name: "ifTraffic",
531
+ PerRow: true,
532
+ GroupBy: []string{"interface"},
533
+ Sources: []VirtualMetricSourceConfig{
534
+ {Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
535
+ {Metric: "ifHCOutOctets", Table: "ifXTable", As: "out"},
536
+ },
537
+ EmitTags: []VirtualMetricEmitTagConfig{
538
+ {Tag: "interface", From: "interface"},
539
+ },
540
+ },
541
+ },
542
+ },
543
+ "valid scalar total without table": {
544
+ metrics: append(baseMetrics, MetricsConfig{
545
+ Symbol: SymbolConfig{
546
+ OID: "1.3.6.1.4.1.2021.11.50.0",
547
+ Name: "_ucd.ssCpuRawUser",
548
+ },
549
+ }),
550
+ virtualMetrics: []VirtualMetricConfig{
551
+ {
552
+ Name: "ucd.cpuUsage",
553
+ Sources: []VirtualMetricSourceConfig{
554
+ {Metric: "_ucd.ssCpuRawUser", As: "user"},
555
+ },
556
+ },
557
+ },
558
+ },
559
+ "valid mapped source dim": {
560
+ metrics: append(baseMetrics, MetricsConfig{
561
+ Table: SymbolConfig{
562
+ OID: "1.3.6.1.2.1.15.3",
563
+ Name: "bgpPeerTable",
564
+ },
565
+ Symbols: []SymbolConfig{
566
+ {
567
+ OID: "1.3.6.1.2.1.15.3.1.2",
568
+ Name: "bgpPeerAdminStatus",
569
+ Mapping: map[string]string{
570
+ "1": "stop",
571
+ "2": "start",
572
+ },
573
+ },
574
+ },
575
+ MetricTags: MetricTagConfigList{
576
+ {Tag: "neighbor", Index: 1},
577
+ },
578
+ }),
579
+ virtualMetrics: []VirtualMetricConfig{
580
+ {
581
+ Name: "bgpPeerAvailability",
582
+ PerRow: true,
583
+ Sources: []VirtualMetricSourceConfig{
584
+ {Metric: "bgpPeerAdminStatus", Table: "bgpPeerTable", As: "admin_enabled", Dim: "start"},
585
+ },
586
+ },
587
+ },
588
+ },
589
+ "invalid mapped source dim": {
590
+ metrics: append(baseMetrics, MetricsConfig{
591
+ Table: SymbolConfig{
592
+ OID: "1.3.6.1.2.1.15.3",
593
+ Name: "bgpPeerTable",
594
+ },
595
+ Symbols: []SymbolConfig{
596
+ {
597
+ OID: "1.3.6.1.2.1.15.3.1.2",
598
+ Name: "bgpPeerAdminStatus",
599
+ Mapping: map[string]string{
600
+ "1": "stop",
601
+ "2": "start",
602
+ },
603
+ },
604
+ },
605
+ MetricTags: MetricTagConfigList{
606
+ {Tag: "neighbor", Index: 1},
607
+ },
608
+ }),
609
+ virtualMetrics: []VirtualMetricConfig{
610
+ {
611
+ Name: "bgpPeerAvailability",
612
+ PerRow: true,
613
+ Sources: []VirtualMetricSourceConfig{
614
+ {Metric: "bgpPeerAdminStatus", Table: "bgpPeerTable", As: "admin_enabled", Dim: "running"},
615
+ },
616
+ },
617
+ },
618
+ wantErrContains: []string{
619
+ `virtual_metrics[0].sources[0]: dim "running" is not available on metric/table "bgpPeerAdminStatus"/"bgpPeerTable" (available: start, stop)`,
620
+ },
621
+ },
622
+ "dim requires multivalue source": {
623
+ metrics: baseMetrics,
624
+ virtualMetrics: []VirtualMetricConfig{
625
+ {
626
+ Name: "ifTraffic",
627
+ PerRow: true,
628
+ Sources: []VirtualMetricSourceConfig{
629
+ {Metric: "ifHCInOctets", Table: "ifXTable", As: "in", Dim: "up"},
630
+ },
631
+ },
632
+ },
633
+ wantErrContains: []string{
634
+ `virtual_metrics[0].sources[0]: dim "up" requires a MultiValue source metric (metric/table "ifHCInOctets"/"ifXTable")`,
635
+ },
636
+ },
637
+ "valid transform multivalue source dim": {
638
+ metrics: append(baseMetrics, MetricsConfig{
639
+ Table: SymbolConfig{
640
+ OID: "1.3.6.1.4.1.14988.1.1.3.100",
641
+ Name: "mtxrHlTable",
642
+ },
643
+ Symbols: []SymbolConfig{
644
+ {
645
+ OID: "1.3.6.1.4.1.14988.1.1.3.100.1.3",
646
+ Name: "mtxrHlSensorState",
647
+ Transform: `
648
+{{- setMultivalue .Metric (i64map 0 "down" 1 "up") -}}
649
+`,
650
+ },
651
+ },
652
+ MetricTags: MetricTagConfigList{
653
+ {Tag: "sensor", Index: 1},
654
+ },
655
+ }),
656
+ virtualMetrics: []VirtualMetricConfig{
657
+ {
658
+ Name: "sensorAvailability",
659
+ PerRow: true,
660
+ Sources: []VirtualMetricSourceConfig{
661
+ {Metric: "mtxrHlSensorState", Table: "mtxrHlTable", As: "up", Dim: "up"},
662
+ },
663
+ },
664
+ },
665
+ },
666
+ "invalid transform multivalue source dim": {
667
+ metrics: append(baseMetrics, MetricsConfig{
668
+ Table: SymbolConfig{
669
+ OID: "1.3.6.1.4.1.14988.1.1.3.100",
670
+ Name: "mtxrHlTable",
671
+ },
672
+ Symbols: []SymbolConfig{
673
+ {
674
+ OID: "1.3.6.1.4.1.14988.1.1.3.100.1.3",
675
+ Name: "mtxrHlSensorState",
676
+ Transform: `
677
+{{- setMultivalue .Metric (i64map 0 "down" 1 "up") -}}
678
+`,
679
+ },
680
+ },
681
+ MetricTags: MetricTagConfigList{
682
+ {Tag: "sensor", Index: 1},
683
+ },
684
+ }),
685
+ virtualMetrics: []VirtualMetricConfig{
686
+ {
687
+ Name: "sensorAvailability",
688
+ PerRow: true,
689
+ Sources: []VirtualMetricSourceConfig{
690
+ {Metric: "mtxrHlSensorState", Table: "mtxrHlTable", As: "up", Dim: "idle"},
691
+ },
692
+ },
693
+ },
694
+ wantErrContains: []string{
695
+ `virtual_metrics[0].sources[0]: dim "idle" is not available on metric/table "mtxrHlSensorState"/"mtxrHlTable" (available: down, up)`,
696
+ },
697
+ },
698
+ "missing name and sources": {
699
+ metrics: baseMetrics,
700
+ virtualMetrics: []VirtualMetricConfig{
701
+ {},
702
+ },
703
+ wantErrContains: []string{
704
+ "virtual_metrics[0]: missing name",
705
+ "virtual_metrics[0]: must define sources or alternatives",
706
+ },
707
+ },
708
+ "duplicate name conflicting with metric": {
709
+ metrics: baseMetrics,
710
+ virtualMetrics: []VirtualMetricConfig{
711
+ {Name: "ifTraffic", Sources: []VirtualMetricSourceConfig{{Metric: "ifHCInOctets", Table: "ifXTable"}}},
712
+ {Name: "ifTraffic", Sources: []VirtualMetricSourceConfig{{Metric: "ifHCOutOctets", Table: "ifXTable"}}},
713
+ {Name: "ifInErrors", Sources: []VirtualMetricSourceConfig{{Metric: "ifHCOutOctets", Table: "ifXTable"}}},
714
+ },
715
+ wantErrContains: []string{
716
+ `virtual_metrics[1]: duplicate name "ifTraffic"`,
717
+ `virtual_metrics[2]: name "ifInErrors" conflicts with an existing metric`,
718
+ },
719
+ },
720
+ "invalid grouped sources and emit tags": {
721
+ metrics: baseMetrics,
722
+ virtualMetrics: []VirtualMetricConfig{
723
+ {
724
+ Name: "brokenGrouped",
725
+ PerRow: true,
726
+ GroupBy: []string{"", "interface"},
727
+ Sources: []VirtualMetricSourceConfig{
728
+ {Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
729
+ {Metric: "ifInErrors", Table: "ifTable", As: "out"},
730
+ {Metric: "", Table: "", As: "missing"},
731
+ },
732
+ EmitTags: []VirtualMetricEmitTagConfig{
733
+ {Tag: "", From: "interface"},
734
+ {Tag: "interface", From: ""},
735
+ },
736
+ },
737
+ },
738
+ wantErrContains: []string{
739
+ "virtual_metrics[0].group_by[0]: label cannot be empty",
740
+ "virtual_metrics[0].emit_tags[0]: missing tag",
741
+ "virtual_metrics[0].emit_tags[1]: missing from",
742
+ `virtual_metrics[0].sources[1]: grouped virtual metrics require all sources to use the same table`,
743
+ "virtual_metrics[0].sources[2]: missing metric",
744
+ "virtual_metrics[0].sources[2]: missing table",
745
+ },
746
+ },
747
+ "invalid alternatives": {
748
+ metrics: baseMetrics,
749
+ virtualMetrics: []VirtualMetricConfig{
750
+ {
751
+ Name: "ifTraffic",
752
+ Alternatives: []VirtualMetricAlternativeSourcesConfig{
753
+ {},
754
+ {Sources: []VirtualMetricSourceConfig{{Metric: "missingMetric", Table: "ifXTable"}}},
755
+ },
756
+ },
757
+ },
758
+ wantErrContains: []string{
759
+ "virtual_metrics[0].alternatives[0]: must define sources",
760
+ `virtual_metrics[0].alternatives[1].sources[0]: unknown metric source "missingMetric"`,
761
+ },
762
+ },
763
+ }
764
+
765
+ for name, tt := range tests {
766
+ t.Run(name, func(t *testing.T) {
767
+ err := validateEnrichVirtualMetrics(tt.metrics, tt.virtualMetrics)
768
+ if len(tt.wantErrContains) == 0 {
769
+ assert.NoError(t, err)
770
+ return
771
+ }
772
+
773
+ assert.Error(t, err)
774
+ for _, msg := range tt.wantErrContains {
775
+ assert.ErrorContains(t, err, msg)
776
+ }
777
+ })
778
+ }
779
+}
780
+
781
func Test_validateEnrichMetricTags(t *testing.T) {
782
tests := map[string]struct {
783
metrics []MetricTagConfig
@@ -536,6 +840,35 @@ func Test_validateEnrichMetricTags(t *testing.T) {
840
},
841
},
842
},
843
+ "raw index transform with drop_right": {
844
+ wantError: false,
845
+ metrics: []MetricTagConfig{
846
+ {
847
+ Tag: "remote_addr",
848
+ IndexTransform: []MetricIndexTransform{
849
+ {
850
+ Start: 2,
851
+ DropRight: 2,
852
+ },
853
+ },
854
+ },
855
+ },
856
+ },
857
+ "raw index transform cannot combine end and drop_right": {
858
+ wantError: true,
859
+ metrics: []MetricTagConfig{
860
+ {
861
+ Tag: "remote_addr",
862
+ IndexTransform: []MetricIndexTransform{
863
+ {
864
+ Start: 2,
865
+ End: 6,
866
+ DropRight: 2,
867
+ },
868
+ },
869
+ },
870
+ },
871
+ },
872
}
873
for name, tc := range tests {
874
t.Run(name, func(t *testing.T) {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/virtual_metric_dim_validation.go
new
+183
@@ -0,0 +1,183 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddprofiledefinition
4
+
5
+import (
6
+ "regexp"
7
+ "slices"
8
+ "strconv"
9
+ "strings"
10
+)
11
+
12
+type virtualMetricDimSupportMode uint8
13
+
14
+const (
15
+ virtualMetricDimUnsupported virtualMetricDimSupportMode = iota
16
+ virtualMetricDimKnown
17
+ virtualMetricDimDynamic
18
+)
19
+
20
+type virtualMetricDimSupport struct {
21
+ mode virtualMetricDimSupportMode
22
+ dims map[string]bool
23
+}
24
+
25
+type virtualMetricSourceSpec struct {
26
+ dimSupport virtualMetricDimSupport
27
+}
28
+
29
+var (
30
+ virtualMetricI64MapPattern = regexp.MustCompile(`i64map\b([^)]*)\)`)
31
+ virtualMetricQuotedStringPattern = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
32
+)
33
+
34
+func collectVirtualMetricSourceSpecs(metrics []MetricsConfig) map[string]map[string]virtualMetricSourceSpec {
35
+ specs := make(map[string]map[string]virtualMetricSourceSpec)
36
+
37
+ for _, metric := range metrics {
38
+ switch {
39
+ case metric.IsScalar():
40
+ addVirtualMetricSourceSpec(specs, metric.Symbol.Name, "", buildVirtualMetricSourceSpec(metric.Symbol))
41
+ case metric.IsColumn():
42
+ for _, sym := range metric.Symbols {
43
+ addVirtualMetricSourceSpec(specs, sym.Name, metric.Table.Name, buildVirtualMetricSourceSpec(sym))
44
+ }
45
+ }
46
+ }
47
+
48
+ return specs
49
+}
50
+
51
+func addVirtualMetricSourceSpec(specs map[string]map[string]virtualMetricSourceSpec, metricName, tableName string, spec virtualMetricSourceSpec) {
52
+ tables, ok := specs[metricName]
53
+ if !ok {
54
+ tables = make(map[string]virtualMetricSourceSpec)
55
+ specs[metricName] = tables
56
+ }
57
+ tables[tableName] = spec
58
+}
59
+
60
+func buildVirtualMetricSourceSpec(sym SymbolConfig) virtualMetricSourceSpec {
61
+ return virtualMetricSourceSpec{
62
+ dimSupport: mergeVirtualMetricDimSupport(
63
+ buildMappingVirtualMetricDimSupport(sym.Mapping),
64
+ buildTransformVirtualMetricDimSupport(sym.Transform),
65
+ ),
66
+ }
67
+}
68
+
69
+func buildMappingVirtualMetricDimSupport(mapping map[string]string) virtualMetricDimSupport {
70
+ if len(mapping) == 0 {
71
+ return virtualMetricDimSupport{mode: virtualMetricDimUnsupported}
72
+ }
73
+
74
+ keysNumeric := true
75
+ valuesNumeric := true
76
+ for key, value := range mapping {
77
+ if !isIntegerString(key) {
78
+ keysNumeric = false
79
+ }
80
+ if !isIntegerString(value) {
81
+ valuesNumeric = false
82
+ }
83
+ }
84
+
85
+ switch {
86
+ case keysNumeric && valuesNumeric:
87
+ return virtualMetricDimSupport{mode: virtualMetricDimUnsupported}
88
+ case keysNumeric:
89
+ dims := make(map[string]bool)
90
+ for _, value := range mapping {
91
+ dims[value] = true
92
+ }
93
+ return virtualMetricDimSupport{mode: virtualMetricDimKnown, dims: dims}
94
+ default:
95
+ dims := make(map[string]bool)
96
+ for key, value := range mapping {
97
+ if isIntegerString(value) {
98
+ dims[key] = true
99
+ }
100
+ }
101
+ if len(dims) == 0 {
102
+ return virtualMetricDimSupport{mode: virtualMetricDimUnsupported}
103
+ }
104
+ return virtualMetricDimSupport{mode: virtualMetricDimKnown, dims: dims}
105
+ }
106
+}
107
+
108
+func buildTransformVirtualMetricDimSupport(transform string) virtualMetricDimSupport {
109
+ if transform == "" {
110
+ return virtualMetricDimSupport{mode: virtualMetricDimUnsupported}
111
+ }
112
+
113
+ if strings.Contains(transform, "transformEntitySensorValue") {
114
+ return virtualMetricDimSupport{mode: virtualMetricDimDynamic}
115
+ }
116
+
117
+ if !strings.Contains(transform, "setMultivalue") {
118
+ return virtualMetricDimSupport{mode: virtualMetricDimUnsupported}
119
+ }
120
+
121
+ dims := extractTransformMultiValueDims(transform)
122
+ if len(dims) == 0 {
123
+ return virtualMetricDimSupport{mode: virtualMetricDimDynamic}
124
+ }
125
+
126
+ return virtualMetricDimSupport{mode: virtualMetricDimKnown, dims: dims}
127
+}
128
+
129
+func mergeVirtualMetricDimSupport(mappingSupport, transformSupport virtualMetricDimSupport) virtualMetricDimSupport {
130
+ if transformSupport.mode == virtualMetricDimDynamic {
131
+ return transformSupport
132
+ }
133
+
134
+ if transformSupport.mode == virtualMetricDimKnown {
135
+ if mappingSupport.mode == virtualMetricDimKnown {
136
+ dims := make(map[string]bool, len(mappingSupport.dims)+len(transformSupport.dims))
137
+ for dim := range mappingSupport.dims {
138
+ dims[dim] = true
139
+ }
140
+ for dim := range transformSupport.dims {
141
+ dims[dim] = true
142
+ }
143
+ return virtualMetricDimSupport{mode: virtualMetricDimKnown, dims: dims}
144
+ }
145
+ return transformSupport
146
+ }
147
+
148
+ return mappingSupport
149
+}
150
+
151
+func extractTransformMultiValueDims(transform string) map[string]bool {
152
+ dims := make(map[string]bool)
153
+
154
+ for _, match := range virtualMetricI64MapPattern.FindAllStringSubmatch(transform, -1) {
155
+ if len(match) < 2 {
156
+ continue
157
+ }
158
+
159
+ for _, token := range virtualMetricQuotedStringPattern.FindAllString(match[1], -1) {
160
+ dim, err := strconv.Unquote(token)
161
+ if err != nil || dim == "" {
162
+ continue
163
+ }
164
+ dims[dim] = true
165
+ }
166
+ }
167
+
168
+ return dims
169
+}
170
+
171
+func virtualMetricSourceAvailableDims(spec virtualMetricSourceSpec) []string {
172
+ dims := make([]string, 0, len(spec.dimSupport.dims))
173
+ for dim := range spec.dimSupport.dims {
174
+ dims = append(dims, dim)
175
+ }
176
+ slices.Sort(dims)
177
+ return dims
178
+}
179
+
180
+func isIntegerString(value string) bool {
181
+ _, err := strconv.ParseInt(value, 10, 64)
182
+ return err == nil
183
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/virtual_metrics.go
+9
-2
@@ -8,6 +8,7 @@ type VirtualMetricConfig struct {
8
Name string `yaml:"name"`
9
PerRow bool `yaml:"per_row"`
10
GroupBy []string `yaml:"group_by"`
11
+ EmitTags []VirtualMetricEmitTagConfig `yaml:"emit_tags"`
12
Sources []VirtualMetricSourceConfig `yaml:"sources"`
13
Alternatives []VirtualMetricAlternativeSourcesConfig `yaml:"alternatives"`
14
ChartMeta ChartMeta `yaml:"chart_meta"`
@@ -25,6 +26,7 @@ func (vm VirtualMetricConfig) Clone() VirtualMetricConfig {
26
Name: vm.Name,
27
PerRow: vm.PerRow,
28
GroupBy: slices.Clone(vm.GroupBy),
29
+ EmitTags: slices.Clone(vm.EmitTags),
30
Sources: slices.Clone(vm.Sources),
31
Alternatives: alts,
32
ChartMeta: vm.ChartMeta,
@@ -32,10 +34,15 @@ func (vm VirtualMetricConfig) Clone() VirtualMetricConfig {
34
}
35
36
type (
37
+ VirtualMetricEmitTagConfig struct {
38
+ Tag string `yaml:"tag"`
39
+ From string `yaml:"from"`
40
+ }
41
VirtualMetricSourceConfig struct {
42
Metric string `yaml:"metric"`
37
- Table string `yaml:"table"` // Required for now
38
- As string `yaml:"as"` // dimension name for composite charts
43
+ Table string `yaml:"table"`
44
+ As string `yaml:"as"` // dimension name for composite charts
45
+ Dim string `yaml:"dim"` // optional MultiValue dimension selector for source metrics
46
}
47
VirtualMetricAlternativeSourcesConfig struct {
48
Sources []VirtualMetricSourceConfig `yaml:"sources"`
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+34
-11
@@ -224,7 +224,8 @@ var metricMetaReplacer = strings.NewReplacer(
224
// are still walked during collection. Without this, if a table like ifXTable is used
225
// only for cross-table tags (e.g., getting interface names) but has no metrics defined,
226
// it won't be walked and the tags will be missing. This creates synthetic metric entries
227
-// for such tables using the longest common OID prefix of the referenced columns.
227
+// for such tables using the longest common OID prefix of the referenced columns, including
228
+// lookup columns used by value-based joins.
229
func handleCrossTableTagsWithoutMetrics(prof *ddsnmp.Profile) {
230
if prof.Definition == nil {
231
return
@@ -243,11 +244,15 @@ func handleCrossTableTagsWithoutMetrics(prof *ddsnmp.Profile) {
244
continue
245
}
246
for _, tag := range m.MetricTags {
246
- oid := tag.Symbol.OID
247
- if tag.Table == "" || seenTableNames[tag.Table] || oid == "" {
247
+ if tag.Table == "" || seenTableNames[tag.Table] {
248
continue
249
}
250
- tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], oid)
250
+ if tag.Symbol.OID != "" {
251
+ tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], tag.Symbol.OID)
252
+ }
253
+ if tag.LookupSymbol.OID != "" {
254
+ tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], tag.LookupSymbol.OID)
255
+ }
256
}
257
}
258
@@ -269,14 +274,32 @@ func longestCommonPrefix(oids []string) string {
274
if len(oids) == 0 {
275
return ""
276
}
272
- prefix := oids[0]
277
+
278
+ prefixParts := splitOIDParts(oids[0])
279
for i := 1; i < len(oids); i++ {
274
- for !strings.HasPrefix(oids[i], prefix) {
275
- prefix = prefix[0 : len(prefix)-1]
276
- if len(prefix) == 0 {
277
- return ""
278
- }
280
+ parts := splitOIDParts(oids[i])
281
+ n := len(prefixParts)
282
+ if len(parts) < n {
283
+ n = len(parts)
284
+ }
285
+
286
+ j := 0
287
+ for j < n && prefixParts[j] == parts[j] {
288
+ j++
289
}
290
+ prefixParts = prefixParts[:j]
291
+ if len(prefixParts) == 0 {
292
+ return ""
293
+ }
294
+ }
295
+
296
+ return strings.Join(prefixParts, ".")
297
+}
298
+
299
+func splitOIDParts(oid string) []string {
300
+ parts := strings.Split(strings.Trim(oid, "."), ".")
301
+ if len(parts) == 1 && parts[0] == "" {
302
+ return nil
303
}
281
- return strings.TrimSuffix(prefix, ".")
304
+ return parts
305
}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_global_tags.go
+2
-2
@@ -81,8 +81,8 @@ func (gc *globalTagsCollector) processDynamicTags(metricTags []ddprofiledefiniti
81
ta := tagAdder{tags: globalTags}
82
83
if err := gc.tagProc.processTag(tagCfg, pdus, ta); err != nil {
84
- errs = append(errs, fmt.Errorf("failed to process tag value for '%s/%s': %w",
85
- tagCfg.Tag, tagCfg.Symbol.Name, err))
84
+ errs = append(errs, fmt.Errorf("failed to process tag value for %q: %w",
85
+ metricTagDisplayName(tagCfg), err))
86
continue
87
}
88
}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar_test.go
+4
@@ -291,6 +291,10 @@ func TestScalarCollector_Collect(t *testing.T) {
291
{
292
Name: "sysUpTime",
293
Value: 123456,
294
+ Tags: map[string]string{
295
+ "source": "system",
296
+ "type": "uptime",
297
+ },
298
StaticTags: map[string]string{
299
"source": "system",
300
"type": "uptime",
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go
+51
-26
@@ -90,10 +90,11 @@ type (
90
91
// === Computed during processing (set by various methods) ===
92
93
- // columnOIDs maps column OIDs to their symbol configurations
94
- // Built from config.Symbols, used to identify which columns contain metrics
95
- // Key: column OID (e.g., "1.3.6.1.2.1.2.2.1.10"), Value: symbol config
96
- columnOIDs map[string]ddprofiledefinition.SymbolConfig
93
+ // columnOIDs maps column OIDs to all symbol configurations that read from them.
94
+ // A single column can back multiple metrics when profiles use different
95
+ // extract_value rules on the same raw OID, such as separate code/subcode fields.
96
+ // Key: column OID (e.g., "1.3.6.1.2.1.2.2.1.10"), Value: symbol configs
97
+ columnOIDs map[string][]ddprofiledefinition.SymbolConfig
98
99
// staticTags contains tags that apply to all metrics from this table
100
// Parsed from config.StaticTags (e.g., "source:network")
@@ -148,8 +149,8 @@ type cacheProcessingContext struct {
149
150
// columnOIDs identifies which columns contain metrics (not tags)
151
// Built from config.Symbols, used to filter which OIDs to GET
151
- // Key: column OID, Value: symbol configuration
152
- columnOIDs map[string]ddprofiledefinition.SymbolConfig
152
+ // Key: column OID, Value: symbol configurations
153
+ columnOIDs map[string][]ddprofiledefinition.SymbolConfig
154
155
// pdus contains current metric values fetched via SNMP GET
156
// Only contains metric columns (not tag columns, which are cached)
@@ -438,8 +439,9 @@ func (tc *tableCollector) processRows(ctx *tableProcessingContext, stats *ddsnmp
439
var errs []error
440
441
crossTableCtx := &crossTableContext{
441
- walkedData: ctx.walkedData,
442
- tableNameToOID: ctx.tableNameToOID,
442
+ walkedData: ctx.walkedData,
443
+ tableNameToOID: ctx.tableNameToOID,
444
+ lookupIndexCache: make(map[crossTableLookupKey]string),
445
}
446
447
for index, rowPDUs := range ctx.rows {
@@ -525,7 +527,7 @@ func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext, sta
527
528
// Process each metric column
529
for columnOID, fullOID := range columns {
528
- sym, isMetric := ctx.columnOIDs[columnOID]
530
+ syms, isMetric := ctx.columnOIDs[columnOID]
531
if !isMetric {
532
continue
533
}
@@ -536,21 +538,23 @@ func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext, sta
538
continue
539
}
540
539
- value, err := tc.valProc.processValue(sym, pdu)
540
- if err != nil {
541
- stats.Errors.Processing.Table++
542
- tc.log.Debugf("Error processing value for %s: %v", sym.Name, err)
543
- continue
544
- }
541
+ for _, sym := range syms {
542
+ value, err := tc.valProc.processValue(sym, pdu)
543
+ if err != nil {
544
+ stats.Errors.Processing.Table++
545
+ tc.log.Debugf("Error processing value for %s: %v", sym.Name, err)
546
+ continue
547
+ }
548
546
- metric, err := buildTableMetric(sym, pdu, value, rowTags, staticTags, ctx.tableName)
547
- if err != nil {
548
- stats.Errors.Processing.Table++
549
- errs = append(errs, err)
550
- continue
551
- }
549
+ metric, err := buildTableMetric(sym, pdu, value, rowTags, staticTags, ctx.tableName)
550
+ if err != nil {
551
+ stats.Errors.Processing.Table++
552
+ errs = append(errs, err)
553
+ continue
554
+ }
555
553
- metrics = append(metrics, *metric)
556
+ metrics = append(metrics, *metric)
557
+ }
558
}
559
}
560
@@ -633,10 +637,11 @@ func parseStaticTags(staticTags []ddprofiledefinition.StaticMetricTagConfig) map
637
return tags
638
}
639
636
-func buildColumnOIDs(cfg ddprofiledefinition.MetricsConfig) map[string]ddprofiledefinition.SymbolConfig {
637
- columnOIDs := make(map[string]ddprofiledefinition.SymbolConfig)
640
+func buildColumnOIDs(cfg ddprofiledefinition.MetricsConfig) map[string][]ddprofiledefinition.SymbolConfig {
641
+ columnOIDs := make(map[string][]ddprofiledefinition.SymbolConfig)
642
for _, sym := range cfg.Symbols {
639
- columnOIDs[trimOID(sym.OID)] = sym
643
+ columnOID := trimOID(sym.OID)
644
+ columnOIDs[columnOID] = append(columnOIDs[columnOID], sym)
645
}
646
return columnOIDs
647
}
@@ -669,7 +674,7 @@ func buildOrderedTags(cfg ddprofiledefinition.MetricsConfig) []orderedTagConfig
674
for _, tagCfg := range cfg.MetricTags {
675
var tt tagType
676
switch {
672
- case tagCfg.Index != 0:
677
+ case isIndexTagConfig(tagCfg):
678
tt = tagTypeIndex
679
case tagCfg.Table != "" && tagCfg.Table != cfg.Table.Name:
680
tt = tagTypeCrossTable
@@ -685,3 +690,23 @@ func buildOrderedTags(cfg ddprofiledefinition.MetricsConfig) []orderedTagConfig
690
691
return ordered
692
}
693
+
694
+func isIndexTagConfig(tagCfg ddprofiledefinition.MetricTagConfig) bool {
695
+ if tagCfg.Index != 0 {
696
+ return true
697
+ }
698
+
699
+ if tagCfg.Table != "" {
700
+ return false
701
+ }
702
+
703
+ if tagCfg.Symbol.OID != "" {
704
+ return false
705
+ }
706
+
707
+ return len(tagCfg.IndexTransform) > 0 ||
708
+ tagCfg.Symbol.Format != "" ||
709
+ tagCfg.Symbol.ExtractValue != "" ||
710
+ tagCfg.Symbol.MatchPattern != "" ||
711
+ len(tagCfg.Mapping) > 0
712
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table_test.go
+257
-2
@@ -807,11 +807,15 @@ func TestTableCollector_Collect(t *testing.T) {
807
{
808
Name: "ifInOctets",
809
Value: 1000,
810
+ Tags: map[string]string{
811
+ "interface": "eth0",
812
+ "source": "interface",
813
+ "table": "if",
814
+ },
815
StaticTags: map[string]string{
816
"source": "interface",
817
"table": "if",
818
},
814
- Tags: map[string]string{"interface": "eth0"},
819
MetricType: "rate",
820
IsTable: true,
821
Table: "ifTable",
@@ -1043,7 +1047,7 @@ func TestTableCollector_Collect(t *testing.T) {
1047
Name: "ifInOctets",
1048
Value: 1000,
1049
StaticTags: map[string]string{"source": "network"},
1046
- Tags: map[string]string{"interface": "eth0", "if_type": "ethernet"},
1050
+ Tags: map[string]string{"interface": "eth0", "if_type": "ethernet", "source": "network"},
1051
MetricType: "rate",
1052
IsTable: true,
1053
@@ -2018,6 +2022,159 @@ func TestTableCollector_Collect(t *testing.T) {
2022
},
2023
expectedError: false,
2024
},
2025
+ "huawei route table cross-table tags from augmented tables": {
2026
+ profile: &ddsnmp.Profile{
2027
+ SourceFile: "test-profile.yaml",
2028
+ Definition: &ddprofiledefinition.ProfileDefinition{
2029
+ Metrics: []ddprofiledefinition.MetricsConfig{
2030
+ {
2031
+ Table: ddprofiledefinition.SymbolConfig{
2032
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.3",
2033
+ Name: "hwBgpPeerRouteTable",
2034
+ },
2035
+ Symbols: []ddprofiledefinition.SymbolConfig{
2036
+ {
2037
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.3.1.1",
2038
+ Name: "huawei.hwBgpPeerPrefixRcvCounter",
2039
+ MetricType: ddprofiledefinition.ProfileMetricTypeMonotonicCount,
2040
+ ChartMeta: ddprofiledefinition.ChartMeta{
2041
+ Description: "The number of prefixes received from the remote BGP peer",
2042
+ Family: "Network/Routing/BGP/Peer/Prefix/Received/Total",
2043
+ Unit: "{prefix}",
2044
+ },
2045
+ },
2046
+ },
2047
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
2048
+ {
2049
+ Tag: "huawei_hw_bgp_peer_vrf_name",
2050
+ Table: "hwBgpPeerAddrFamilyTable",
2051
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2052
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.1.1.6",
2053
+ Name: "huawei.hwBgpPeerVrfName",
2054
+ },
2055
+ },
2056
+ {
2057
+ Tag: "huawei_hw_bgp_peer_remote_addr",
2058
+ Table: "hwBgpPeerTable",
2059
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2060
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
2061
+ Name: "huawei.hwBgpPeerRemoteAddr",
2062
+ Format: "ip_address",
2063
+ },
2064
+ },
2065
+ {
2066
+ Tag: "_routing_instance",
2067
+ Table: "hwBgpPeerAddrFamilyTable",
2068
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2069
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.1.1.6",
2070
+ Name: "huawei.hwBgpPeerVrfName",
2071
+ },
2072
+ },
2073
+ {
2074
+ Tag: "_neighbor",
2075
+ Table: "hwBgpPeerTable",
2076
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2077
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
2078
+ Name: "huawei.hwBgpPeerRemoteAddr",
2079
+ Format: "ip_address",
2080
+ },
2081
+ },
2082
+ {
2083
+ Tag: "_remote_as",
2084
+ Table: "hwBgpPeerTable",
2085
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2086
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2",
2087
+ Name: "huawei.hwBgpPeerRemoteAs",
2088
+ },
2089
+ },
2090
+ {
2091
+ Tag: "_peer_description",
2092
+ Table: "hwBgpPeerTable",
2093
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2094
+ OID: "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.12",
2095
+ Name: "huawei.hwBgpPeerDescription",
2096
+ },
2097
+ },
2098
+ {
2099
+ Tag: "_address_family",
2100
+ Index: 2,
2101
+ Mapping: map[string]string{
2102
+ "1": "ipv4",
2103
+ "2": "ipv6",
2104
+ "25": "vpls",
2105
+ "196": "l2vpn",
2106
+ },
2107
+ },
2108
+ {
2109
+ Tag: "_subsequent_address_family",
2110
+ Index: 3,
2111
+ Mapping: map[string]string{
2112
+ "1": "unicast",
2113
+ "2": "multicast",
2114
+ "4": "mpls",
2115
+ "5": "mcast-vpn",
2116
+ "65": "vpls",
2117
+ "66": "mdt",
2118
+ "74": "sd-wan",
2119
+ "128": "vpn",
2120
+ "132": "route-target",
2121
+ },
2122
+ },
2123
+ {
2124
+ Tag: "_neighbor_address_type",
2125
+ Index: 4,
2126
+ Mapping: map[string]string{
2127
+ "0": "unknown",
2128
+ "1": "ipv4",
2129
+ "2": "ipv6",
2130
+ "3": "ipv4z",
2131
+ "4": "ipv6z",
2132
+ "16": "dns",
2133
+ },
2134
+ },
2135
+ },
2136
+ },
2137
+ },
2138
+ },
2139
+ },
2140
+ setupMock: func(m *snmpmock.MockHandler) {
2141
+ expectSNMPWalk(m, gosnmp.Version2c, "1.3.6.1.4.1.2011.5.25.177.1.1.3", []gosnmp.SnmpPDU{
2142
+ createCounter32PDU("1.3.6.1.4.1.2011.5.25.177.1.1.3.1.1.100.1.1.1.192.0.2.1", 123),
2143
+ })
2144
+ expectSNMPWalk(m, gosnmp.Version2c, "1.3.6.1.4.1.2011.5.25.177.1.1.1.1.6", []gosnmp.SnmpPDU{
2145
+ createStringPDU("1.3.6.1.4.1.2011.5.25.177.1.1.1.1.6.100.1.1.1.192.0.2.1", "blue"),
2146
+ })
2147
+ expectSNMPWalk(m, gosnmp.Version2c, "1.3.6.1.4.1.2011.5.25.177.1.1.2.1", []gosnmp.SnmpPDU{
2148
+ createGauge32PDU("1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.100.1.1.1.192.0.2.1", 65001),
2149
+ createPDU("1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.100.1.1.1.192.0.2.1", gosnmp.OctetString, []byte{192, 0, 2, 1}),
2150
+ createStringPDU("1.3.6.1.4.1.2011.5.25.177.1.1.2.1.12.100.1.1.1.192.0.2.1", "Transit-1"),
2151
+ })
2152
+ },
2153
+ expectedResult: []ddsnmp.Metric{
2154
+ {
2155
+ Name: "huawei.hwBgpPeerPrefixRcvCounter",
2156
+ Description: "The number of prefixes received from the remote BGP peer",
2157
+ Family: "Network/Routing/BGP/Peer/Prefix/Received/Total",
2158
+ Unit: "{prefix}",
2159
+ Value: 123,
2160
+ Tags: map[string]string{
2161
+ "huawei_hw_bgp_peer_vrf_name": "blue",
2162
+ "huawei_hw_bgp_peer_remote_addr": "192.0.2.1",
2163
+ "_routing_instance": "blue",
2164
+ "_neighbor": "192.0.2.1",
2165
+ "_remote_as": "65001",
2166
+ "_peer_description": "Transit-1",
2167
+ "_address_family": "ipv4",
2168
+ "_subsequent_address_family": "unicast",
2169
+ "_neighbor_address_type": "ipv4",
2170
+ },
2171
+ MetricType: ddprofiledefinition.ProfileMetricTypeMonotonicCount,
2172
+ IsTable: true,
2173
+ Table: "hwBgpPeerRouteTable",
2174
+ },
2175
+ },
2176
+ expectedError: false,
2177
+ },
2178
"cross-table tag with extract_value": {
2179
profile: &ddsnmp.Profile{
2180
SourceFile: "test-profile.yaml",
@@ -2332,6 +2489,104 @@ func TestTableCollector_Collect(t *testing.T) {
2489
},
2490
expectedError: false,
2491
},
2492
+ "cross-table tag with lookup_symbol value match": {
2493
+ profile: &ddsnmp.Profile{
2494
+ SourceFile: "test-profile.yaml",
2495
+ Definition: &ddprofiledefinition.ProfileDefinition{
2496
+ Metrics: []ddprofiledefinition.MetricsConfig{
2497
+ {
2498
+ Table: ddprofiledefinition.SymbolConfig{
2499
+ OID: "1.3.6.1.4.1.2636.5.1.1.2.6.2",
2500
+ Name: "jnxBgpM2PrefixCountersTable",
2501
+ },
2502
+ Symbols: []ddprofiledefinition.SymbolConfig{
2503
+ {
2504
+ OID: "1.3.6.1.4.1.2636.5.1.1.2.6.2.1.8",
2505
+ Name: "bgpPeerPrefixesAccepted",
2506
+ },
2507
+ },
2508
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
2509
+ {
2510
+ Tag: "neighbor",
2511
+ Table: "jnxBgpM2PeerTable",
2512
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2513
+ OID: "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.11",
2514
+ Name: "_jnxBgpM2PeerRemoteAddr",
2515
+ Format: "ip_address",
2516
+ },
2517
+ LookupSymbol: ddprofiledefinition.SymbolConfigCompat{
2518
+ OID: "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14",
2519
+ Name: "_jnxBgpM2PeerIndex",
2520
+ },
2521
+ IndexTransform: []ddprofiledefinition.MetricIndexTransform{
2522
+ {Start: 0, End: 0},
2523
+ },
2524
+ },
2525
+ {
2526
+ Tag: "remote_as",
2527
+ Table: "jnxBgpM2PeerTable",
2528
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
2529
+ OID: "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.13",
2530
+ Name: "_jnxBgpM2PeerRemoteAs",
2531
+ },
2532
+ LookupSymbol: ddprofiledefinition.SymbolConfigCompat{
2533
+ OID: "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14",
2534
+ Name: "_jnxBgpM2PeerIndex",
2535
+ },
2536
+ IndexTransform: []ddprofiledefinition.MetricIndexTransform{
2537
+ {Start: 0, End: 0},
2538
+ },
2539
+ },
2540
+ {
2541
+ Tag: "address_family",
2542
+ Index: 2,
2543
+ Mapping: map[string]string{
2544
+ "1": "ipv4",
2545
+ "2": "ipv6",
2546
+ "25": "l2vpn",
2547
+ },
2548
+ },
2549
+ {
2550
+ Tag: "subsequent_address_family",
2551
+ Index: 3,
2552
+ Mapping: map[string]string{
2553
+ "1": "unicast",
2554
+ "70": "evpn",
2555
+ "128": "vpn",
2556
+ },
2557
+ },
2558
+ },
2559
+ },
2560
+ },
2561
+ },
2562
+ },
2563
+ setupMock: func(m *snmpmock.MockHandler) {
2564
+ expectSNMPWalk(m, gosnmp.Version2c, "1.3.6.1.4.1.2636.5.1.1.2.6.2", []gosnmp.SnmpPDU{
2565
+ createGauge32PDU("1.3.6.1.4.1.2636.5.1.1.2.6.2.1.8.100.1.1", 42),
2566
+ })
2567
+ expectSNMPWalk(m, gosnmp.Version2c, "1.3.6.1.4.1.2636.5.1.1.2.1.1.1", []gosnmp.SnmpPDU{
2568
+ createPDU("1.3.6.1.4.1.2636.5.1.1.2.1.1.1.11.7.1.4.10.0.0.1.1.4.192.0.2.1", gosnmp.OctetString, []byte{192, 0, 2, 1}),
2569
+ createGauge32PDU("1.3.6.1.4.1.2636.5.1.1.2.1.1.1.13.7.1.4.10.0.0.1.1.4.192.0.2.1", 65001),
2570
+ createGauge32PDU("1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14.7.1.4.10.0.0.1.1.4.192.0.2.1", 100),
2571
+ })
2572
+ },
2573
+ expectedResult: []ddsnmp.Metric{
2574
+ {
2575
+ Name: "bgpPeerPrefixesAccepted",
2576
+ Value: 42,
2577
+ Tags: map[string]string{
2578
+ "neighbor": "192.0.2.1",
2579
+ "remote_as": "65001",
2580
+ "address_family": "ipv4",
2581
+ "subsequent_address_family": "unicast",
2582
+ },
2583
+ MetricType: "gauge",
2584
+ IsTable: true,
2585
+ Table: "jnxBgpM2PrefixCountersTable",
2586
+ },
2587
+ },
2588
+ expectedError: false,
2589
+ },
2590
"cross-table tag error when index transform fails": {
2591
profile: &ddsnmp.Profile{
2592
SourceFile: "test-profile.yaml",
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go
+12
@@ -149,3 +149,15 @@ func TestCollector_Collect_StatsSnapshot(t *testing.T) {
149
150
assert.Equal(t, expected, pm.Stats)
151
}
152
+
153
+func TestLongestCommonPrefix(t *testing.T) {
154
+ assert.Equal(t, "1.3.6.1.2.1.31.1.1.1", longestCommonPrefix([]string{
155
+ "1.3.6.1.2.1.31.1.1.1.1",
156
+ "1.3.6.1.2.1.31.1.1.1.18",
157
+ }))
158
+
159
+ assert.Equal(t, "1.3.6.1.4.1.2636.5.1.1.2.1.1.1", longestCommonPrefix([]string{
160
+ "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.11",
161
+ "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14",
162
+ }))
163
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics.go
+72
-80
@@ -4,7 +4,6 @@ package ddsnmpcollector
4
5
import (
6
"slices"
7
- "sort"
7
"strings"
8
9
"github.com/netdata/netdata/go/plugins/logger"
@@ -38,13 +37,13 @@ func (p *vmetricsCollector) accumulate(lookup map[vmetricsSourceKey][]vmetricsSi
37
continue
38
}
39
41
- v, mv := vmCollapseMetricValue(m)
42
-
40
type gke struct {
41
key string
42
ok bool
43
}
44
var gkCache map[*vmetricsAggregator]gke
45
+ var tags map[string]string
46
+ var tagsReady bool
47
48
for _, sink := range sinks {
49
agg := sink.agg
@@ -52,6 +51,11 @@ func (p *vmetricsCollector) accumulate(lookup map[vmetricsSourceKey][]vmetricsSi
51
continue
52
}
53
54
+ v, mv, ok := vmResolveSourceValue(m, sink)
55
+ if !ok {
56
+ continue
57
+ }
58
+
59
if agg.metricType == "" {
60
agg.metricType = m.MetricType
61
}
@@ -64,16 +68,20 @@ func (p *vmetricsCollector) accumulate(lookup map[vmetricsSourceKey][]vmetricsSi
68
if gkCache == nil {
69
gkCache = make(map[*vmetricsAggregator]gke, 2)
70
}
71
+ if !tagsReady {
72
+ tags = vmMetricTags(m)
73
+ tagsReady = true
74
+ }
75
entry, found := gkCache[agg]
76
if !found {
69
- k, ok := vmBuildGroupKey(m.Tags, agg)
77
+ k, ok := vmBuildGroupKey(tags, agg)
78
entry = gke{key: k, ok: ok}
79
gkCache[agg] = entry
80
}
81
if !entry.ok {
82
continue
83
}
76
- agg.accumulateGroupedWithKey(sink, entry.key, v, m.Tags)
84
+ agg.accumulateGroupedWithKey(sink, entry.key, v, tags)
85
}
86
}
87
}
@@ -170,8 +178,9 @@ type (
178
179
// sink binding; dimIdx == -1 for non-composite
180
vmetricsSink struct {
173
- agg *vmetricsAggregator
174
- dimIdx int16
181
+ agg *vmetricsAggregator
182
+ dimIdx int16
183
+ sourceDim string
184
}
185
186
// per-group accumulator (emitted as one table row)
@@ -308,82 +317,21 @@ func (p *vmetricsCollector) getDefinedMetricNames(profMetrics []ddprofiledefinit
317
return names
318
}
319
311
-// vmBuildGroupKey returns a stable group key.
312
-func vmBuildGroupKey(tags map[string]string, agg *vmetricsAggregator) (string, bool) {
313
- if !agg.grouped || len(tags) == 0 {
314
- return "", false
315
- }
316
-
317
- const (
318
- groupKeySep = '\x1F' // ASCII Unit Separator between values/pairs
319
- kvSep = '=' // used in per-row fallback "k=v"
320
- )
321
-
322
- agg.keyBuf.Reset()
323
-
324
- if agg.perRow {
325
- if len(agg.groupBy) > 0 {
326
- for i, l := range agg.groupBy {
327
- v := tags[l]
328
- if v == "" {
329
- return "", false
330
- }
331
- if i > 0 {
332
- agg.keyBuf.WriteByte(groupKeySep)
333
- }
334
- agg.keyBuf.WriteString(v)
335
- }
336
- return agg.keyBuf.String(), true
337
- }
338
-
339
- // per-row without group_by: stable key from all non-underscore tags
340
- keys := make([]string, 0, len(tags))
341
- for k := range tags {
342
- if !strings.HasPrefix(k, "_") {
343
- keys = append(keys, k)
344
- }
345
- }
346
- if len(keys) == 0 {
347
- return "", false
348
- }
349
-
350
- sort.Strings(keys)
351
- for i, k := range keys {
352
- if i > 0 {
353
- agg.keyBuf.WriteByte(groupKeySep)
354
- }
355
- agg.keyBuf.WriteString(k)
356
- agg.keyBuf.WriteByte(kvSep)
357
- agg.keyBuf.WriteString(tags[k])
358
- }
359
- return agg.keyBuf.String(), true
360
- }
361
-
362
- // non per-row: respect group_by exactly; underscore labels are NOT special
363
- switch len(agg.groupBy) {
364
- case 0:
365
- return "", false
366
- case 1:
367
- l := agg.groupBy[0]
368
- v := tags[l]
369
- return v, v != ""
370
- default:
371
- for i, l := range agg.groupBy {
372
- v := tags[l]
373
- if v == "" {
374
- return "", false
320
+// vmBuildEmitTags captures labels to emit for a group (called once per new group)
321
+func vmBuildEmitTags(tags map[string]string, agg *vmetricsAggregator) map[string]string {
322
+ if len(agg.config.EmitTags) > 0 {
323
+ out := make(map[string]string, len(agg.config.EmitTags))
324
+ for _, spec := range agg.config.EmitTags {
325
+ if spec.Tag == "" || spec.From == "" {
326
+ continue
327
}
376
- if i > 0 {
377
- agg.keyBuf.WriteByte(groupKeySep)
328
+ if v := tags[spec.From]; v != "" {
329
+ out[spec.Tag] = v
330
}
379
- agg.keyBuf.WriteString(v)
331
}
381
- return agg.keyBuf.String(), true
332
+ return out
333
}
383
-}
334
385
-// vmBuildEmitTags captures labels to emit for a group (called once per new group)
386
-func vmBuildEmitTags(tags map[string]string, agg *vmetricsAggregator) map[string]string {
335
if agg.perRow {
336
// per-row: reuse pointer; we never mutate it here
337
return tags
@@ -397,7 +345,29 @@ func vmBuildEmitTags(tags map[string]string, agg *vmetricsAggregator) map[string
345
return out
346
}
347
400
-// vmCollapseMetricValue a metric to an int64 quickly; return mv if present for merge path
348
+func vmMetricTags(m ddsnmp.Metric) map[string]string {
349
+ if len(m.StaticTags) == 0 {
350
+ return m.Tags
351
+ }
352
+ if len(m.Tags) == 0 {
353
+ return m.StaticTags
354
+ }
355
+ for k, v := range m.StaticTags {
356
+ if m.Tags[k] != v {
357
+ out := make(map[string]string, len(m.Tags)+len(m.StaticTags))
358
+ for key, value := range m.StaticTags {
359
+ out[key] = value
360
+ }
361
+ for key, value := range m.Tags {
362
+ out[key] = value
363
+ }
364
+ return out
365
+ }
366
+ }
367
+ return m.Tags
368
+}
369
+
370
+// vmCollapseMetricValue collapses a metric to an int64 quickly; return mv if present for merge path.
371
func vmCollapseMetricValue(m ddsnmp.Metric) (v int64, mv map[string]int64) {
372
if len(m.MultiValue) == 0 {
373
return m.Value, nil
@@ -409,6 +379,24 @@ func vmCollapseMetricValue(m ddsnmp.Metric) (v int64, mv map[string]int64) {
379
return sum, m.MultiValue
380
}
381
382
+func vmResolveSourceValue(m ddsnmp.Metric, sink vmetricsSink) (v int64, mv map[string]int64, ok bool) {
383
+ if sink.sourceDim == "" {
384
+ v, mv = vmCollapseMetricValue(m)
385
+ return v, mv, true
386
+ }
387
+
388
+ if len(m.MultiValue) == 0 {
389
+ return 0, nil, false
390
+ }
391
+
392
+ v, ok = m.MultiValue[sink.sourceDim]
393
+ if !ok {
394
+ return 0, nil, false
395
+ }
396
+
397
+ return v, nil, true
398
+}
399
+
400
type aggregatorsBuilder struct {
401
log *logger.Logger
402
prof *ddprofiledefinition.ProfileDefinition
@@ -569,6 +557,10 @@ func (b *aggregatorsBuilder) bindSinks(
557
dimIdx = int16(idx)
558
}
559
}
572
- b.sourceToSinks[key] = append(b.sourceToSinks[key], vmetricsSink{agg: agg, dimIdx: dimIdx})
560
+ b.sourceToSinks[key] = append(b.sourceToSinks[key], vmetricsSink{
561
+ agg: agg,
562
+ dimIdx: dimIdx,
563
+ sourceDim: src.Dim,
564
+ })
565
}
566
}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics_groupkey.go
new
+79
@@ -0,0 +1,79 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "sort"
7
+ "strconv"
8
+ "strings"
9
+)
10
+
11
+// vmBuildGroupKey returns a stable group key.
12
+func vmBuildGroupKey(tags map[string]string, agg *vmetricsAggregator) (string, bool) {
13
+ if !agg.grouped || len(tags) == 0 {
14
+ return "", false
15
+ }
16
+
17
+ agg.keyBuf.Reset()
18
+
19
+ if agg.perRow {
20
+ if len(agg.groupBy) > 0 {
21
+ missingHint := false
22
+ for _, label := range agg.groupBy {
23
+ value := tags[label]
24
+ if value == "" {
25
+ missingHint = true
26
+ break
27
+ }
28
+ vmWriteGroupKeyPart(&agg.keyBuf, value)
29
+ }
30
+ if !missingHint {
31
+ return agg.keyBuf.String(), true
32
+ }
33
+ agg.keyBuf.Reset()
34
+ }
35
+
36
+ // per-row without group_by: stable length-prefixed key from all non-underscore tags
37
+ keys := make([]string, 0, len(tags))
38
+ for key := range tags {
39
+ if !strings.HasPrefix(key, "_") {
40
+ keys = append(keys, key)
41
+ }
42
+ }
43
+ if len(keys) == 0 {
44
+ return "", false
45
+ }
46
+
47
+ sort.Strings(keys)
48
+ for _, key := range keys {
49
+ vmWriteGroupKeyPart(&agg.keyBuf, key)
50
+ vmWriteGroupKeyPart(&agg.keyBuf, tags[key])
51
+ }
52
+ return agg.keyBuf.String(), true
53
+ }
54
+
55
+ // non per-row: respect group_by exactly; underscore labels are NOT special
56
+ switch len(agg.groupBy) {
57
+ case 0:
58
+ return "", false
59
+ case 1:
60
+ label := agg.groupBy[0]
61
+ value := tags[label]
62
+ return value, value != ""
63
+ default:
64
+ for _, label := range agg.groupBy {
65
+ value := tags[label]
66
+ if value == "" {
67
+ return "", false
68
+ }
69
+ vmWriteGroupKeyPart(&agg.keyBuf, value)
70
+ }
71
+ return agg.keyBuf.String(), true
72
+ }
73
+}
74
+
75
+func vmWriteGroupKeyPart(buf *strings.Builder, value string) {
76
+ buf.WriteString(strconv.Itoa(len(value)))
77
+ buf.WriteByte(':')
78
+ buf.WriteString(value)
79
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics_test.go
+435
-13
@@ -4,6 +4,7 @@ package ddsnmpcollector
4
5
import (
6
"strconv"
7
+ "strings"
8
"testing"
9
10
"github.com/stretchr/testify/assert"
@@ -746,6 +747,40 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
747
},
748
},
749
750
+ "composite with selected multivalue dimensions": {
751
+ profileDef: &ddprofiledefinition.ProfileDefinition{
752
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
753
+ {
754
+ Name: "bgpPeerAvailability",
755
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
756
+ {Metric: "bgpPeerAdminStatus", Table: "bgpPeerTable", As: "admin_enabled", Dim: "start"},
757
+ {Metric: "bgpPeerState", Table: "bgpPeerTable", As: "established", Dim: "established"},
758
+ },
759
+ },
760
+ },
761
+ },
762
+ collectedMetrics: []ddsnmp.Metric{
763
+ {
764
+ Name: "bgpPeerAdminStatus",
765
+ IsTable: true,
766
+ Table: "bgpPeerTable",
767
+ MultiValue: map[string]int64{"stop": 0, "start": 1},
768
+ },
769
+ {
770
+ Name: "bgpPeerState",
771
+ IsTable: true,
772
+ Table: "bgpPeerTable",
773
+ MultiValue: map[string]int64{"idle": 0, "connect": 0, "active": 0, "opensent": 0, "openconfirm": 0, "established": 1},
774
+ },
775
+ },
776
+ expected: []ddsnmp.Metric{
777
+ {
778
+ Name: "bgpPeerAvailability",
779
+ MultiValue: map[string]int64{"admin_enabled": 1, "established": 1},
780
+ },
781
+ },
782
+ },
783
+
784
"composite with scalar sources (CPU)": {
785
profileDef: &ddprofiledefinition.ProfileDefinition{
786
VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
@@ -783,7 +818,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
818
VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
819
{
820
Name: "ifTrafficPerInterface",
786
- GroupBy: []string{"interface", "ifType"},
821
+ GroupBy: ddprofiledefinition.StringArray{"interface", "ifType"},
822
Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
823
{Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
824
{Metric: "ifHCOutOctets", Table: "ifXTable", As: "out"},
@@ -818,7 +853,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
853
VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
854
{
855
Name: "ifErrorsPerInterface",
821
- GroupBy: []string{"interface"},
856
+ GroupBy: ddprofiledefinition.StringArray{"interface"},
857
Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
858
{Metric: "ifInErrors", Table: "ifTable"},
859
},
@@ -906,7 +941,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
941
VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
942
{
943
Name: "invalidGroupedVM",
909
- GroupBy: []string{"interface"},
944
+ GroupBy: ddprofiledefinition.StringArray{"interface"},
945
Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
946
{Metric: "ifInOctets", Table: "ifTable", As: "in"},
947
{Metric: "ifHCInOctets", Table: "ifXTable", As: "in2"},
@@ -1026,7 +1061,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1061
{
1062
Name: "ifTrafficPerRowHint",
1063
PerRow: true,
1029
- GroupBy: []string{"interface"}, // used as row-key hint
1064
+ GroupBy: ddprofiledefinition.StringArray{"interface"}, // used as row-key hint
1065
Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1066
{Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
1067
{Metric: "ifHCOutOctets", Table: "ifXTable", As: "out"},
@@ -1062,6 +1097,364 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1097
},
1098
},
1099
1100
+ "per_row with missing key hint falls back to stable visible tags": {
1101
+ profileDef: &ddprofiledefinition.ProfileDefinition{
1102
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1103
+ {
1104
+ Name: "ifTrafficPerRowHintFallback",
1105
+ PerRow: true,
1106
+ GroupBy: ddprofiledefinition.StringArray{"interface"},
1107
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1108
+ {Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
1109
+ {Metric: "ifHCOutOctets", Table: "ifXTable", As: "out"},
1110
+ },
1111
+ },
1112
+ },
1113
+ },
1114
+ collectedMetrics: []ddsnmp.Metric{
1115
+ {Name: "ifHCInOctets", Value: 5, IsTable: true, Table: "ifXTable",
1116
+ Tags: map[string]string{"ifType": "ethernetCsmacd", "ifIndex": "11"}},
1117
+ {Name: "ifHCOutOctets", Value: 7, IsTable: true, Table: "ifXTable",
1118
+ Tags: map[string]string{"ifType": "ethernetCsmacd", "ifIndex": "11"}},
1119
+ },
1120
+ expected: []ddsnmp.Metric{
1121
+ {
1122
+ Name: "ifTrafficPerRowHintFallback",
1123
+ IsTable: true,
1124
+ Table: "ifXTable",
1125
+ Tags: map[string]string{"ifType": "ethernetCsmacd", "ifIndex": "11"},
1126
+ MultiValue: map[string]int64{"in": 5, "out": 7},
1127
+ },
1128
+ },
1129
+ },
1130
+
1131
+ "per_row composite with selected multivalue dimensions": {
1132
+ profileDef: &ddprofiledefinition.ProfileDefinition{
1133
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1134
+ {
1135
+ Name: "bgpPeerAvailability",
1136
+ PerRow: true,
1137
+ GroupBy: ddprofiledefinition.StringArray{"neighbor"},
1138
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1139
+ {Metric: "bgpPeerAdminStatus", Table: "bgpPeerTable", As: "admin_enabled", Dim: "start"},
1140
+ {Metric: "bgpPeerState", Table: "bgpPeerTable", As: "established", Dim: "established"},
1141
+ },
1142
+ ChartMeta: ddprofiledefinition.ChartMeta{
1143
+ Description: "Per-peer availability",
1144
+ Family: "Network/Routing/BGP/Peer/Availability",
1145
+ Unit: "{status}",
1146
+ },
1147
+ },
1148
+ },
1149
+ },
1150
+ collectedMetrics: []ddsnmp.Metric{
1151
+ {
1152
+ Name: "bgpPeerAdminStatus",
1153
+ IsTable: true,
1154
+ Table: "bgpPeerTable",
1155
+ Tags: map[string]string{"neighbor": "192.0.2.1", "remote_as": "64512"},
1156
+ MultiValue: map[string]int64{"stop": 0, "start": 1},
1157
+ },
1158
+ {
1159
+ Name: "bgpPeerState",
1160
+ IsTable: true,
1161
+ Table: "bgpPeerTable",
1162
+ Tags: map[string]string{"neighbor": "192.0.2.1", "remote_as": "64512"},
1163
+ MultiValue: map[string]int64{"idle": 1, "connect": 0, "active": 0, "opensent": 0, "openconfirm": 0, "established": 0},
1164
+ },
1165
+ {
1166
+ Name: "bgpPeerAdminStatus",
1167
+ IsTable: true,
1168
+ Table: "bgpPeerTable",
1169
+ Tags: map[string]string{"neighbor": "2001:db8::1", "remote_as": "64513"},
1170
+ MultiValue: map[string]int64{"stop": 1, "start": 0},
1171
+ },
1172
+ {
1173
+ Name: "bgpPeerState",
1174
+ IsTable: true,
1175
+ Table: "bgpPeerTable",
1176
+ Tags: map[string]string{"neighbor": "2001:db8::1", "remote_as": "64513"},
1177
+ MultiValue: map[string]int64{"idle": 1, "connect": 0, "active": 0, "opensent": 0, "openconfirm": 0, "established": 0},
1178
+ },
1179
+ },
1180
+ expected: []ddsnmp.Metric{
1181
+ {
1182
+ Name: "bgpPeerAvailability",
1183
+ IsTable: true,
1184
+ Table: "bgpPeerTable",
1185
+ Tags: map[string]string{"neighbor": "192.0.2.1", "remote_as": "64512"},
1186
+ MultiValue: map[string]int64{"admin_enabled": 1, "established": 0},
1187
+ Description: "Per-peer availability",
1188
+ Family: "Network/Routing/BGP/Peer/Availability",
1189
+ Unit: "{status}",
1190
+ },
1191
+ {
1192
+ Name: "bgpPeerAvailability",
1193
+ IsTable: true,
1194
+ Table: "bgpPeerTable",
1195
+ Tags: map[string]string{"neighbor": "2001:db8::1", "remote_as": "64513"},
1196
+ MultiValue: map[string]int64{"admin_enabled": 0, "established": 0},
1197
+ Description: "Per-peer availability",
1198
+ Family: "Network/Routing/BGP/Peer/Availability",
1199
+ Unit: "{status}",
1200
+ },
1201
+ },
1202
+ },
1203
+
1204
+ "per_row composite can group by hidden tags and emit normalized tags": {
1205
+ profileDef: &ddprofiledefinition.ProfileDefinition{
1206
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1207
+ {
1208
+ Name: "bgpPeerAvailability",
1209
+ PerRow: true,
1210
+ GroupBy: ddprofiledefinition.StringArray{"_routing_instance", "_neighbor", "_address_family", "_subsequent_address_family"},
1211
+ EmitTags: []ddprofiledefinition.VirtualMetricEmitTagConfig{
1212
+ {Tag: "routing_instance", From: "_routing_instance"},
1213
+ {Tag: "neighbor", From: "_neighbor"},
1214
+ {Tag: "address_family", From: "_address_family"},
1215
+ {Tag: "subsequent_address_family", From: "_subsequent_address_family"},
1216
+ {Tag: "remote_as", From: "_remote_as"},
1217
+ },
1218
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1219
+ {Metric: "bgpPeerAdminStatus", Table: "hwBgpPeerTable", As: "admin_enabled", Dim: "start"},
1220
+ {Metric: "bgpPeerState", Table: "hwBgpPeerTable", As: "established", Dim: "established"},
1221
+ },
1222
+ ChartMeta: ddprofiledefinition.ChartMeta{
1223
+ Description: "Per-peer availability",
1224
+ Family: "Network/Routing/BGP/Peer/Availability",
1225
+ Unit: "{status}",
1226
+ },
1227
+ },
1228
+ },
1229
+ },
1230
+ collectedMetrics: []ddsnmp.Metric{
1231
+ {
1232
+ Name: "bgpPeerAdminStatus",
1233
+ IsTable: true,
1234
+ Table: "hwBgpPeerTable",
1235
+ Tags: map[string]string{
1236
+ "huawei_hw_bgp_peer_vrf_name": "blue",
1237
+ "huawei_hw_bgp_peer_remote_addr": "192.0.2.1",
1238
+ "_routing_instance": "blue",
1239
+ "_neighbor": "192.0.2.1",
1240
+ "_remote_as": "64512",
1241
+ "_address_family": "ipv4",
1242
+ "_subsequent_address_family": "unicast",
1243
+ },
1244
+ MultiValue: map[string]int64{"stop": 0, "start": 1},
1245
+ },
1246
+ {
1247
+ Name: "bgpPeerState",
1248
+ IsTable: true,
1249
+ Table: "hwBgpPeerTable",
1250
+ Tags: map[string]string{
1251
+ "huawei_hw_bgp_peer_vrf_name": "blue",
1252
+ "huawei_hw_bgp_peer_remote_addr": "192.0.2.1",
1253
+ "_routing_instance": "blue",
1254
+ "_neighbor": "192.0.2.1",
1255
+ "_remote_as": "64512",
1256
+ "_address_family": "ipv4",
1257
+ "_subsequent_address_family": "unicast",
1258
+ },
1259
+ MultiValue: map[string]int64{"idle": 0, "established": 1},
1260
+ },
1261
+ {
1262
+ Name: "bgpPeerAdminStatus",
1263
+ IsTable: true,
1264
+ Table: "hwBgpPeerTable",
1265
+ Tags: map[string]string{
1266
+ "huawei_hw_bgp_peer_vrf_name": "blue",
1267
+ "huawei_hw_bgp_peer_remote_addr": "192.0.2.1",
1268
+ "_routing_instance": "blue",
1269
+ "_neighbor": "192.0.2.1",
1270
+ "_remote_as": "64512",
1271
+ "_address_family": "ipv6",
1272
+ "_subsequent_address_family": "unicast",
1273
+ },
1274
+ MultiValue: map[string]int64{"stop": 0, "start": 1},
1275
+ },
1276
+ {
1277
+ Name: "bgpPeerState",
1278
+ IsTable: true,
1279
+ Table: "hwBgpPeerTable",
1280
+ Tags: map[string]string{
1281
+ "huawei_hw_bgp_peer_vrf_name": "blue",
1282
+ "huawei_hw_bgp_peer_remote_addr": "192.0.2.1",
1283
+ "_routing_instance": "blue",
1284
+ "_neighbor": "192.0.2.1",
1285
+ "_remote_as": "64512",
1286
+ "_address_family": "ipv6",
1287
+ "_subsequent_address_family": "unicast",
1288
+ },
1289
+ MultiValue: map[string]int64{"idle": 1, "established": 0},
1290
+ },
1291
+ },
1292
+ expected: []ddsnmp.Metric{
1293
+ {
1294
+ Name: "bgpPeerAvailability",
1295
+ IsTable: true,
1296
+ Table: "hwBgpPeerTable",
1297
+ Tags: map[string]string{
1298
+ "routing_instance": "blue",
1299
+ "neighbor": "192.0.2.1",
1300
+ "address_family": "ipv4",
1301
+ "subsequent_address_family": "unicast",
1302
+ "remote_as": "64512",
1303
+ },
1304
+ MultiValue: map[string]int64{"admin_enabled": 1, "established": 1},
1305
+ Description: "Per-peer availability",
1306
+ Family: "Network/Routing/BGP/Peer/Availability",
1307
+ Unit: "{status}",
1308
+ },
1309
+ {
1310
+ Name: "bgpPeerAvailability",
1311
+ IsTable: true,
1312
+ Table: "hwBgpPeerTable",
1313
+ Tags: map[string]string{
1314
+ "routing_instance": "blue",
1315
+ "neighbor": "192.0.2.1",
1316
+ "address_family": "ipv6",
1317
+ "subsequent_address_family": "unicast",
1318
+ "remote_as": "64512",
1319
+ },
1320
+ MultiValue: map[string]int64{"admin_enabled": 1, "established": 0},
1321
+ Description: "Per-peer availability",
1322
+ Family: "Network/Routing/BGP/Peer/Availability",
1323
+ Unit: "{status}",
1324
+ },
1325
+ },
1326
+ },
1327
+
1328
+ "per_row composite can group by metric static tags": {
1329
+ profileDef: &ddprofiledefinition.ProfileDefinition{
1330
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1331
+ {
1332
+ Name: "bgpPeerUpdates",
1333
+ PerRow: true,
1334
+ GroupBy: ddprofiledefinition.StringArray{"routing_instance", "neighbor", "address_family", "subsequent_address_family"},
1335
+ EmitTags: []ddprofiledefinition.VirtualMetricEmitTagConfig{
1336
+ {Tag: "routing_instance", From: "routing_instance"},
1337
+ {Tag: "neighbor", From: "neighbor"},
1338
+ {Tag: "address_family", From: "address_family"},
1339
+ {Tag: "subsequent_address_family", From: "subsequent_address_family"},
1340
+ {Tag: "remote_as", From: "remote_as"},
1341
+ },
1342
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1343
+ {Metric: "bgpPeerInUpdates", Table: "tBgpPeerNgOperTable", As: "received"},
1344
+ {Metric: "bgpPeerOutUpdates", Table: "tBgpPeerNgOperTable", As: "sent"},
1345
+ },
1346
+ ChartMeta: ddprofiledefinition.ChartMeta{
1347
+ Description: "Per-peer update traffic",
1348
+ Family: "Network/Routing/BGP/Peer/Message/Update",
1349
+ Unit: "{message}",
1350
+ },
1351
+ },
1352
+ },
1353
+ },
1354
+ collectedMetrics: []ddsnmp.Metric{
1355
+ {
1356
+ Name: "bgpPeerInUpdates",
1357
+ Value: 11,
1358
+ IsTable: true,
1359
+ Table: "tBgpPeerNgOperTable",
1360
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
1361
+ Tags: map[string]string{
1362
+ "routing_instance": "Base",
1363
+ "neighbor": "192.0.2.1",
1364
+ "remote_as": "64512",
1365
+ },
1366
+ StaticTags: map[string]string{
1367
+ "address_family": "ipv4",
1368
+ "subsequent_address_family": "unicast",
1369
+ },
1370
+ },
1371
+ {
1372
+ Name: "bgpPeerOutUpdates",
1373
+ Value: 12,
1374
+ IsTable: true,
1375
+ Table: "tBgpPeerNgOperTable",
1376
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
1377
+ Tags: map[string]string{
1378
+ "routing_instance": "Base",
1379
+ "neighbor": "192.0.2.1",
1380
+ "remote_as": "64512",
1381
+ },
1382
+ StaticTags: map[string]string{
1383
+ "address_family": "ipv4",
1384
+ "subsequent_address_family": "unicast",
1385
+ },
1386
+ },
1387
+ {
1388
+ Name: "bgpPeerInUpdates",
1389
+ Value: 21,
1390
+ IsTable: true,
1391
+ Table: "tBgpPeerNgOperTable",
1392
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
1393
+ Tags: map[string]string{
1394
+ "routing_instance": "Base",
1395
+ "neighbor": "192.0.2.1",
1396
+ "remote_as": "64512",
1397
+ },
1398
+ StaticTags: map[string]string{
1399
+ "address_family": "ipv6",
1400
+ "subsequent_address_family": "unicast",
1401
+ },
1402
+ },
1403
+ {
1404
+ Name: "bgpPeerOutUpdates",
1405
+ Value: 22,
1406
+ IsTable: true,
1407
+ Table: "tBgpPeerNgOperTable",
1408
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
1409
+ Tags: map[string]string{
1410
+ "routing_instance": "Base",
1411
+ "neighbor": "192.0.2.1",
1412
+ "remote_as": "64512",
1413
+ },
1414
+ StaticTags: map[string]string{
1415
+ "address_family": "ipv6",
1416
+ "subsequent_address_family": "unicast",
1417
+ },
1418
+ },
1419
+ },
1420
+ expected: []ddsnmp.Metric{
1421
+ {
1422
+ Name: "bgpPeerUpdates",
1423
+ IsTable: true,
1424
+ Table: "tBgpPeerNgOperTable",
1425
+ Tags: map[string]string{
1426
+ "routing_instance": "Base",
1427
+ "neighbor": "192.0.2.1",
1428
+ "address_family": "ipv4",
1429
+ "subsequent_address_family": "unicast",
1430
+ "remote_as": "64512",
1431
+ },
1432
+ MultiValue: map[string]int64{"received": 11, "sent": 12},
1433
+ Description: "Per-peer update traffic",
1434
+ Family: "Network/Routing/BGP/Peer/Message/Update",
1435
+ Unit: "{message}",
1436
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
1437
+ },
1438
+ {
1439
+ Name: "bgpPeerUpdates",
1440
+ IsTable: true,
1441
+ Table: "tBgpPeerNgOperTable",
1442
+ Tags: map[string]string{
1443
+ "routing_instance": "Base",
1444
+ "neighbor": "192.0.2.1",
1445
+ "address_family": "ipv6",
1446
+ "subsequent_address_family": "unicast",
1447
+ "remote_as": "64512",
1448
+ },
1449
+ MultiValue: map[string]int64{"received": 21, "sent": 22},
1450
+ Description: "Per-peer update traffic",
1451
+ Family: "Network/Routing/BGP/Peer/Message/Update",
1452
+ Unit: "{message}",
1453
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
1454
+ },
1455
+ },
1456
+ },
1457
+
1458
"per_row single-source (value path)": {
1459
profileDef: &ddprofiledefinition.ProfileDefinition{
1460
VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
@@ -1400,7 +1793,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1793
{
1794
Name: "ifTraffic",
1795
PerRow: true,
1403
- GroupBy: []string{"interface"},
1796
+ GroupBy: ddprofiledefinition.StringArray{"interface"},
1797
Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1798
{Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1799
{Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
@@ -1462,7 +1855,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1855
{
1856
Name: "ifTraffic",
1857
PerRow: true,
1465
- GroupBy: []string{"interface"},
1858
+ GroupBy: ddprofiledefinition.StringArray{"interface"},
1859
Alternatives: []ddprofiledefinition.VirtualMetricAlternativeSourcesConfig{
1860
{Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1861
{Metric: "ifHCInOctets", Table: "ifXTable", As: "in"},
@@ -1526,7 +1919,15 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1919
}
1920
1921
func Test_vmBuildGroupKey(t *testing.T) {
1529
- const sep = '\x1F'
1922
+ encodeParts := func(parts ...string) string {
1923
+ var b strings.Builder
1924
+ for _, part := range parts {
1925
+ b.WriteString(strconv.Itoa(len(part)))
1926
+ b.WriteByte(':')
1927
+ b.WriteString(part)
1928
+ }
1929
+ return b.String()
1930
+ }
1931
1932
tests := map[string]struct {
1933
agg vmetricsAggregator
@@ -1545,7 +1946,7 @@ func Test_vmBuildGroupKey(t *testing.T) {
1946
agg: vmetricsAggregator{grouped: true, perRow: true},
1947
tags: map[string]string{"iface": "eth0", "_if_type": "loopback", "zone": "a"},
1948
wantOK: true,
1548
- wantKey: "iface=eth0" + string(sep) + "zone=a",
1949
+ wantKey: encodeParts("iface", "eth0", "zone", "a"),
1950
},
1951
1952
"per_row + no groupBy: all tags underscore -> no key": {
@@ -1559,14 +1960,14 @@ func Test_vmBuildGroupKey(t *testing.T) {
1960
agg: vmetricsAggregator{grouped: true, perRow: true, groupBy: []string{"_if_type", "iface"}},
1961
tags: map[string]string{"iface": "eth0", "_if_type": "ethernetCsmacd"},
1962
wantOK: true,
1562
- wantKey: "ethernetCsmacd" + string(sep) + "eth0",
1963
+ wantKey: encodeParts("ethernetCsmacd", "eth0"),
1964
},
1965
1565
- "per_row + groupBy: missing required tag -> no key": {
1966
+ "per_row + groupBy: missing hint falls back to stable visible-tag key": {
1967
agg: vmetricsAggregator{grouped: true, perRow: true, groupBy: []string{"iface", "zone"}},
1968
tags: map[string]string{"iface": "eth0"},
1568
- wantOK: false,
1569
- wantKey: "",
1969
+ wantOK: true,
1970
+ wantKey: encodeParts("iface", "eth0"),
1971
},
1972
1973
"non per_row + groupBy(1): returns that label value": {
@@ -1580,7 +1981,7 @@ func Test_vmBuildGroupKey(t *testing.T) {
1981
agg: vmetricsAggregator{grouped: true, perRow: false, groupBy: []string{"_if_type", "zone"}},
1982
tags: map[string]string{"_if_type": "ethernetCsmacd", "zone": "edge"},
1983
wantOK: true,
1583
- wantKey: "ethernetCsmacd" + string(sep) + "edge",
1984
+ wantKey: encodeParts("ethernetCsmacd", "edge"),
1985
},
1986
1987
"non per_row + groupBy: missing one value -> no key": {
@@ -1598,6 +1999,27 @@ func Test_vmBuildGroupKey(t *testing.T) {
1999
assert.Equal(t, tc.wantKey, key, "key mismatch")
2000
})
2001
}
2002
+
2003
+ t.Run("per_row fallback encodes key-value pairs without raw-delimiter collisions", func(t *testing.T) {
2004
+ keyA, okA := vmBuildGroupKey(map[string]string{"a=b": "c"}, &vmetricsAggregator{grouped: true, perRow: true})
2005
+ keyB, okB := vmBuildGroupKey(map[string]string{"a": "b=c"}, &vmetricsAggregator{grouped: true, perRow: true})
2006
+
2007
+ assert.True(t, okA)
2008
+ assert.True(t, okB)
2009
+ assert.NotEqual(t, keyA, keyB)
2010
+ })
2011
+
2012
+ t.Run("multi-label group_by encodes values without separator collisions", func(t *testing.T) {
2013
+ sep := string(rune(0x1F))
2014
+ agg := vmetricsAggregator{grouped: true, groupBy: []string{"iface", "zone"}}
2015
+
2016
+ keyA, okA := vmBuildGroupKey(map[string]string{"iface": "a" + sep + "b", "zone": "c"}, &agg)
2017
+ keyB, okB := vmBuildGroupKey(map[string]string{"iface": "a", "zone": "b" + sep + "c"}, &vmetricsAggregator{grouped: true, groupBy: []string{"iface", "zone"}})
2018
+
2019
+ assert.True(t, okA)
2020
+ assert.True(t, okB)
2021
+ assert.NotEqual(t, keyA, keyB)
2022
+ })
2023
}
2024
2025
var (
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/cross_table_lookup.go
new
+188
@@ -0,0 +1,188 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "fmt"
7
+ "net/netip"
8
+ "strings"
9
+
10
+ "github.com/gosnmp/gosnmp"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13
+)
14
+
15
+func (r *crossTableResolver) requiresLookupByValue(tagCfg ddprofiledefinition.MetricTagConfig) bool {
16
+ return tagCfg.LookupSymbol.OID != "" || tagCfg.LookupSymbol.Name != ""
17
+}
18
+
19
+func (r *crossTableResolver) resolveLookupIndexByValue(
20
+ tagCfg ddprofiledefinition.MetricTagConfig,
21
+ lookupValue string,
22
+ refTableOID string,
23
+ refTablePDUs map[string]gosnmp.SnmpPDU,
24
+ ctx *crossTableContext,
25
+) (string, error) {
26
+ normalizedLookupValue, ok := r.normalizeLookupValue(ddprofiledefinition.SymbolConfig(tagCfg.LookupSymbol), lookupValue)
27
+ if !ok {
28
+ return "", fmt.Errorf("value '%s' could not be normalized for lookup column %s", lookupValue, tagCfg.LookupSymbol.OID)
29
+ }
30
+
31
+ cacheKey := crossTableLookupKey{
32
+ refTableOID: refTableOID,
33
+ lookupColumnOID: trimOID(tagCfg.LookupSymbol.OID),
34
+ targetColumnOID: trimOID(tagCfg.Symbol.OID),
35
+ lookupValue: normalizedLookupValue,
36
+ }
37
+ if rowIndex, ok := ctx.lookupIndexCache[cacheKey]; ok {
38
+ if rowIndex == "" {
39
+ return "", fmt.Errorf("value '%s' not found in lookup column %s", normalizedLookupValue, tagCfg.LookupSymbol.OID)
40
+ }
41
+ return rowIndex, nil
42
+ }
43
+
44
+ rowIndex, err := r.findRowIndexByLookupValue(tagCfg, normalizedLookupValue, refTablePDUs)
45
+ if err != nil {
46
+ return "", err
47
+ }
48
+
49
+ ctx.lookupIndexCache[cacheKey] = rowIndex
50
+ return rowIndex, nil
51
+}
52
+
53
+func (r *crossTableResolver) findRowIndexByLookupValue(
54
+ tagCfg ddprofiledefinition.MetricTagConfig,
55
+ lookupValue string,
56
+ refTablePDUs map[string]gosnmp.SnmpPDU,
57
+) (string, error) {
58
+ lookupSym := tagCfg.LookupSymbol
59
+ lookupColumnOID := trimOID(lookupSym.OID)
60
+ prefix := lookupColumnOID + "."
61
+ matchedIndexes := make([]string, 0, 1)
62
+
63
+ for fullOID, pdu := range refTablePDUs {
64
+ if !strings.HasPrefix(fullOID, prefix) {
65
+ continue
66
+ }
67
+
68
+ val, ok := r.processLookupSymbolValue(ddprofiledefinition.SymbolConfig(lookupSym), pdu)
69
+ if !ok || val != lookupValue {
70
+ continue
71
+ }
72
+
73
+ matchedIndexes = append(matchedIndexes, strings.TrimPrefix(fullOID, prefix))
74
+ }
75
+
76
+ switch len(matchedIndexes) {
77
+ case 0:
78
+ return "", fmt.Errorf("value '%s' not found in lookup column %s", lookupValue, lookupSym.OID)
79
+ case 1:
80
+ return matchedIndexes[0], nil
81
+ }
82
+
83
+ rowIndex, ok := r.resolveAmbiguousLookupRows(tagCfg, matchedIndexes, refTablePDUs)
84
+ if ok {
85
+ return rowIndex, nil
86
+ }
87
+
88
+ return "", fmt.Errorf(
89
+ "lookup value '%s' matched multiple rows in %s with different values for %s",
90
+ lookupValue,
91
+ lookupSym.OID,
92
+ tagCfg.Symbol.OID,
93
+ )
94
+}
95
+
96
+func (r *crossTableResolver) processLookupSymbolValue(sym ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU) (string, bool) {
97
+ val, err := convPduToStringf(pdu, sym.Format)
98
+ if err != nil {
99
+ return "", false
100
+ }
101
+
102
+ return r.normalizeLookupText(sym, val, false)
103
+}
104
+
105
+func (r *crossTableResolver) normalizeLookupValue(sym ddprofiledefinition.SymbolConfig, raw string) (string, bool) {
106
+ return r.normalizeLookupText(sym, raw, true)
107
+}
108
+
109
+func (r *crossTableResolver) normalizeLookupText(sym ddprofiledefinition.SymbolConfig, val string, applyFormat bool) (string, bool) {
110
+ if sym.ExtractValueCompiled != nil {
111
+ sm := sym.ExtractValueCompiled.FindStringSubmatch(val)
112
+ if len(sm) > 1 {
113
+ val = sm[1]
114
+ }
115
+ }
116
+
117
+ if sym.MatchPatternCompiled != nil {
118
+ sm := sym.MatchPatternCompiled.FindStringSubmatch(val)
119
+ if len(sm) == 0 {
120
+ return "", false
121
+ }
122
+ val = replaceSubmatches(sym.MatchValue, sm)
123
+ }
124
+
125
+ if applyFormat && sym.Format != "" {
126
+ formatted, err := formatIndexTagValue(val, sym.Format)
127
+ if err != nil {
128
+ return "", false
129
+ }
130
+ val = formatted
131
+ }
132
+
133
+ if sym.Format == "ip_address" {
134
+ if addr, err := netip.ParseAddr(val); err == nil {
135
+ val = addr.Unmap().String()
136
+ }
137
+ }
138
+
139
+ if mapped, ok := sym.Mapping[val]; ok {
140
+ val = mapped
141
+ }
142
+
143
+ return val, true
144
+}
145
+
146
+func (r *crossTableResolver) resolveAmbiguousLookupRows(
147
+ tagCfg ddprofiledefinition.MetricTagConfig,
148
+ rowIndexes []string,
149
+ refTablePDUs map[string]gosnmp.SnmpPDU,
150
+) (string, bool) {
151
+ wantValue := ""
152
+
153
+ for _, rowIndex := range rowIndexes {
154
+ value, ok := r.resolveLookupTargetTagValue(tagCfg, rowIndex, refTablePDUs)
155
+ if !ok {
156
+ return "", false
157
+ }
158
+ if wantValue == "" {
159
+ wantValue = value
160
+ continue
161
+ }
162
+ if wantValue != value {
163
+ return "", false
164
+ }
165
+ }
166
+
167
+ return rowIndexes[0], wantValue != ""
168
+}
169
+
170
+func (r *crossTableResolver) resolveLookupTargetTagValue(
171
+ tagCfg ddprofiledefinition.MetricTagConfig,
172
+ rowIndex string,
173
+ refTablePDUs map[string]gosnmp.SnmpPDU,
174
+) (string, bool) {
175
+ pdu, err := r.lookupValue(tagCfg, rowIndex, refTablePDUs)
176
+ if err != nil {
177
+ return "", false
178
+ }
179
+
180
+ tags := make(map[string]string, 1)
181
+ if err := r.tagProcessor.processTag(tagCfg, pdu, tagAdder{tags: tags}); err != nil {
182
+ return "", false
183
+ }
184
+
185
+ tagName := ternary(tagCfg.Tag != "", tagCfg.Tag, tagCfg.Symbol.Name)
186
+ value := tags[tagName]
187
+ return value, value != ""
188
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/cross_table_lookup_test.go
new
+184
@@ -0,0 +1,184 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/gosnmp/gosnmp"
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/logger"
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
14
+)
15
+
16
+func TestCrossTableResolver_ResolveLookupIndexByValue_NormalizesIPv6CurrentRowIndex(t *testing.T) {
17
+ resolver := newCrossTableResolver(logger.New())
18
+ tagCfg := lookupTestTagConfig(
19
+ "neighbor",
20
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
21
+ "neighbor",
22
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
23
+ )
24
+
25
+ refTablePDUs := map[string]gosnmp.SnmpPDU{
26
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.2.1.2.16.32.1.18.248.0.0.0.0.0.0.0.0.2.35.2.83": createStringPDU(
27
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.2.1.2.16.32.1.18.248.0.0.0.0.0.0.0.0.2.35.2.83",
28
+ "2001:12F8::223:253",
29
+ ),
30
+ }
31
+
32
+ rowIndex, err := resolver.resolveLookupIndexByValue(
33
+ tagCfg,
34
+ "0.0.16.32.1.18.248.0.0.0.0.0.0.0.0.2.35.2.83",
35
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2",
36
+ refTablePDUs,
37
+ &crossTableContext{lookupIndexCache: map[crossTableLookupKey]string{}},
38
+ )
39
+ require.NoError(t, err)
40
+ assert.Equal(t, "0.2.1.2.16.32.1.18.248.0.0.0.0.0.0.0.0.2.35.2.83", rowIndex)
41
+}
42
+
43
+func TestCrossTableResolver_ResolveLookupIndexByValue_AllowsDuplicateRowsWhenTargetValueMatches(t *testing.T) {
44
+ resolver := newCrossTableResolver(logger.New())
45
+ tagCfg := lookupTestTagConfig(
46
+ "remote_as",
47
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2",
48
+ "neighbor",
49
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
50
+ )
51
+
52
+ refTablePDUs := map[string]gosnmp.SnmpPDU{
53
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.1.1.4.10.45.2.2": createStringPDU(
54
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.1.1.4.10.45.2.2",
55
+ "10.45.2.2",
56
+ ),
57
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.128.1.4.10.45.2.2": createStringPDU(
58
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.128.1.4.10.45.2.2",
59
+ "10.45.2.2",
60
+ ),
61
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.1.1.4.10.45.2.2": createGauge32PDU(
62
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.1.1.4.10.45.2.2",
63
+ 26479,
64
+ ),
65
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.128.1.4.10.45.2.2": createGauge32PDU(
66
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.128.1.4.10.45.2.2",
67
+ 26479,
68
+ ),
69
+ }
70
+
71
+ rowIndex, err := resolver.resolveLookupIndexByValue(
72
+ tagCfg,
73
+ "0.0.4.10.45.2.2",
74
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2",
75
+ refTablePDUs,
76
+ &crossTableContext{lookupIndexCache: map[crossTableLookupKey]string{}},
77
+ )
78
+ require.NoError(t, err)
79
+ assert.Contains(t, []string{
80
+ "0.1.1.1.4.10.45.2.2",
81
+ "0.1.128.1.4.10.45.2.2",
82
+ }, rowIndex)
83
+}
84
+
85
+func TestCrossTableResolver_ResolveLookupIndexByValue_RejectsDuplicateRowsWhenTargetValueDiffers(t *testing.T) {
86
+ resolver := newCrossTableResolver(logger.New())
87
+ tagCfg := lookupTestTagConfig(
88
+ "remote_as",
89
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2",
90
+ "neighbor",
91
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
92
+ )
93
+
94
+ refTablePDUs := map[string]gosnmp.SnmpPDU{
95
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.1.1.4.10.45.2.2": createStringPDU(
96
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.1.1.4.10.45.2.2",
97
+ "10.45.2.2",
98
+ ),
99
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.128.1.4.10.45.2.2": createStringPDU(
100
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.128.1.4.10.45.2.2",
101
+ "10.45.2.2",
102
+ ),
103
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.1.1.4.10.45.2.2": createGauge32PDU(
104
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.1.1.4.10.45.2.2",
105
+ 26479,
106
+ ),
107
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.128.1.4.10.45.2.2": createGauge32PDU(
108
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.128.1.4.10.45.2.2",
109
+ 64512,
110
+ ),
111
+ }
112
+
113
+ _, err := resolver.resolveLookupIndexByValue(
114
+ tagCfg,
115
+ "0.0.4.10.45.2.2",
116
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2",
117
+ refTablePDUs,
118
+ &crossTableContext{lookupIndexCache: map[crossTableLookupKey]string{}},
119
+ )
120
+ require.Error(t, err)
121
+ assert.Contains(t, err.Error(), "matched multiple rows")
122
+ assert.Contains(t, err.Error(), tagCfg.Symbol.OID)
123
+}
124
+
125
+func TestCrossTableResolver_ResolveLookupIndexByValue_DoesNotCacheLookupErrorsAsNotFound(t *testing.T) {
126
+ resolver := newCrossTableResolver(logger.New())
127
+ tagCfg := lookupTestTagConfig(
128
+ "remote_as",
129
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2",
130
+ "neighbor",
131
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4",
132
+ )
133
+
134
+ refTablePDUs := map[string]gosnmp.SnmpPDU{
135
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.1.1.4.10.45.2.2": createStringPDU(
136
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.1.1.4.10.45.2.2",
137
+ "10.45.2.2",
138
+ ),
139
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.128.1.4.10.45.2.2": createStringPDU(
140
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.4.0.1.128.1.4.10.45.2.2",
141
+ "10.45.2.2",
142
+ ),
143
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.1.1.4.10.45.2.2": createGauge32PDU(
144
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.1.1.4.10.45.2.2",
145
+ 26479,
146
+ ),
147
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.128.1.4.10.45.2.2": createGauge32PDU(
148
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2.1.2.0.1.128.1.4.10.45.2.2",
149
+ 64512,
150
+ ),
151
+ }
152
+ ctx := &crossTableContext{lookupIndexCache: map[crossTableLookupKey]string{}}
153
+
154
+ for i := 0; i < 2; i++ {
155
+ _, err := resolver.resolveLookupIndexByValue(
156
+ tagCfg,
157
+ "0.0.4.10.45.2.2",
158
+ "1.3.6.1.4.1.2011.5.25.177.1.1.2",
159
+ refTablePDUs,
160
+ ctx,
161
+ )
162
+ require.Error(t, err)
163
+ assert.Contains(t, err.Error(), "matched multiple rows")
164
+ assert.Contains(t, err.Error(), tagCfg.Symbol.OID)
165
+ }
166
+ assert.Empty(t, ctx.lookupIndexCache)
167
+}
168
+
169
+func lookupTestTagConfig(tagName, symbolOID, lookupName, lookupOID string) ddprofiledefinition.MetricTagConfig {
170
+ return ddprofiledefinition.MetricTagConfig{
171
+ Tag: tagName,
172
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
173
+ OID: symbolOID,
174
+ Name: tagName,
175
+ },
176
+ LookupSymbol: ddprofiledefinition.SymbolConfigCompat{
177
+ OID: lookupOID,
178
+ Name: lookupName,
179
+ Format: "ip_address",
180
+ ExtractValue: `^(?:[^.]+\.){2}(?:4|16)\.(.*)$`,
181
+ ExtractValueCompiled: mustCompileRegex(`^(?:[^.]+\.){2}(?:4|16)\.(.*)$`),
182
+ },
183
+ }
184
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/index_tag_value.go
new
+87
@@ -0,0 +1,87 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "fmt"
7
+ "strconv"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
11
+)
12
+
13
+func processRawIndexTagValue(cfg ddprofiledefinition.MetricTagConfig, raw string) (string, error) {
14
+ val := raw
15
+
16
+ if cfg.Symbol.ExtractValueCompiled != nil {
17
+ sm := cfg.Symbol.ExtractValueCompiled.FindStringSubmatch(val)
18
+ if len(sm) < 2 {
19
+ return "", fmt.Errorf("extract_value did not match transformed index '%s'", raw)
20
+ }
21
+ val = sm[1]
22
+ }
23
+
24
+ if cfg.Symbol.MatchPatternCompiled != nil {
25
+ sm := cfg.Symbol.MatchPatternCompiled.FindStringSubmatch(val)
26
+ if len(sm) == 0 {
27
+ return "", fmt.Errorf("match_pattern '%s' did not match transformed index '%s'", cfg.Symbol.MatchPattern, val)
28
+ }
29
+ val = replaceSubmatches(cfg.Symbol.MatchValue, sm)
30
+ }
31
+
32
+ if cfg.Symbol.Format != "" {
33
+ formatted, err := formatIndexTagValue(val, cfg.Symbol.Format)
34
+ if err != nil {
35
+ return "", err
36
+ }
37
+ val = formatted
38
+ }
39
+
40
+ if mapped, ok := cfg.Mapping[val]; ok {
41
+ val = mapped
42
+ }
43
+
44
+ return val, nil
45
+}
46
+
47
+func formatIndexTagValue(raw string, format string) (string, error) {
48
+ switch format {
49
+ case "", "string":
50
+ return raw, nil
51
+ case "ip_address":
52
+ return formatIndexIPAddress(raw)
53
+ default:
54
+ return raw, nil
55
+ }
56
+}
57
+
58
+func formatIndexIPAddress(raw string) (string, error) {
59
+ if strings.Contains(raw, ":") {
60
+ if s, ok := canonicalIPAddressText(raw); ok {
61
+ return s, nil
62
+ }
63
+ return "", fmt.Errorf("cannot convert transformed index '%s' to IP address", raw)
64
+ }
65
+
66
+ parts := strings.Split(raw, ".")
67
+ switch len(parts) {
68
+ case 4, 8, 16, 20:
69
+ default:
70
+ return "", fmt.Errorf("cannot convert transformed index '%s' to IP address", raw)
71
+ }
72
+
73
+ bytes := make([]byte, 0, len(parts))
74
+ for _, part := range parts {
75
+ n, err := strconv.Atoi(part)
76
+ if err != nil || n < 0 || n > 255 {
77
+ return "", fmt.Errorf("cannot convert transformed index '%s' to IP address", raw)
78
+ }
79
+ bytes = append(bytes, byte(n))
80
+ }
81
+
82
+ if s, ok := ipAddressFromRawBytes(bytes); ok {
83
+ return s, nil
84
+ }
85
+
86
+ return "", fmt.Errorf("cannot convert transformed index '%s' to IP address", raw)
87
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/index_tag_value_test.go
new
+136
@@ -0,0 +1,136 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/assert"
9
+ "github.com/stretchr/testify/require"
10
+
11
+ "github.com/netdata/netdata/go/plugins/logger"
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13
+)
14
+
15
+func TestTableRowProcessor_ProcessIndexTag_DropRightIPAddress(t *testing.T) {
16
+ p := newTableRowProcessor(logger.New())
17
+
18
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
19
+ Tag: "neighbor",
20
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
21
+ Name: "cbgpPeer2RemoteAddrIndex",
22
+ Format: "ip_address",
23
+ },
24
+ IndexTransform: []ddprofiledefinition.MetricIndexTransform{
25
+ {
26
+ Start: 2,
27
+ DropRight: 2,
28
+ },
29
+ },
30
+ }, "1.4.192.0.2.1.1.128")
31
+
32
+ require.NoError(t, err)
33
+ assert.Equal(t, "neighbor", tagName)
34
+ assert.Equal(t, "192.0.2.1", tagValue)
35
+}
36
+
37
+func TestTableRowProcessor_ProcessIndexTag_RegexMappedFamily(t *testing.T) {
38
+ p := newTableRowProcessor(logger.New())
39
+
40
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
41
+ Tag: "subsequent_address_family",
42
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
43
+ Name: "cbgpPeer2AddrFamilySafiIndex",
44
+ ExtractValueCompiled: mustCompileRegex(`^(?:\d+\.)+\d+\.(\d+)$`),
45
+ },
46
+ Mapping: map[string]string{
47
+ "128": "vpn",
48
+ },
49
+ }, "1.4.192.0.2.1.1.128")
50
+
51
+ require.NoError(t, err)
52
+ assert.Equal(t, "subsequent_address_family", tagName)
53
+ assert.Equal(t, "vpn", tagValue)
54
+}
55
+
56
+func TestTableRowProcessor_ProcessIndexTag_PositionUsesSymbolNameFallback(t *testing.T) {
57
+ p := newTableRowProcessor(logger.New())
58
+
59
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
60
+ Index: 2,
61
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
62
+ Name: "neighbor",
63
+ },
64
+ Mapping: map[string]string{
65
+ "42": "mapped",
66
+ },
67
+ }, "7.42.9")
68
+
69
+ require.NoError(t, err)
70
+ assert.Equal(t, "neighbor", tagName)
71
+ assert.Equal(t, "mapped", tagValue)
72
+}
73
+
74
+func TestTableRowProcessor_ProcessIndexTag_IPv4zAddress(t *testing.T) {
75
+ p := newTableRowProcessor(logger.New())
76
+
77
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
78
+ Tag: "neighbor",
79
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
80
+ Name: "bgpPeerRemoteAddrIndex",
81
+ Format: "ip_address",
82
+ },
83
+ }, "192.0.2.1.0.0.0.7")
84
+
85
+ require.NoError(t, err)
86
+ assert.Equal(t, "neighbor", tagName)
87
+ assert.Equal(t, "192.0.2.1%0.0.0.7", tagValue)
88
+}
89
+
90
+func TestTableRowProcessor_ProcessIndexTag_IPv6zAddress(t *testing.T) {
91
+ p := newTableRowProcessor(logger.New())
92
+
93
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
94
+ Tag: "neighbor",
95
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
96
+ Name: "bgpPeerRemoteAddrIndex",
97
+ Format: "ip_address",
98
+ },
99
+ }, "254.128.1.2.0.0.0.0.194.213.130.253.254.123.34.167.0.0.14.132")
100
+
101
+ require.NoError(t, err)
102
+ assert.Equal(t, "neighbor", tagName)
103
+ assert.Equal(t, "fe80:102::c2d5:82fd:fe7b:22a7%0.0.14.132", tagValue)
104
+}
105
+
106
+func TestTableRowProcessor_ProcessIndexTag_TextIPv6AddressIsCanonicalized(t *testing.T) {
107
+ p := newTableRowProcessor(logger.New())
108
+
109
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
110
+ Tag: "neighbor",
111
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
112
+ Name: "bgpPeerRemoteAddrIndex",
113
+ Format: "ip_address",
114
+ },
115
+ }, "2001:0550:0002:002f:0000:0000:0033:0001")
116
+
117
+ require.NoError(t, err)
118
+ assert.Equal(t, "neighbor", tagName)
119
+ assert.Equal(t, "2001:550:2:2f::33:1", tagValue)
120
+}
121
+
122
+func TestTableRowProcessor_ProcessIndexTag_RawIPv6AddressIsCanonicalized(t *testing.T) {
123
+ p := newTableRowProcessor(logger.New())
124
+
125
+ tagName, tagValue, err := p.processIndexTag(ddprofiledefinition.MetricTagConfig{
126
+ Tag: "neighbor",
127
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
128
+ Name: "bgpPeerRemoteAddrIndex",
129
+ Format: "ip_address",
130
+ },
131
+ }, "32.1.5.80.0.2.0.47.0.0.0.0.0.51.0.1")
132
+
133
+ require.NoError(t, err)
134
+ assert.Equal(t, "neighbor", tagName)
135
+ assert.Equal(t, "2001:550:2:2f::33:1", tagValue)
136
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/ip_address_format.go
new
+142
@@ -0,0 +1,142 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "fmt"
7
+ "net/netip"
8
+ "strconv"
9
+ "strings"
10
+ "unicode/utf8"
11
+
12
+ "github.com/gosnmp/gosnmp"
13
+)
14
+
15
+func convPduToIPAddress(pdu gosnmp.SnmpPDU) (string, error) {
16
+ if pdu.Type == gosnmp.IPAddress {
17
+ return convPduToString(pdu)
18
+ }
19
+
20
+ switch v := pdu.Value.(type) {
21
+ case []byte:
22
+ if s, ok := ipAddressFromOctetBytes(v); ok {
23
+ return s, nil
24
+ }
25
+ case string:
26
+ if s, ok := ipAddressFromText(v); ok {
27
+ return s, nil
28
+ }
29
+ }
30
+
31
+ return "", fmt.Errorf("cannot convert %T to IP address", pdu.Value)
32
+}
33
+
34
+func ipAddressFromOctetBytes(bs []byte) (string, bool) {
35
+ if s, ok := ipAddressFromRawBytes(bs); ok {
36
+ return s, true
37
+ }
38
+
39
+ if !utf8.Valid(bs) {
40
+ return "", false
41
+ }
42
+
43
+ return ipAddressFromText(string(bs))
44
+}
45
+
46
+func ipAddressFromText(raw string) (string, bool) {
47
+ raw = strings.TrimSpace(raw)
48
+ if raw == "" {
49
+ return "", false
50
+ }
51
+
52
+ if s, ok := canonicalIPAddressText(raw); ok {
53
+ return s, true
54
+ }
55
+
56
+ decoded, ok := parseDecimalOctets(raw)
57
+ if !ok {
58
+ return "", false
59
+ }
60
+
61
+ if s, ok := ipAddressFromRawBytes(decoded); ok {
62
+ return s, true
63
+ }
64
+
65
+ if !utf8.Valid(decoded) {
66
+ return "", false
67
+ }
68
+
69
+ text := strings.TrimSpace(string(decoded))
70
+ if s, ok := canonicalIPAddressText(text); ok {
71
+ return s, true
72
+ }
73
+
74
+ return "", false
75
+}
76
+
77
+func canonicalIPAddressText(raw string) (string, bool) {
78
+ addr, err := netip.ParseAddr(raw)
79
+ if err != nil {
80
+ return "", false
81
+ }
82
+ return addr.Unmap().String(), true
83
+}
84
+
85
+func ipAddressFromRawBytes(bs []byte) (string, bool) {
86
+ switch len(bs) {
87
+ case 4:
88
+ return renderIPv4Bytes(bs), true
89
+ case 8:
90
+ return renderIPv4Bytes(bs[:4]) + "%" + renderZoneIndex(bs[4:8]), true
91
+ case 16:
92
+ return canonicalIPAddressBytes(bs)
93
+ case 20:
94
+ if s, ok := canonicalIPAddressBytes(bs[:16]); ok {
95
+ return s + "%" + renderZoneIndex(bs[16:20]), true
96
+ }
97
+ return "", false
98
+ default:
99
+ return "", false
100
+ }
101
+}
102
+
103
+func renderIPv4Bytes(bs []byte) string {
104
+ return fmt.Sprintf("%d.%d.%d.%d", bs[0], bs[1], bs[2], bs[3])
105
+}
106
+
107
+func canonicalIPAddressBytes(bs []byte) (string, bool) {
108
+ addr, ok := netip.AddrFromSlice(bs)
109
+ if !ok {
110
+ return "", false
111
+ }
112
+ return addr.Unmap().String(), true
113
+}
114
+
115
+func renderZoneIndex(bs []byte) string {
116
+ parts := make([]string, 0, len(bs))
117
+ for _, b := range bs {
118
+ parts = append(parts, strconv.Itoa(int(b)))
119
+ }
120
+ return strings.Join(parts, ".")
121
+}
122
+
123
+func parseDecimalOctets(raw string) ([]byte, bool) {
124
+ parts := strings.Split(raw, ".")
125
+ if len(parts) < 2 {
126
+ return nil, false
127
+ }
128
+
129
+ out := make([]byte, 0, len(parts))
130
+ for _, part := range parts {
131
+ if part == "" {
132
+ return nil, false
133
+ }
134
+ n, err := strconv.Atoi(part)
135
+ if err != nil || n < 0 || n > 255 {
136
+ return nil, false
137
+ }
138
+ out = append(out, byte(n))
139
+ }
140
+
141
+ return out, true
142
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/ip_address_format_test.go
new
+73
@@ -0,0 +1,73 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "net/netip"
7
+ "testing"
8
+
9
+ "github.com/gosnmp/gosnmp"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+)
13
+
14
+func TestConvPduToIPAddress(t *testing.T) {
15
+ t.Run("raw ipv4 octets", func(t *testing.T) {
16
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte{169, 254, 247, 2}))
17
+ require.NoError(t, err)
18
+ assert.Equal(t, "169.254.247.2", s)
19
+ })
20
+
21
+ t.Run("textual octet string ipv4", func(t *testing.T) {
22
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte("169.254.247.2")))
23
+ require.NoError(t, err)
24
+ assert.Equal(t, "169.254.247.2", s)
25
+ })
26
+
27
+ t.Run("decimal encoded octets that decode to textual ip", func(t *testing.T) {
28
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte("49.57.50.46.49.54.56.46.50.53.53.46.49.48")))
29
+ require.NoError(t, err)
30
+ assert.Equal(t, "192.168.255.10", s)
31
+ })
32
+
33
+ t.Run("textual ipv6 octet string", func(t *testing.T) {
34
+ input := "2001:0550:0002:002f:0000:0000:0033:0001"
35
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte("2001:0550:0002:002f:0000:0000:0033:0001")))
36
+ require.NoError(t, err)
37
+ assert.Equal(t, netip.MustParseAddr(input).String(), s)
38
+ })
39
+
40
+ t.Run("textual ipv6-mapped ipv4 octet string is unmapped", func(t *testing.T) {
41
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte("::ffff:192.0.2.10")))
42
+ require.NoError(t, err)
43
+ assert.Equal(t, "192.0.2.10", s)
44
+ })
45
+
46
+ t.Run("raw ipv4z octets", func(t *testing.T) {
47
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte{192, 0, 2, 1, 0, 0, 0, 7}))
48
+ require.NoError(t, err)
49
+ assert.Equal(t, "192.0.2.1%0.0.0.7", s)
50
+ })
51
+
52
+ t.Run("raw ipv6 octets are canonicalized like text", func(t *testing.T) {
53
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte{
54
+ 32, 1, 5, 80, 0, 2, 0, 47, 0, 0, 0, 0, 0, 51, 0, 1,
55
+ }))
56
+ require.NoError(t, err)
57
+ assert.Equal(t, "2001:550:2:2f::33:1", s)
58
+ })
59
+
60
+ t.Run("raw ipv6z octets", func(t *testing.T) {
61
+ s, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte{
62
+ 254, 128, 1, 2, 0, 0, 0, 0, 194, 213, 130, 253, 254, 123, 34, 167,
63
+ 0, 0, 14, 132,
64
+ }))
65
+ require.NoError(t, err)
66
+ assert.Equal(t, "fe80:102::c2d5:82fd:fe7b:22a7%0.0.14.132", s)
67
+ })
68
+
69
+ t.Run("invalid octet string", func(t *testing.T) {
70
+ _, err := convPduToIPAddress(createPDU("1.2.3", gosnmp.OctetString, []byte("not-an-ip")))
71
+ require.Error(t, err)
72
+ })
73
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/metric_builder.go
+9
@@ -38,6 +38,15 @@ func (mb *metricBuilder) withTags(tags map[string]string) *metricBuilder {
38
func (mb *metricBuilder) withStaticTags(tags map[string]string) *metricBuilder {
39
if len(tags) > 0 {
40
mb.metric.StaticTags = maps.Clone(tags)
41
+ if mb.metric.Tags == nil {
42
+ mb.metric.Tags = maps.Clone(tags)
43
+ } else {
44
+ for k, v := range tags {
45
+ if current, ok := mb.metric.Tags[k]; !ok || current == "" {
46
+ mb.metric.Tags[k] = v
47
+ }
48
+ }
49
+ }
50
}
51
return mb
52
}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/metric_builder_test.go
new
+46
@@ -0,0 +1,46 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/assert"
9
+)
10
+
11
+func TestMetricBuilder_WithStaticTagsFillsMissingAndEmptyValues(t *testing.T) {
12
+ metric := newMetricBuilder("testMetric", 1).
13
+ withTags(map[string]string{
14
+ "region": "",
15
+ "neighbor": "192.0.2.10",
16
+ }).
17
+ withStaticTags(map[string]string{
18
+ "region": "eu-west",
19
+ "site": "athens",
20
+ }).
21
+ build()
22
+
23
+ assert.Equal(t, map[string]string{
24
+ "region": "eu-west",
25
+ "neighbor": "192.0.2.10",
26
+ "site": "athens",
27
+ }, metric.Tags)
28
+ assert.Equal(t, map[string]string{
29
+ "region": "eu-west",
30
+ "site": "athens",
31
+ }, metric.StaticTags)
32
+}
33
+
34
+func TestMetricBuilder_WithStaticTagsKeepsExistingNonEmptyValues(t *testing.T) {
35
+ metric := newMetricBuilder("testMetric", 1).
36
+ withTags(map[string]string{
37
+ "region": "edge",
38
+ }).
39
+ withStaticTags(map[string]string{
40
+ "region": "core",
41
+ }).
42
+ build()
43
+
44
+ assert.Equal(t, map[string]string{"region": "edge"}, metric.Tags)
45
+ assert.Equal(t, map[string]string{"region": "core"}, metric.StaticTags)
46
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/table_row_processor.go
+76
-25
@@ -30,7 +30,7 @@ type (
30
// tableRowProcessingContext contains context needed for processing a row
31
tableRowProcessingContext struct {
32
config ddprofiledefinition.MetricsConfig
33
- columnOIDs map[string]ddprofiledefinition.SymbolConfig
33
+ columnOIDs map[string][]ddprofiledefinition.SymbolConfig
34
crossTableCtx *crossTableContext
35
orderedTags []orderedTagConfig
36
}
@@ -80,20 +80,20 @@ func (p *tableRowProcessor) processSingleSameTableTag(row *tableRowData, tagCfg
80
81
ta := tagAdder{tags: row.tags}
82
if err := p.tagProc.processTag(tagCfg, pdu, ta); err != nil {
83
- p.log.Debugf("Error processing tag %s: %v", tagCfg.Tag, err)
83
+ p.log.Debugf("Error processing tag %s: %v", metricTagDisplayName(tagCfg), err)
84
}
85
}
86
87
func (p *tableRowProcessor) processSingleCrossTableTag(row *tableRowData, tagCfg ddprofiledefinition.MetricTagConfig, ctx *tableRowProcessingContext) {
88
if err := p.crossTableResolver.resolveCrossTableTag(tagCfg, row.index, ctx.crossTableCtx); err != nil {
89
- p.log.Debugf("Error resolving cross-table tag %s: %v", tagCfg.Tag, err)
89
+ p.log.Debugf("Error resolving cross-table tag %s: %v", metricTagDisplayName(tagCfg), err)
90
}
91
}
92
93
func (p *tableRowProcessor) processSingleIndexTag(row *tableRowData, tagCfg ddprofiledefinition.MetricTagConfig) {
94
- tagName, indexValue, ok := p.processIndexTag(tagCfg, row.index)
95
- if !ok {
96
- p.log.Debugf("Cannot extract position %d from index %s", tagCfg.Index, row.index)
94
+ tagName, indexValue, err := p.processIndexTag(tagCfg, row.index)
95
+ if err != nil {
96
+ p.log.Debugf("Cannot process index tag %s from index %s: %v", metricTagDisplayName(tagCfg), row.index, err)
97
return
98
}
99
@@ -101,19 +101,44 @@ func (p *tableRowProcessor) processSingleIndexTag(row *tableRowData, tagCfg ddpr
101
ta.addTag(tagName, indexValue)
102
}
103
104
-func (p *tableRowProcessor) processIndexTag(cfg ddprofiledefinition.MetricTagConfig, index string) (string, string, bool) {
105
- indexValue, ok := p.extractIndexPosition(index, cfg.Index)
106
- if !ok {
107
- return "", "", false
104
+func (p *tableRowProcessor) processIndexTag(cfg ddprofiledefinition.MetricTagConfig, index string) (string, string, error) {
105
+ tagName := metricTagDisplayName(cfg)
106
+
107
+ rawValue := index
108
+ if cfg.Index != 0 {
109
+ indexValue, ok := p.extractIndexPosition(index, cfg.Index)
110
+ if !ok {
111
+ return "", "", fmt.Errorf("position %d not found", cfg.Index)
112
+ }
113
+ rawValue = indexValue
114
}
115
110
- tagName := ternary(cfg.Tag != "", cfg.Tag, fmt.Sprintf("index%d", cfg.Index))
116
+ if cfg.Index == 0 && len(cfg.IndexTransform) > 0 {
117
+ rawValue = p.crossTableResolver.applyIndexTransform(index, cfg.IndexTransform)
118
+ if rawValue == "" {
119
+ return "", "", fmt.Errorf("index transformation failed")
120
+ }
121
+ }
122
112
- if v, ok := cfg.Mapping[indexValue]; ok {
113
- indexValue = v
123
+ value, err := processRawIndexTagValue(cfg, rawValue)
124
+ if err != nil {
125
+ return "", "", err
126
}
127
116
- return tagName, indexValue, true
128
+ return tagName, value, nil
129
+}
130
+
131
+func metricTagDisplayName(cfg ddprofiledefinition.MetricTagConfig) string {
132
+ switch {
133
+ case cfg.Tag != "":
134
+ return cfg.Tag
135
+ case cfg.Symbol.Name != "":
136
+ return cfg.Symbol.Name
137
+ case cfg.Index != 0:
138
+ return fmt.Sprintf("index%d", cfg.Index)
139
+ default:
140
+ return "index"
141
+ }
142
}
143
144
// extractPosition extracts a specific position from an index
@@ -141,21 +166,27 @@ func (p *tableRowProcessor) extractIndexPosition(index string, position uint) (s
166
}
167
168
func (p *tableRowProcessor) processRowMetrics(row *tableRowData, ctx *tableRowProcessingContext) ([]ddsnmp.Metric, error) {
144
- metrics := make([]ddsnmp.Metric, 0, len(ctx.columnOIDs))
169
+ symbolCount := 0
170
+ for _, syms := range ctx.columnOIDs {
171
+ symbolCount += len(syms)
172
+ }
173
+ metrics := make([]ddsnmp.Metric, 0, symbolCount)
174
146
- for columnOID, sym := range ctx.columnOIDs {
175
+ for columnOID, syms := range ctx.columnOIDs {
176
pdu, ok := row.pdus[columnOID]
177
if !ok {
178
continue
179
}
180
152
- metric, err := p.createMetric(sym, pdu, row)
153
- if err != nil {
154
- p.log.Debugf("Error creating metric %s: %v", sym.Name, err)
155
- continue
156
- }
181
+ for _, sym := range syms {
182
+ metric, err := p.createMetric(sym, pdu, row)
183
+ if err != nil {
184
+ p.log.Debugf("Error creating metric %s: %v", sym.Name, err)
185
+ continue
186
+ }
187
158
- metrics = append(metrics, *metric)
188
+ metrics = append(metrics, *metric)
189
+ }
190
}
191
192
return metrics, nil
@@ -171,6 +202,12 @@ func (p *tableRowProcessor) createMetric(sym ddprofiledefinition.SymbolConfig, p
202
}
203
204
type (
205
+ crossTableLookupKey struct {
206
+ refTableOID string
207
+ lookupColumnOID string
208
+ targetColumnOID string
209
+ lookupValue string
210
+ }
211
// crossTableResolver handles resolving tags from other tables
212
crossTableResolver struct {
213
log *logger.Logger
@@ -178,9 +215,10 @@ type (
215
}
216
// crossTableContext contains all data needed for cross-table resolution
217
crossTableContext struct {
181
- walkedData map[string]map[string]gosnmp.SnmpPDU // tableOID -> PDUs
182
- tableNameToOID map[string]string // tableName -> tableOID
183
- rowTags map[string]string
218
+ walkedData map[string]map[string]gosnmp.SnmpPDU // tableOID -> PDUs
219
+ tableNameToOID map[string]string // tableName -> tableOID
220
+ lookupIndexCache map[crossTableLookupKey]string // cache key -> resolved row index
221
+ rowTags map[string]string
222
}
223
)
224
@@ -208,6 +246,13 @@ func (r *crossTableResolver) resolveCrossTableTag(tagCfg ddprofiledefinition.Met
246
return err
247
}
248
249
+ if r.requiresLookupByValue(tagCfg) {
250
+ lookupIndex, err = r.resolveLookupIndexByValue(tagCfg, lookupIndex, refTableOID, refTablePDUs, ctx)
251
+ if err != nil {
252
+ return err
253
+ }
254
+ }
255
+
256
pdu, err := r.lookupValue(tagCfg, lookupIndex, refTablePDUs)
257
if err != nil {
258
return err
@@ -277,6 +322,12 @@ func (r *crossTableResolver) applyIndexTransform(index string, transforms []ddpr
322
323
for _, transform := range transforms {
324
start, end := transform.Start, transform.End
325
+ if transform.DropRight > 0 {
326
+ if int(transform.DropRight) >= len(parts) {
327
+ return ""
328
+ }
329
+ end = uint(len(parts) - int(transform.DropRight) - 1)
330
+ }
331
332
if int(start) >= len(parts) || end < start || int(end) >= len(parts) {
333
return ""
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/tag_processor_test.go
new
+75
@@ -0,0 +1,75 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/gosnmp/gosnmp"
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13
+)
14
+
15
+func TestTableTagProcessor_ProcessTag_Uint32Format(t *testing.T) {
16
+ processor := newTableTagProcessor()
17
+ ta := tagAdder{tags: map[string]string{}}
18
+
19
+ err := processor.processTag(ddprofiledefinition.MetricTagConfig{
20
+ Tag: "remote_as",
21
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
22
+ OID: "1.3.6.1.2.1.15.3.1.9",
23
+ Name: "bgpPeerRemoteAs",
24
+ Format: "uint32",
25
+ },
26
+ }, gosnmp.SnmpPDU{
27
+ Name: "1.3.6.1.2.1.15.3.1.9.169.254.1.1",
28
+ Type: gosnmp.Integer,
29
+ Value: -94967296,
30
+ }, ta)
31
+
32
+ require.NoError(t, err)
33
+ assert.Equal(t, "4200000000", ta.tags["remote_as"])
34
+}
35
+
36
+func TestMetricTagDisplayName(t *testing.T) {
37
+ tests := map[string]struct {
38
+ cfg ddprofiledefinition.MetricTagConfig
39
+ want string
40
+ }{
41
+ "explicit tag": {
42
+ cfg: ddprofiledefinition.MetricTagConfig{
43
+ Tag: "neighbor",
44
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
45
+ Name: "peer_addr",
46
+ },
47
+ },
48
+ want: "neighbor",
49
+ },
50
+ "symbol name fallback": {
51
+ cfg: ddprofiledefinition.MetricTagConfig{
52
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
53
+ Name: "peer_addr",
54
+ },
55
+ },
56
+ want: "peer_addr",
57
+ },
58
+ "index fallback": {
59
+ cfg: ddprofiledefinition.MetricTagConfig{
60
+ Index: 2,
61
+ },
62
+ want: "index2",
63
+ },
64
+ "raw index fallback": {
65
+ cfg: ddprofiledefinition.MetricTagConfig{},
66
+ want: "index",
67
+ },
68
+ }
69
+
70
+ for name, tc := range tests {
71
+ t.Run(name, func(t *testing.T) {
72
+ assert.Equal(t, tc.want, metricTagDisplayName(tc.cfg))
73
+ })
74
+ }
75
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/utils.go
+23
-23
@@ -57,30 +57,13 @@ func convPduToStringf(pdu gosnmp.SnmpPDU, format string) (string, error) {
57
case "mac_address":
58
return convPhysAddressToString(pdu)
59
case "ip_address":
60
- if pdu.Type == gosnmp.IPAddress {
61
- // Use the default handler for IP addresses
62
- return convPduToString(pdu)
60
+ return convPduToIPAddress(pdu)
61
+ case "uint32":
62
+ value, err := convNumericPduToInt64f(pdu, format)
63
+ if err != nil {
64
+ return "", err
65
}
64
-
65
- // Try to handle as bytes that represent an IP
66
- bs, ok := pdu.Value.([]byte)
67
- if !ok {
68
- return "", fmt.Errorf("cannot convert %T to IP address", pdu.Value)
69
- }
70
-
71
- if len(bs) == 4 {
72
- // IPv4
73
- return fmt.Sprintf("%d.%d.%d.%d", bs[0], bs[1], bs[2], bs[3]), nil
74
- } else if len(bs) == 16 {
75
- // IPv6
76
- parts := make([]string, 0, 8)
77
- for i := 0; i < 16; i += 2 {
78
- parts = append(parts, fmt.Sprintf("%02x%02x", bs[i], bs[i+1]))
79
- }
80
- return strings.Join(parts, ":"), nil
81
- }
82
-
83
- return "", fmt.Errorf("cannot convert %v to IP address (incorrect length)", pdu.Value)
66
+ return strconv.FormatInt(value, 10), nil
67
case "hex":
68
// Convert any value to hex string
69
bs, ok := pdu.Value.([]byte)
@@ -94,6 +77,23 @@ func convPduToStringf(pdu gosnmp.SnmpPDU, format string) (string, error) {
77
}
78
}
79
80
+func convNumericPduToInt64f(pdu gosnmp.SnmpPDU, format string) (int64, error) {
81
+ if !isPduNumericType(pdu) {
82
+ return 0, fmt.Errorf("cannot convert %T to numeric value", pdu.Value)
83
+ }
84
+
85
+ value := gosnmp.ToBigInt(pdu.Value).Int64()
86
+
87
+ switch format {
88
+ case "uint32":
89
+ if value < 0 {
90
+ return int64(uint32(value)), nil
91
+ }
92
+ }
93
+
94
+ return value, nil
95
+}
96
+
97
func convPduToString(pdu gosnmp.SnmpPDU) (string, error) {
98
switch pdu.Type {
99
case gosnmp.NoSuchObject, gosnmp.NoSuchInstance, gosnmp.Null:
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/value_processor.go
+23
-3
@@ -70,7 +70,10 @@ func (p *numericValueProcessor) processOpaqueDouble(sym ddprofiledefinition.Symb
70
}
71
72
func (p *numericValueProcessor) processInteger(sym ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU) (int64, error) {
73
- value := gosnmp.ToBigInt(pdu.Value).Int64()
73
+ value, err := convNumericPduToInt64f(pdu, sym.Format)
74
+ if err != nil {
75
+ return 0, err
76
+ }
77
78
if len(sym.Mapping) > 0 {
79
s := strconv.FormatInt(value, 10)
@@ -114,9 +117,9 @@ func (p *stringValueProcessor) processValue(sym ddprofiledefinition.SymbolConfig
117
s = v
118
}
119
117
- value, err := strconv.ParseInt(s, 10, 64)
120
+ value, err := parseStringMetricValue(sym, s)
121
if err != nil {
119
- return 0, fmt.Errorf("cannot convert '%s' to int64: %w", s, err)
122
+ return 0, err
123
}
124
125
if sym.ScaleFactor != 0 {
@@ -125,3 +128,20 @@ func (p *stringValueProcessor) processValue(sym ddprofiledefinition.SymbolConfig
128
129
return value, nil
130
}
131
+
132
+func parseStringMetricValue(sym ddprofiledefinition.SymbolConfig, s string) (int64, error) {
133
+ base := 10
134
+ if sym.Format == "hex" {
135
+ base = 16
136
+ }
137
+
138
+ value, err := strconv.ParseInt(s, base, 64)
139
+ if err != nil {
140
+ if base == 16 {
141
+ return 0, fmt.Errorf("cannot convert '%s' to int64 from hex: %w", s, err)
142
+ }
143
+ return 0, fmt.Errorf("cannot convert '%s' to int64: %w", s, err)
144
+ }
145
+
146
+ return value, nil
147
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/value_processor_test.go
new
+116
@@ -0,0 +1,116 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/gosnmp/gosnmp"
9
+ "github.com/stretchr/testify/require"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
12
+)
13
+
14
+func TestStringValueProcessor_ProcessValue_HexFormat(t *testing.T) {
15
+ processor := newValueProcessor()
16
+ pdu := gosnmp.SnmpPDU{
17
+ Name: "1.3.6.1.2.1.15.3.1.14.192.0.2.1",
18
+ Type: gosnmp.OctetString,
19
+ Value: []byte{0x04, 0x03},
20
+ }
21
+ zeroPDU := gosnmp.SnmpPDU{
22
+ Name: "1.3.6.1.2.1.15.3.1.14.192.0.2.2",
23
+ Type: gosnmp.OctetString,
24
+ Value: []byte{0x00, 0x00},
25
+ }
26
+
27
+ tests := map[string]struct {
28
+ symbol ddprofiledefinition.SymbolConfig
29
+ pdu gosnmp.SnmpPDU
30
+ expected int64
31
+ }{
32
+ "code": {
33
+ symbol: ddprofiledefinition.SymbolConfig{
34
+ OID: "1.3.6.1.2.1.15.3.1.14",
35
+ Name: "bgpPeerLastErrorCode",
36
+ Format: "hex",
37
+ ExtractValueCompiled: mustCompileRegex(`^([0-9a-f]{2})`),
38
+ },
39
+ pdu: pdu,
40
+ expected: 4,
41
+ },
42
+ "subcode": {
43
+ symbol: ddprofiledefinition.SymbolConfig{
44
+ OID: "1.3.6.1.2.1.15.3.1.14",
45
+ Name: "bgpPeerLastErrorSubcode",
46
+ Format: "hex",
47
+ ExtractValueCompiled: mustCompileRegex(`^[0-9a-f]{2}([0-9a-f]{2})`),
48
+ },
49
+ pdu: pdu,
50
+ expected: 3,
51
+ },
52
+ "zero code": {
53
+ symbol: ddprofiledefinition.SymbolConfig{
54
+ OID: "1.3.6.1.2.1.15.3.1.14",
55
+ Name: "bgpPeerLastErrorCode",
56
+ Format: "hex",
57
+ ExtractValueCompiled: mustCompileRegex(`^(00)`),
58
+ },
59
+ pdu: zeroPDU,
60
+ expected: 0,
61
+ },
62
+ }
63
+
64
+ for name, tc := range tests {
65
+ t.Run(name, func(t *testing.T) {
66
+ value, err := processor.processValue(tc.symbol, tc.pdu)
67
+ require.NoError(t, err)
68
+ require.Equal(t, tc.expected, value)
69
+ })
70
+ }
71
+}
72
+
73
+func TestNumericValueProcessor_ProcessValue_Uint32Format(t *testing.T) {
74
+ processor := newValueProcessor()
75
+
76
+ tests := map[string]struct {
77
+ symbol ddprofiledefinition.SymbolConfig
78
+ pdu gosnmp.SnmpPDU
79
+ expected int64
80
+ }{
81
+ "reinterpret signed int32 as uint32": {
82
+ symbol: ddprofiledefinition.SymbolConfig{
83
+ OID: "1.3.6.1.2.1.15.3.1.9",
84
+ Name: "bgpPeerRemoteAs",
85
+ Format: "uint32",
86
+ },
87
+ pdu: gosnmp.SnmpPDU{
88
+ Name: "1.3.6.1.2.1.15.3.1.9.169.254.1.1",
89
+ Type: gosnmp.Integer,
90
+ Value: -94967296,
91
+ },
92
+ expected: 4200000000,
93
+ },
94
+ "preserve positive value": {
95
+ symbol: ddprofiledefinition.SymbolConfig{
96
+ OID: "1.3.6.1.2.1.15.3.1.9",
97
+ Name: "bgpPeerRemoteAs",
98
+ Format: "uint32",
99
+ },
100
+ pdu: gosnmp.SnmpPDU{
101
+ Name: "1.3.6.1.2.1.15.3.1.9.192.0.2.1",
102
+ Type: gosnmp.Integer,
103
+ Value: 64512,
104
+ },
105
+ expected: 64512,
106
+ },
107
+ }
108
+
109
+ for name, tc := range tests {
110
+ t.Run(name, func(t *testing.T) {
111
+ value, err := processor.processValue(tc.symbol, tc.pdu)
112
+ require.NoError(t, err)
113
+ require.Equal(t, tc.expected, value)
114
+ })
115
+ }
116
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/load.go
+7
-1
@@ -142,6 +142,7 @@ func loadProfileWithExtendsMap(filename string, extendsPaths multipath.MultiPath
142
}
143
144
prof.extensionHierarchy = make([]*extensionInfo, 0, len(prof.Definition.Extends))
145
+ mergedBases := make([]*Profile, 0, len(prof.Definition.Extends))
146
147
for _, name := range prof.Definition.Extends {
148
if slices.Contains(stack, name) {
@@ -164,8 +165,13 @@ func loadProfileWithExtendsMap(filename string, extendsPaths multipath.MultiPath
165
extensions: mergedBase.extensionHierarchy,
166
}
167
prof.extensionHierarchy = append(prof.extensionHierarchy, extInfo)
168
+ mergedBases = append(mergedBases, mergedBase)
169
+ }
170
168
- prof.merge(mergedBase)
171
+ // Merge in reverse so later extends override earlier ones while the
172
+ // current profile still keeps the highest precedence.
173
+ for i := len(mergedBases) - 1; i >= 0; i-- {
174
+ prof.merge(mergedBases[i])
175
}
176
177
return &prof, nil
src/go/plugin/go.d/collector/snmp/ddsnmp/profile.go
+49
-12
@@ -12,6 +12,16 @@ import (
12
"github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13
)
14
15
+type scalarMetricKey struct {
16
+ name string
17
+ oid string
18
+}
19
+
20
+type columnMetricKey struct {
21
+ table string
22
+ symbolName string
23
+}
24
+
25
// FindProfiles returns profiles matching the given sysObjectID.
26
// Profiles are sorted by match specificity: most specific first.
27
func FindProfiles(sysObjID, sysDescr string, manualProfiles []string) []*Profile {
@@ -137,19 +147,22 @@ func (p *Profile) merge(base *Profile) {
147
p.mergeMetrics(base)
148
// Append other fields as before (these likely don't need deduplication)
149
p.Definition.MetricTags = append(p.Definition.MetricTags, base.Definition.MetricTags...)
140
- p.Definition.StaticTags = append(p.Definition.StaticTags, base.Definition.StaticTags...)
150
+ p.Definition.StaticTags = append(slices.Clone(base.Definition.StaticTags), p.Definition.StaticTags...)
151
}
152
153
func (p *Profile) mergeMetrics(base *Profile) {
144
- seen := make(map[string]bool)
154
+ seenScalars := make(map[scalarMetricKey]bool)
155
+ seenColumns := make(map[columnMetricKey]bool)
156
+ seenTableOIDs := make(map[string]string)
157
158
for _, m := range p.Definition.Metrics {
159
switch {
160
case m.IsScalar():
149
- seen[m.Symbol.Name+"|"+m.Symbol.OID] = true
161
+ seenScalars[scalarMetricKey{name: m.Symbol.Name, oid: m.Symbol.OID}] = true
162
case m.IsColumn():
163
+ seenTableOIDs[columnMetricTableIdentity(m.Table)] = m.Table.OID
164
for _, sym := range m.Symbols {
152
- seen[sym.Name] = true
165
+ seenColumns[columnMetricSymbolKey(m.Table, sym)] = true
166
}
167
}
168
}
@@ -157,19 +170,29 @@ func (p *Profile) mergeMetrics(base *Profile) {
170
for _, bm := range base.Definition.Metrics {
171
switch {
172
case bm.IsScalar():
160
- key := bm.Symbol.Name + "|" + bm.Symbol.OID
161
- if !seen[key] {
173
+ key := scalarMetricKey{name: bm.Symbol.Name, oid: bm.Symbol.OID}
174
+ if !seenScalars[key] {
175
p.Definition.Metrics = append(p.Definition.Metrics, bm)
163
- seen[key] = true
176
+ seenScalars[key] = true
177
}
178
case bm.IsColumn():
166
- bm.Symbols = slices.DeleteFunc(bm.Symbols, func(sym ddprofiledefinition.SymbolConfig) bool {
167
- v := seen[sym.Name]
168
- seen[sym.Name] = true
169
- return v
170
- })
179
+ tableID := columnMetricTableIdentity(bm.Table)
180
+ if tableOID, ok := seenTableOIDs[tableID]; ok && tableOID != bm.Table.OID {
181
+ continue
182
+ }
183
+
184
+ symbols := make([]ddprofiledefinition.SymbolConfig, 0, len(bm.Symbols))
185
+ for _, sym := range bm.Symbols {
186
+ key := columnMetricSymbolKey(bm.Table, sym)
187
+ if seenColumns[key] {
188
+ continue
189
+ }
190
+ symbols = append(symbols, sym)
191
+ }
192
+ bm.Symbols = symbols
193
if len(bm.Symbols) > 0 {
194
p.Definition.Metrics = append(p.Definition.Metrics, bm)
195
+ seenTableOIDs[tableID] = bm.Table.OID
196
}
197
}
198
}
@@ -187,6 +210,20 @@ func (p *Profile) mergeMetrics(base *Profile) {
210
}
211
}
212
213
+func columnMetricSymbolKey(table ddprofiledefinition.SymbolConfig, sym ddprofiledefinition.SymbolConfig) columnMetricKey {
214
+ return columnMetricKey{
215
+ table: columnMetricTableIdentity(table),
216
+ symbolName: sym.Name,
217
+ }
218
+}
219
+
220
+func columnMetricTableIdentity(table ddprofiledefinition.SymbolConfig) string {
221
+ if table.Name != "" {
222
+ return table.Name
223
+ }
224
+ return table.OID
225
+}
226
+
227
func (p *Profile) mergeMetadata(base *Profile) {
228
if p.Definition.Metadata == nil {
229
p.Definition.Metadata = make(ddprofiledefinition.MetadataConfig)
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_merge_test.go
new
+215
@@ -0,0 +1,215 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmp
4
+
5
+import (
6
+ "path/filepath"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/pkg/multipath"
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
14
+)
15
+
16
+func TestProfile_MultipleExtends_TableSymbolLaterOverrideEarlierByNameWithinTable(t *testing.T) {
17
+ tmp := t.TempDir()
18
+
19
+ writeTableBase(t, filepath.Join(tmp, "_base1.yaml"), "1.3.6.1.2.1.2.2", "ifTable", "1.3.6.1.2.1.2.2.1.10", "ifInOctets", "base1")
20
+ writeTableBase(t, filepath.Join(tmp, "_base2.yaml"), "1.3.6.1.2.1.2.2", "ifTable", "1.3.6.1.2.1.2.2.1.16", "ifInOctets", "base2")
21
+ writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
22
+ Extends: []string{"_base1.yaml", "_base2.yaml"},
23
+ })
24
+
25
+ prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
26
+ require.NoError(t, err)
27
+ require.Len(t, prof.Definition.Metrics, 1)
28
+ require.Len(t, prof.Definition.Metrics[0].Symbols, 1)
29
+
30
+ sym := prof.Definition.Metrics[0].Symbols[0]
31
+ assert.Equal(t, "ifInOctets", sym.Name)
32
+ assert.Equal(t, "1.3.6.1.2.1.2.2.1.16", sym.OID)
33
+ assert.Equal(t, "base2", sym.ChartMeta.Description)
34
+}
35
+
36
+func TestProfile_MultipleExtends_TableSymbolLaterOverrideEarlierByTableNameWhenOIDDiffers(t *testing.T) {
37
+ tmp := t.TempDir()
38
+
39
+ writeTableBase(t, filepath.Join(tmp, "_base1.yaml"), "1.3.6.1.2.1.2.2", "ifTable", "1.3.6.1.2.1.2.2.1.10", "ifInOctets", "base1")
40
+ writeTableBase(t, filepath.Join(tmp, "_base2.yaml"), "1.3.6.1.4.1.999.2", "ifTable", "1.3.6.1.4.1.999.2.1.10", "ifInOctets", "base2")
41
+ writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
42
+ Extends: []string{"_base1.yaml", "_base2.yaml"},
43
+ })
44
+
45
+ prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
46
+ require.NoError(t, err)
47
+ require.Len(t, prof.Definition.Metrics, 1)
48
+ require.Len(t, prof.Definition.Metrics[0].Symbols, 1)
49
+
50
+ assert.Equal(t, "1.3.6.1.4.1.999.2", prof.Definition.Metrics[0].Table.OID)
51
+ assert.Equal(t, "ifTable", prof.Definition.Metrics[0].Table.Name)
52
+ assert.Equal(t, "1.3.6.1.4.1.999.2.1.10", prof.Definition.Metrics[0].Symbols[0].OID)
53
+ assert.Equal(t, "base2", prof.Definition.Metrics[0].Symbols[0].ChartMeta.Description)
54
+}
55
+
56
+func TestProfile_MultipleExtends_TableOIDOverrideDropsEarlierTableSymbols(t *testing.T) {
57
+ tmp := t.TempDir()
58
+
59
+ writeYAML(t, filepath.Join(tmp, "_base1.yaml"), ddprofiledefinition.ProfileDefinition{
60
+ Metrics: []ddprofiledefinition.MetricsConfig{
61
+ {
62
+ Table: ddprofiledefinition.SymbolConfig{
63
+ OID: "1.3.6.1.2.1.2.2",
64
+ Name: "ifTable",
65
+ },
66
+ Symbols: []ddprofiledefinition.SymbolConfig{
67
+ {OID: "1.3.6.1.2.1.2.2.1.10", Name: "ifInOctets"},
68
+ {OID: "1.3.6.1.2.1.2.2.1.16", Name: "ifOutOctets"},
69
+ },
70
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
71
+ {Tag: "row", IndexTransform: []ddprofiledefinition.MetricIndexTransform{{Start: 0, End: 0}}},
72
+ },
73
+ },
74
+ },
75
+ })
76
+ writeTableBase(t, filepath.Join(tmp, "_base2.yaml"), "1.3.6.1.4.1.999.2", "ifTable", "1.3.6.1.4.1.999.2.1.10", "ifInOctets", "base2")
77
+ writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
78
+ Extends: []string{"_base1.yaml", "_base2.yaml"},
79
+ })
80
+
81
+ prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
82
+ require.NoError(t, err)
83
+ require.Len(t, prof.Definition.Metrics, 1)
84
+ require.Len(t, prof.Definition.Metrics[0].Symbols, 1)
85
+
86
+ assert.Equal(t, "1.3.6.1.4.1.999.2", prof.Definition.Metrics[0].Table.OID)
87
+ assert.Equal(t, "ifTable", prof.Definition.Metrics[0].Table.Name)
88
+ assert.Equal(t, "ifInOctets", prof.Definition.Metrics[0].Symbols[0].Name)
89
+ assert.Equal(t, "1.3.6.1.4.1.999.2.1.10", prof.Definition.Metrics[0].Symbols[0].OID)
90
+}
91
+
92
+func TestProfile_MultipleExtends_TableSymbolsPreserveSameOIDDifferentNames(t *testing.T) {
93
+ tmp := t.TempDir()
94
+
95
+ writeTableBase(t, filepath.Join(tmp, "_base1.yaml"), "1.3.6.1.4.1.999.1", "eventTable", "1.3.6.1.4.1.999.1.1.5", "eventCode", "base1")
96
+ writeTableBase(t, filepath.Join(tmp, "_base2.yaml"), "1.3.6.1.4.1.999.1", "eventTable", "1.3.6.1.4.1.999.1.1.5", "eventSubCode", "base2")
97
+ writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
98
+ Extends: []string{"_base1.yaml", "_base2.yaml"},
99
+ })
100
+
101
+ prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
102
+ require.NoError(t, err)
103
+ require.Len(t, prof.Definition.Metrics, 2)
104
+
105
+ got := make(map[string]string)
106
+ for _, metric := range prof.Definition.Metrics {
107
+ require.Len(t, metric.Symbols, 1)
108
+ got[metric.Symbols[0].Name] = metric.Symbols[0].OID
109
+ }
110
+
111
+ assert.Equal(t, map[string]string{
112
+ "eventCode": "1.3.6.1.4.1.999.1.1.5",
113
+ "eventSubCode": "1.3.6.1.4.1.999.1.1.5",
114
+ }, got)
115
+}
116
+
117
+func TestProfile_MergeMetrics_DoesNotMutateBaseColumnSymbols(t *testing.T) {
118
+ target := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
119
+ Metrics: []ddprofiledefinition.MetricsConfig{
120
+ tableMetricConfig("1.3.6.1.2.1.2.2", "ifTable", "1.3.6.1.2.1.2.2.1.10", "ifInOctets", "target"),
121
+ },
122
+ }}
123
+ base := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
124
+ Metrics: []ddprofiledefinition.MetricsConfig{
125
+ {
126
+ Table: ddprofiledefinition.SymbolConfig{
127
+ OID: "1.3.6.1.2.1.2.2",
128
+ Name: "ifTable",
129
+ },
130
+ Symbols: []ddprofiledefinition.SymbolConfig{
131
+ {OID: "1.3.6.1.2.1.2.2.1.10", Name: "ifInOctets"},
132
+ {OID: "1.3.6.1.2.1.2.2.1.16", Name: "ifOutOctets"},
133
+ },
134
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
135
+ {
136
+ Tag: "row",
137
+ IndexTransform: []ddprofiledefinition.MetricIndexTransform{
138
+ {Start: 0, End: 0},
139
+ },
140
+ },
141
+ },
142
+ },
143
+ },
144
+ }}
145
+
146
+ target.mergeMetrics(base)
147
+
148
+ require.Len(t, base.Definition.Metrics[0].Symbols, 2)
149
+ assert.Equal(t, "ifInOctets", base.Definition.Metrics[0].Symbols[0].Name)
150
+ assert.Equal(t, "ifOutOctets", base.Definition.Metrics[0].Symbols[1].Name)
151
+ require.Len(t, target.Definition.Metrics, 2)
152
+ require.Len(t, target.Definition.Metrics[1].Symbols, 1)
153
+ assert.Equal(t, "ifOutOctets", target.Definition.Metrics[1].Symbols[0].Name)
154
+}
155
+
156
+func TestProfile_MergeMetrics_PreservesRepeatedBaseColumnSymbols(t *testing.T) {
157
+ target := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{}}
158
+ base := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
159
+ Metrics: []ddprofiledefinition.MetricsConfig{
160
+ {
161
+ Table: ddprofiledefinition.SymbolConfig{
162
+ OID: "1.3.6.1.4.1.999.1",
163
+ Name: "eventTable",
164
+ },
165
+ Symbols: []ddprofiledefinition.SymbolConfig{
166
+ {OID: "1.3.6.1.4.1.999.1.1.5", Name: "eventCode"},
167
+ {OID: "1.3.6.1.4.1.999.1.1.6", Name: "eventCode"},
168
+ },
169
+ },
170
+ },
171
+ }}
172
+
173
+ target.mergeMetrics(base)
174
+
175
+ require.Len(t, target.Definition.Metrics, 1)
176
+ require.Len(t, target.Definition.Metrics[0].Symbols, 2)
177
+ assert.Equal(t, "1.3.6.1.4.1.999.1.1.5", target.Definition.Metrics[0].Symbols[0].OID)
178
+ assert.Equal(t, "1.3.6.1.4.1.999.1.1.6", target.Definition.Metrics[0].Symbols[1].OID)
179
+}
180
+
181
+func writeTableBase(t *testing.T, path, tableOID, tableName, symbolOID, symbolName, description string) {
182
+ t.Helper()
183
+
184
+ writeYAML(t, path, ddprofiledefinition.ProfileDefinition{
185
+ Metrics: []ddprofiledefinition.MetricsConfig{
186
+ tableMetricConfig(tableOID, tableName, symbolOID, symbolName, description),
187
+ },
188
+ })
189
+}
190
+
191
+func tableMetricConfig(tableOID, tableName, symbolOID, symbolName, description string) ddprofiledefinition.MetricsConfig {
192
+ return ddprofiledefinition.MetricsConfig{
193
+ Table: ddprofiledefinition.SymbolConfig{
194
+ OID: tableOID,
195
+ Name: tableName,
196
+ },
197
+ Symbols: []ddprofiledefinition.SymbolConfig{
198
+ {
199
+ OID: symbolOID,
200
+ Name: symbolName,
201
+ ChartMeta: ddprofiledefinition.ChartMeta{
202
+ Description: description,
203
+ },
204
+ },
205
+ },
206
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
207
+ {
208
+ Tag: "row",
209
+ IndexTransform: []ddprofiledefinition.MetricIndexTransform{
210
+ {Start: 0, End: 0},
211
+ },
212
+ },
213
+ },
214
+ }
215
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_test.go
+112
-2
@@ -1334,8 +1334,8 @@ func TestProfile_MultipleExtends(t *testing.T) {
1334
1335
// Main profile extending both
1336
main := filepath.Join(tmp, "device.yaml")
1337
- writeYAML(t, main, map[string]any{
1338
- "extends": []string{"_base1.yaml", "_base2.yaml"},
1337
+ writeYAML(t, main, ddprofiledefinition.ProfileDefinition{
1338
+ Extends: []string{"_base1.yaml", "_base2.yaml"},
1339
})
1340
1341
paths := multipath.New(tmp)
@@ -1356,6 +1356,116 @@ func TestProfile_MultipleExtends(t *testing.T) {
1356
assert.Len(t, allFiles, 2)
1357
}
1358
1359
+func TestProfile_MultipleExtends_LaterOverrideEarlier(t *testing.T) {
1360
+ tmp := t.TempDir()
1361
+
1362
+ writeBase := func(path, suffix string) {
1363
+ writeYAML(t, path, ddprofiledefinition.ProfileDefinition{
1364
+ Metadata: ddprofiledefinition.MetadataConfig{
1365
+ "device": {
1366
+ Fields: map[string]ddprofiledefinition.MetadataField{
1367
+ "model": {Value: suffix},
1368
+ },
1369
+ },
1370
+ },
1371
+ StaticTags: []ddprofiledefinition.StaticMetricTagConfig{
1372
+ {Tag: "source", Value: suffix},
1373
+ },
1374
+ Metrics: []ddprofiledefinition.MetricsConfig{
1375
+ {
1376
+ Symbol: ddprofiledefinition.SymbolConfig{
1377
+ OID: "1.3.6.1.2.1.1.5.0",
1378
+ Name: "sysName",
1379
+ ChartMeta: ddprofiledefinition.ChartMeta{
1380
+ Description: suffix,
1381
+ },
1382
+ },
1383
+ },
1384
+ },
1385
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
1386
+ {
1387
+ Name: "ifTraffic",
1388
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
1389
+ {Metric: "sysName", Table: ""},
1390
+ },
1391
+ ChartMeta: ddprofiledefinition.ChartMeta{
1392
+ Description: suffix,
1393
+ },
1394
+ },
1395
+ },
1396
+ })
1397
+ }
1398
+
1399
+ base1 := filepath.Join(tmp, "_base1.yaml")
1400
+ base2 := filepath.Join(tmp, "_base2.yaml")
1401
+ writeBase(base1, "base1")
1402
+ writeBase(base2, "base2")
1403
+
1404
+ main := filepath.Join(tmp, "device.yaml")
1405
+ writeYAML(t, main, map[string]any{
1406
+ "extends": []string{"_base1.yaml", "_base2.yaml"},
1407
+ })
1408
+
1409
+ prof, err := loadProfile(main, multipath.New(tmp))
1410
+ require.NoError(t, err)
1411
+
1412
+ require.Len(t, prof.Definition.Metrics, 1)
1413
+ assert.Equal(t, "base2", prof.Definition.Metrics[0].Symbol.ChartMeta.Description)
1414
+
1415
+ require.Len(t, prof.Definition.VirtualMetrics, 1)
1416
+ assert.Equal(t, "base2", prof.Definition.VirtualMetrics[0].ChartMeta.Description)
1417
+
1418
+ assert.Equal(t, "base2", prof.Definition.Metadata["device"].Fields["model"].Value)
1419
+
1420
+ require.Len(t, prof.Definition.StaticTags, 2)
1421
+ assert.Equal(t, "base1", prof.Definition.StaticTags[0].Value)
1422
+ assert.Equal(t, "base2", prof.Definition.StaticTags[1].Value)
1423
+
1424
+ mergedStaticTags := make(map[string]string, len(prof.Definition.StaticTags))
1425
+ for _, tag := range prof.Definition.StaticTags {
1426
+ if tag.Tag != "" && tag.Value != "" {
1427
+ mergedStaticTags[tag.Tag] = tag.Value
1428
+ }
1429
+ }
1430
+ assert.Equal(t, "base2", mergedStaticTags["source"])
1431
+}
1432
+
1433
+func TestProfile_MultipleExtends_PreservesScalarSameNameFallbackOIDs(t *testing.T) {
1434
+ tmp := t.TempDir()
1435
+
1436
+ writeYAML(t, filepath.Join(tmp, "_base1.yaml"), ddprofiledefinition.ProfileDefinition{
1437
+ Metrics: []ddprofiledefinition.MetricsConfig{
1438
+ {
1439
+ Symbol: ddprofiledefinition.SymbolConfig{
1440
+ OID: "1.3.6.1.2.1.25.1.1.0",
1441
+ Name: "systemUptime",
1442
+ },
1443
+ },
1444
+ },
1445
+ })
1446
+ writeYAML(t, filepath.Join(tmp, "_base2.yaml"), ddprofiledefinition.ProfileDefinition{
1447
+ Metrics: []ddprofiledefinition.MetricsConfig{
1448
+ {
1449
+ Symbol: ddprofiledefinition.SymbolConfig{
1450
+ OID: "1.3.6.1.2.1.1.3.0",
1451
+ Name: "systemUptime",
1452
+ },
1453
+ },
1454
+ },
1455
+ })
1456
+ writeYAML(t, filepath.Join(tmp, "device.yaml"), map[string]any{
1457
+ "extends": []string{"_base1.yaml", "_base2.yaml"},
1458
+ })
1459
+
1460
+ prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
1461
+ require.NoError(t, err)
1462
+ require.Len(t, prof.Definition.Metrics, 2)
1463
+ assert.Equal(t, "systemUptime", prof.Definition.Metrics[0].Symbol.Name)
1464
+ assert.Equal(t, "1.3.6.1.2.1.1.3.0", prof.Definition.Metrics[0].Symbol.OID)
1465
+ assert.Equal(t, "systemUptime", prof.Definition.Metrics[1].Symbol.Name)
1466
+ assert.Equal(t, "1.3.6.1.2.1.25.1.1.0", prof.Definition.Metrics[1].Symbol.OID)
1467
+}
1468
+
1469
func TestProfile_ComplexHierarchy(t *testing.T) {
1470
tmp := t.TempDir()
1471
src/go/plugin/go.d/collector/snmp/profile-format.md
+178
-8
@@ -218,6 +218,11 @@ The final profile is the **merged result** of all inherited profiles plus the co
218
2. **Metrics are merged** — all metrics from all referenced profiles are included.
219
3. **Later overrides earlier** — if the same field is defined multiple times, the last one wins.
220
221
+Metric override identity depends on metric type:
222
+
223
+- **Scalar metrics** use `symbol.name + symbol.OID`. This preserves same-name scalar fallback definitions that try alternative OIDs.
224
+- **Table metrics** use logical table identity (`table.name` when set, otherwise `table.OID`) + `symbol.name`. If two inherited profiles define the same table metric name, the later profile wins even when the table or symbol OID differs. If the same logical table name is inherited with a different table OID, the later table definition replaces the earlier table definition; symbols from the earlier table OID are not merged into the later table. Different metric names can still read from the same column OID when separate transformations are needed.
225
+
226
**Common base profiles**
227
228
| Profile | Provides | Typical Use |
@@ -366,9 +371,9 @@ virtual_metrics:
371
- { metric: _ifHCOutOctets, table: ifXTable, as: out }
372
```
373
369
-#### Multiple symbol fallbacks
374
+#### Scalar symbol fallbacks
375
371
-You can express “try this OID, otherwise try that OID” by declaring **multiple metrics with the same** `symbol.name`, each pointing to a different OID. At runtime the collector **GETs** all declared scalar OIDs, marks missing ones, and **emits** the metric from whichever OID returns data. Missing OIDs are skipped cleanly.
376
+You can express “try this OID, otherwise try that OID” by declaring **multiple scalar metrics with the same** `symbol.name`, each pointing to a different OID. At runtime the collector **GETs** all declared scalar OIDs, marks missing ones, and **emits** the metric from whichever OID returns data. Missing OIDs are skipped cleanly.
377
378
```yaml
379
metrics:
@@ -947,6 +952,7 @@ Cross-table tags let you **use data from another SNMP table** as a tag source.
952
- Reads tag values from the specified `table:` instead of the current one.
953
- Matches rows between tables by their **index**.
954
- When index structures differ, an optional `index_transform` can modify the current table’s index to align it with the target.
955
+- When the target table is keyed differently, an optional `lookup_symbol` can match a transformed index value against a column in the target table and then read tags from the matched row.
956
957
#### Same Index
958
@@ -1064,8 +1070,88 @@ metrics:
1070
- `start` and `end` positions are **zero-based** (0 = first index element).
1071
- Each range defines which parts of the index to keep.
1072
- You can list multiple ranges to combine non-contiguous parts.
1073
+- `drop_right` can be used instead of `end` when you need to keep a variable-length prefix of the index and trim a fixed number of trailing elements.
1074
- The goal is to make the current table’s index **match** the target table’s index so tags align correctly.
1075
1076
+Example with `drop_right`:
1077
+
1078
+```yaml
1079
+metric_tags:
1080
+ - tag: peer_index
1081
+ table: peerTable
1082
+ symbol:
1083
+ OID: 1.2.3.4.5
1084
+ name: peerRemoteAs
1085
+ index_transform:
1086
+ - start: 0
1087
+ drop_right: 2
1088
+```
1089
+
1090
+If the current row index is `1.192.0.2.1.1.128`, the transform keeps `1.192.0.2.1` and drops the trailing AFI / SAFI pair.
1091
+
1092
+#### With Value Lookup (`lookup_symbol`)
1093
+
1094
+Some vendor MIBs split related data across tables that do **not** share the same row index.
1095
+
1096
+For example:
1097
+
1098
+- a prefix-counter table may be indexed by `peerIndex.afi.safi`
1099
+- the peer table may be indexed by `routingInstance.localAddr.remoteAddr`
1100
+- but the peer table still contains a `peerIndex` column
1101
+
1102
+In that case, `index_transform` alone is not enough:
1103
+
1104
+- it can extract the current row's `peerIndex`
1105
+- but it cannot guess which peer-table row owns that `peerIndex`
1106
+
1107
+Use `lookup_symbol` to tell the collector:
1108
+
1109
+1. Extract a lookup value from the current row index
1110
+2. Find the row in the target table whose `lookup_symbol` column matches that value
1111
+3. Read the requested `symbol` from that matched row
1112
+
1113
+```yaml
1114
+metrics:
1115
+ - MIB: BGP4-V2-MIB-JUNIPER
1116
+ table:
1117
+ OID: 1.3.6.1.4.1.2636.5.1.1.2.6.2
1118
+ name: jnxBgpM2PrefixCountersTable
1119
+ symbols:
1120
+ - OID: 1.3.6.1.4.1.2636.5.1.1.2.6.2.1.8
1121
+ name: bgpPeerPrefixesAccepted
1122
+ metric_tags:
1123
+ - tag: neighbor
1124
+ table: jnxBgpM2PeerTable
1125
+ symbol:
1126
+ OID: 1.3.6.1.4.1.2636.5.1.1.2.1.1.1.11
1127
+ name: jnxBgpM2PeerRemoteAddr
1128
+ lookup_symbol:
1129
+ OID: 1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14
1130
+ name: jnxBgpM2PeerIndex
1131
+ index_transform:
1132
+ - start: 0
1133
+ end: 0
1134
+```
1135
+
1136
+**What this does**:
1137
+
1138
+- Collects accepted-prefix counters from the Juniper prefix table
1139
+- Takes the first index element from the current row (`peerIndex`)
1140
+- Scans `jnxBgpM2PeerTable.jnxBgpM2PeerIndex` for a matching value
1141
+- Reads `jnxBgpM2PeerRemoteAddr` from the matched peer-table row
1142
+- Produces metrics like:
1143
+ ```text
1144
+ bgpPeerPrefixesAccepted{neighbor="192.0.2.1"} = 1234
1145
+ ```
1146
+
1147
+**Important behavior**:
1148
+
1149
+- `lookup_symbol` is used only for **cross-table tags**
1150
+- it works together with `index_transform`, not instead of it
1151
+- if no row matches, the tag lookup fails for that row
1152
+- if one row matches, the collector reads the requested tag from that row
1153
+- if multiple rows match, the lookup succeeds only when all matched rows resolve to the same final tag value; conflicting values fail the lookup
1154
+
1155
### Index-Based
1156
1157
Index-based tags extract values directly from the **OID index** of the SNMP table rather than from a column.
@@ -1078,6 +1164,7 @@ This is useful when a table encodes identifiers (like method, code, or port numb
1164
- For each `index:` rule, assigns a tag using the specified position in the index.
1165
- Converts numeric index components to strings automatically.
1166
- Attaches all resulting tags to the metric collected from that row.
1167
+- Can also derive tags from the full row index using `index_transform` plus `symbol.format`, `symbol.extract_value`, `symbol.match_pattern`, or `mapping`, even when there is no column OID for that tag.
1168
1169
```yaml
1170
metrics:
@@ -1119,6 +1206,25 @@ metrics:
1206
sipCommonStatusCodeIns{applIndex="1", sipCommonStatusCodeMethod="6", sipCommonStatusCodeValue="200"} = 42
1207
```
1208
1209
+Derived tag example from a transformed index:
1210
+
1211
+```yaml
1212
+metric_tags:
1213
+ - tag: neighbor
1214
+ symbol:
1215
+ name: peerRemoteAddrIndex
1216
+ format: ip_address
1217
+ index_transform:
1218
+ - start: 1
1219
+ drop_right: 2
1220
+```
1221
+
1222
+If the current row index is `1.192.0.2.1.1.128`, the collector:
1223
+
1224
+- keeps the peer-address part only: `192.0.2.1`
1225
+- formats it as an IP address
1226
+- emits `neighbor="192.0.2.1"`
1227
+
1228
## Tag Transformation
1229
1230
Tag transformations let you **modify or extract parts of SNMP values** to produce clear, human-readable tags.
@@ -1364,6 +1470,7 @@ These transformations are typically used to:
1470
| **Where** | Value transformations are used inside `metrics[*].symbol` or `metrics[*].symbols[]`. |
1471
| **Order of application** | 1️⃣ `extract_value` (if present) → 2️⃣ `mapping` → 3️⃣ `scale_factor`. |
1472
| **Scale factor position** | `scale_factor` is always applied **last**, after all other transformations. |
1473
+| **String base parsing** | String-like values are parsed as base-10 by default. If `format: hex` is set, extracted values are parsed as base-16. |
1474
| **Data type handling** | Transformations preserve numeric type (integer/float) unless the mapping converts it to a multi-value metric. |
1475
| **Error handling** | If a transformation fails (e.g., regex doesn’t match), the collector keeps the original value. |
1476
| **Applicability** | Transformations affect metric values only — not metadata or tags. |
@@ -1384,6 +1491,12 @@ These transformations are typically used to:
1491
extract_value: '(\d+)' # First capture group is used
1492
```
1493
1494
+- `format: hex`
1495
+ ```yaml
1496
+ format: hex
1497
+ extract_value: '^([0-9a-f]{2})' # First byte of an OCTET STRING
1498
+ ```
1499
+
1500
- `scale_factor`
1501
```yaml
1502
scale_factor: 8 # Octets → bits
@@ -1459,6 +1572,7 @@ metrics:
1572
- Extracts only the numeric part `"23"` and uses it as the metric value.
1573
- If the value doesn’t match, the original string is retained.
1574
- Ideal for string metrics that embed numbers, units, or labels.
1575
+- If `format: hex` is also set, the extracted value is interpreted as hexadecimal before being stored as a metric.
1576
1577
### Scale Factor
1578
@@ -1538,7 +1652,9 @@ virtual_metrics:
1652
- { metric: <fallbackMetricB>, table: <tableName>, as: <dimensionName> }
1653
1654
per_row: <true|false>
1541
- group_by: <label | [labels]>
1655
+ group_by: [<labels>]
1656
+ emit_tags:
1657
+ - { tag: <outputTag>, from: <sourceTag> }
1658
chart_meta:
1659
description: ...
1660
family: ...
@@ -1560,11 +1676,15 @@ The collector evaluates alternatives **in order** and uses the **first** set tha
1676
| | `sources` | array\<Source\> | no* | — | totals, per_row, grouped | Direct source set. Ignored if `alternatives` exist (alternatives take precedence). |
1677
| | `alternatives` | array\<Alternative\> | no* | — | totals, per_row, grouped | Ordered fallback sets. The first alternative whose sources produce data is used. |
1678
| | `per_row` | bool | no | false | per-row/grouped | When `true`, emits one output per input row; sources become dimensions; row tags attach. |
1563
-| | `group_by` | string / array | no | — | per-row/grouped | Label(s) used as row-key hints (in order). Missing/empty hints fall back to a full-tag stable key. With `per_row:false`, this acts like PromQL’s `sum by (...)`. |
1679
+| | `group_by` | array\<string\> | no | — | per-row/grouped | Label(s) used as row-key hints (in order). With `per_row:true`, missing/empty hints fall back to a stable key built from all non-underscore tags. With `per_row:false`, this acts like PromQL’s `sum by (...)`. |
1680
+| | `emit_tags` | array\<EmitTag\> | no | — | per-row/grouped | Renames or selects which source tags are emitted on the resulting virtual metric. Useful when grouping by private tags such as `_neighbor` but exporting standard tags such as `neighbor`. |
1681
| | `chart_meta` | object | no | — | all | Presentation metadata (`description`, `family`, `unit`, `type`). |
1682
| **Source** | `metric` | string | yes | — | — | Name of an existing metric (scalar or table column metric). |
1566
-| | `table` | string | yes | — | — | Table name for the originating metric. Must match the metric’s table when used in per-row/grouped. |
1567
-| | `as` | string | yes | — | — | Dimension name within the composite (e.g., `in`, `out`). |
1683
+| | `table` | string | no* | — | — | Table name for the originating metric. Required for table-derived grouped/per-row virtual metrics. Scalar sources may omit it. |
1684
+| | `as` | string | no | — | — | Optional dimension name within a composite (e.g., `in`, `out`). Single-source virtual metrics do not need it. |
1685
+| | `dim` | string | no | — | — | Selects one dimension from a MultiValue source metric (for example `start` or `established`) before aggregation. Useful when composing virtual metrics from mapped status charts. |
1686
+| **EmitTag** | `tag` | string | yes | — | — | Output tag name to emit on the virtual metric. |
1687
+| | `from` | string | yes | — | — | Existing source-tag name to copy from the grouped source rows. |
1688
| **Alternative** | `sources` | array\<Source\> | yes | — | — | All sources in an alternative are evaluated together. If none produce data, the collector tries the next alternative. Per-row/group rules apply within the winning alternative. |
1689
1690
> At least one of `sources` or `alternatives` **must be defined**.
@@ -1576,11 +1696,13 @@ The collector evaluates alternatives **in order** and uses the **first** set tha
1696
| **Precedence** | If both `sources` and `alternatives` exist, `alternatives` take precedence. |
1697
| **Same-table requirement** | When `per_row` or `group_by` is used, all sources must originate from the same table. For alternatives, this rule applies within each alternative set. |
1698
| **per_row: true** | One output per input row; multiple sources become chart dimensions (`as`); row tags attach automatically. |
1579
-| **group_by (with per_row:true)** | Acts as row-key hints (in order). Missing or empty hints fall back to a full-tag composite key. |
1699
+| **group_by (with per_row:true)** | Acts as row-key hints (in order). Missing or empty hints fall back to a stable key built from all non-underscore tags. |
1700
| **group_by (with per_row:false)** | Aggregates rows by the listed labels, similar to PromQL’s `sum by (...)`. |
1701
+| **emit_tags** | If omitted, `per_row:true` emits the winning row tags as-is. Grouped non-`per_row` metrics emit the `group_by` labels by default. When set, only the listed tags are emitted, using the `from` source-tag names. |
1702
| **Alternative evaluation** | Alternatives are checked in order. The first whose sources produce data becomes the “winner”; others are ignored. |
1703
| **Parent metadata** | The virtual metric emits charts using its own `name` and `chart_meta`, even when data comes from an alternative. |
1704
| **Dimensions** | Each `as` value defines a dimension in the resulting chart (e.g., `in`, `out`, `total`). |
1705
+| **Selected source dimension** | When `dim` is set on a source, the collector reads only that MultiValue dimension from the source metric and ignores the rest. |
1706
| **Totals vs per-row** | Omitting both `per_row` and `group_by` produces a single total chart across all rows (device-wide view). |
1707
1708
### Examples
@@ -1606,7 +1728,7 @@ virtual_metrics:
1728
- Creates **one output per input row** in `ifXTable`.
1729
- Each chart represents one interface with two dimensions: `in` and `out`.
1730
- `group_by: ["interface"]` provides key hints to keep per-interface charts stable.
1609
-- If a hint is missing or empty, a full-tag composite key is used instead.
1731
+- If a hint is missing or empty, a stable key built from all non-underscore tags is used instead.
1732
- **Constraint**: `per_row` or `group_by` requires all sources to come from the same table.
1733
1734
#### Total aggregation (sum across all interfaces)
@@ -1645,6 +1767,54 @@ virtual_metrics:
1767
unit: "bit/s"
1768
```
1769
1770
+#### Per-row availability from mapped status charts
1771
+
1772
+```yaml
1773
+virtual_metrics:
1774
+ - name: bgpPeerAvailability
1775
+ per_row: true
1776
+ sources:
1777
+ - { metric: bgpPeerAdminStatus, table: bgpPeerTable, as: admin_enabled, dim: start }
1778
+ - { metric: bgpPeerState, table: bgpPeerTable, as: established, dim: established }
1779
+ chart_meta:
1780
+ description: BGP peer administrative and established availability
1781
+ family: 'Network/Routing/BGP/Peer/Availability'
1782
+ unit: "{status}"
1783
+```
1784
+
1785
+**What this does**:
1786
+
1787
+- Reuses mapped one-hot status charts instead of adding duplicate raw-code metrics.
1788
+- Reads only the `start` dimension from `bgpPeerAdminStatus`.
1789
+- Reads only the `established` dimension from `bgpPeerState`.
1790
+- Emits one per-peer chart row with stable dimensions `admin_enabled` and `established`.
1791
+
1792
+#### Per-row grouping by private tags, but emitting normalized tags
1793
+
1794
+```yaml
1795
+virtual_metrics:
1796
+ - name: bgpPeerAvailability
1797
+ per_row: true
1798
+ group_by: ["_neighbor", "_address_family", "_subsequent_address_family"]
1799
+ emit_tags:
1800
+ - { tag: neighbor, from: _neighbor }
1801
+ - { tag: address_family, from: _address_family }
1802
+ - { tag: subsequent_address_family, from: _subsequent_address_family }
1803
+ sources:
1804
+ - { metric: hwBgpPeerAdminStatus, table: hwBgpPeerRouteTable, as: admin_enabled, dim: start }
1805
+ - { metric: hwBgpPeerState, table: hwBgpPeerRouteTable, as: established, dim: established }
1806
+ chart_meta:
1807
+ description: BGP peer availability
1808
+ family: 'Network/Routing/BGP/Peer/Availability'
1809
+ unit: "{status}"
1810
+```
1811
+
1812
+**What this does**:
1813
+
1814
+- Groups rows using private helper tags that are not meant to appear in the final chart labels.
1815
+- Emits standard operator-facing tags on the virtual metric (`neighbor`, `address_family`, `subsequent_address_family`).
1816
+- Avoids collapsing multiple AFI/SAFI rows for the same peer into one output row.
1817
+
1818
**What this does**:
1819
1820
- Performs **PromQL-like “sum by (ifType)” aggregation**.