@cryptotaxi247 / netdata-1 / commits / c08124dea

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**.