feat(go.d/ddsnmp): add metric aggregation support for SNMP profiles (#20786)
Ilya Mashchenko committed
Aug 10, 2025 at 18:52 UTC
da58ac2e9764a16f1b4ed1ff64d575903fecfd53
14 files changed
+728
-73
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metrics.go
+7
-5
@@ -81,17 +81,19 @@ type SymbolConfig struct {
81
// Deprecated types: `counter` (use `rate` instead), percent (use `scale_factor` instead)
82
MetricType ProfileMetricType `yaml:"metric_type,omitempty" json:"metric_type,omitempty"`
83
84
- ChartMeta struct {
85
- Description string `yaml:"description,omitempty" json:"description,omitempty"`
86
- Family string `yaml:"family,omitempty" json:"family,omitempty"`
87
- Unit string `yaml:"unit,omitempty" json:"unit,omitempty"`
88
- } `yaml:"chart_meta,omitempty" json:"chart_meta,omitempty"`
84
+ ChartMeta ChartMeta `yaml:"chart_meta,omitempty" json:"chart_meta,omitempty"`
85
86
Mapping map[string]string `yaml:"mapping,omitempty" json:"mapping,omitempty"`
87
Transform string `yaml:"transform,omitempty" json:"transform,omitempty"`
88
TransformCompiled *template.Template `yaml:"-" json:"-"`
89
}
90
91
+type ChartMeta struct {
92
+ Description string `yaml:"description,omitempty" json:"description,omitempty"`
93
+ Family string `yaml:"family,omitempty" json:"family,omitempty"`
94
+ Unit string `yaml:"unit,omitempty" json:"unit,omitempty"`
95
+}
96
+
97
// Clone creates a duplicate of this SymbolConfig
98
func (s SymbolConfig) Clone() SymbolConfig {
99
ss := s
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/profile_definition.go
+11
-8
@@ -29,6 +29,8 @@ type ProfileDefinition struct {
29
StaticTags []string `yaml:"static_tags,omitempty" json:"static_tags,omitempty"`
30
Metrics []MetricsConfig `yaml:"metrics,omitempty" json:"metrics,omitempty"`
31
32
+ VirtualMetrics []VirtualMetricConfig `yaml:"virtual_metrics,omitempty" json:"virtual_metrics,omitempty"`
33
+
34
// DEPRECATED: Use metadata directly
35
Device DeviceMeta `yaml:"device,omitempty" json:"device,omitempty" jsonschema:"device,omitempty"`
36
@@ -63,14 +65,15 @@ func (p *ProfileDefinition) Clone() *ProfileDefinition {
65
return nil
66
}
67
return &ProfileDefinition{
66
- Name: p.Name,
67
- Description: p.Description,
68
- SysObjectIDs: slices.Clone(p.SysObjectIDs),
69
- Extends: slices.Clone(p.Extends),
70
- Metadata: CloneMap(p.Metadata),
71
- MetricTags: CloneSlice(p.MetricTags),
72
- StaticTags: slices.Clone(p.StaticTags),
73
- Metrics: CloneSlice(p.Metrics),
68
+ Name: p.Name,
69
+ Description: p.Description,
70
+ SysObjectIDs: slices.Clone(p.SysObjectIDs),
71
+ Extends: slices.Clone(p.Extends),
72
+ Metadata: CloneMap(p.Metadata),
73
+ MetricTags: CloneSlice(p.MetricTags),
74
+ StaticTags: slices.Clone(p.StaticTags),
75
+ Metrics: CloneSlice(p.Metrics),
76
+ VirtualMetrics: CloneSlice(p.VirtualMetrics),
77
Device: DeviceMeta{
78
Vendor: p.Device.Vendor,
79
},
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/virtual_metrics.go
new
+24
@@ -0,0 +1,24 @@
1
+package ddprofiledefinition
2
+
3
+import (
4
+ "slices"
5
+)
6
+
7
+type VirtualMetricConfig struct {
8
+ Name string `yaml:"name"`
9
+ Sources []VirtualMetricSourceConfig `yaml:"sources"`
10
+ ChartMeta ChartMeta `yaml:"chart_meta"`
11
+}
12
+
13
+func (vm VirtualMetricConfig) Clone() VirtualMetricConfig {
14
+ return VirtualMetricConfig{
15
+ Name: vm.Name,
16
+ Sources: slices.Clone(vm.Sources),
17
+ ChartMeta: vm.ChartMeta,
18
+ }
19
+}
20
+
21
+type VirtualMetricSourceConfig struct {
22
+ Metric string `yaml:"metric"`
23
+ Table string `yaml:"table"` // Required for now
24
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+29
-40
@@ -37,6 +37,7 @@ func New(snmpClient gosnmp.Handler, profiles []*ddsnmp.Profile, log *logger.Logg
37
coll.deviceMetadataCollector = newDeviceMetadataCollector(snmpClient, coll.missingOIDs, coll.log)
38
coll.scalarCollector = newScalarCollector(snmpClient, coll.missingOIDs, coll.log)
39
coll.tableCollector = newTableCollector(snmpClient, coll.missingOIDs, coll.tableCache, coll.log)
40
+ coll.vmetricsCollector = newVirtualMetricsCollector(coll.log)
41
42
return coll
43
}
@@ -53,6 +54,7 @@ type (
54
deviceMetadataCollector *deviceMetadataCollector
55
scalarCollector *scalarCollector
56
tableCollector *tableCollector
57
+ vmetricsCollector *vmetricsCollector
58
59
DoTableMetrics bool
60
}
@@ -87,10 +89,21 @@ func (c *Collector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
89
}
90
91
for _, prof := range c.profiles {
90
- if ms, err := c.collectProfile(prof); err != nil {
92
+ pm, err := c.collectProfile(prof)
93
+ if err != nil {
94
errs = append(errs, err)
92
- } else if ms != nil {
93
- metrics = append(metrics, ms)
95
+ continue
96
+ }
97
+
98
+ c.updateProfileMetrics(pm)
99
+
100
+ metrics = append(metrics, pm)
101
+
102
+ if vmetrics := c.vmetricsCollector.Collect(prof.profile.Definition, pm.Metrics); len(vmetrics) > 0 {
103
+ for i := range vmetrics {
104
+ vmetrics[i].Profile = pm
105
+ }
106
+ pm.Metrics = append(pm.Metrics, vmetrics...)
107
}
108
}
109
@@ -101,8 +114,6 @@ func (c *Collector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
114
c.log.Debugf("collecting metrics: %v", errors.Join(errs...))
115
}
116
104
- c.updateMetrics(metrics)
105
-
117
return metrics, nil
118
}
119
@@ -152,22 +163,20 @@ func (c *Collector) collectProfile(ps *profileState) (*ddsnmp.ProfileMetrics, er
163
return pm, nil
164
}
165
155
-func (c *Collector) updateMetrics(pms []*ddsnmp.ProfileMetrics) {
156
- for _, pm := range pms {
157
- for i := range pm.Metrics {
158
- m := &pm.Metrics[i]
159
- m.Description = metricMetaReplacer.Replace(m.Description)
160
- m.Family = metricMetaReplacer.Replace(m.Family)
161
- m.Unit = metricMetaReplacer.Replace(m.Unit)
162
- for k, v := range m.Tags {
163
- // Remove tags prefixed with "rm:", which are intended for temporary use during transforms
164
- // and should not appear in the final exported metric.
165
- if strings.HasPrefix(k, "rm:") {
166
- delete(m.Tags, k)
167
- continue
168
- }
169
- m.Tags[k] = metricMetaReplacer.Replace(v)
166
+func (c *Collector) updateProfileMetrics(pm *ddsnmp.ProfileMetrics) {
167
+ for i := range pm.Metrics {
168
+ m := &pm.Metrics[i]
169
+ m.Description = metricMetaReplacer.Replace(m.Description)
170
+ m.Family = metricMetaReplacer.Replace(m.Family)
171
+ m.Unit = metricMetaReplacer.Replace(m.Unit)
172
+ for k, v := range m.Tags {
173
+ // Remove tags prefixed with "rm:", which are intended for temporary use during transforms
174
+ // and should not appear in the final exported metric.
175
+ if strings.HasPrefix(k, "rm:") {
176
+ delete(m.Tags, k)
177
+ continue
178
}
179
+ m.Tags[k] = metricMetaReplacer.Replace(v)
180
}
181
}
182
}
@@ -193,26 +202,6 @@ func (c *Collector) snmpGet(oids []string) (map[string]gosnmp.SnmpPDU, error) {
202
return pdus, nil
203
}
204
196
-func processMetricFamily(family, devType, vendor string) string {
197
- prefix := strings.TrimPrefix(devType+"/"+vendor, "/")
198
- if prefix == "" {
199
- return family
200
- }
201
- if family == "" {
202
- return prefix
203
- }
204
-
205
- parts := strings.Split(family, "/")
206
- parts = slices.DeleteFunc(parts, func(s string) bool {
207
- return strings.EqualFold(s, devType) ||
208
- strings.EqualFold(s, devType+"s") ||
209
- strings.EqualFold(s, devType+"es") ||
210
- strings.EqualFold(s, vendor)
211
- })
212
-
213
- return strings.TrimSuffix(prefix+"/"+strings.Join(parts, "/"), "/")
214
-}
215
-
205
var metricMetaReplacer = strings.NewReplacer(
206
"'", "",
207
"\n", " ",
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go
+8
-4
@@ -152,6 +152,8 @@ type cacheProcessingContext struct {
152
// Only contains metric columns (not tag columns, which are cached)
153
// Key: full OID (trimmed), Value: current PDU value
154
pdus map[string]gosnmp.SnmpPDU
155
+
156
+ tableName string
157
}
158
159
// walkTablesAsNeeded walks only tables that aren't fully cached
@@ -340,6 +342,7 @@ func (tc *tableCollector) tryCollectFromCache(cfg ddprofiledefinition.MetricsCon
342
cachedOIDs: cachedOIDs,
343
cachedTags: cachedTags,
344
columnOIDs: columnOIDs,
345
+ tableName: cfg.Table.Name,
346
}
347
348
metrics, err := tc.collectWithCache(ctx)
@@ -380,9 +383,9 @@ func (tc *tableCollector) organizePDUsByRow(ctx *tableProcessingContext) (rows m
383
for oid := range ctx.columnOIDs {
384
allColumnOIDs = append(allColumnOIDs, oid)
385
}
383
- for _, orderedTag := range ctx.orderedTags {
384
- if orderedTag.tagType == tagTypeSameTable && orderedTag.config.Symbol.OID != "" {
385
- oid := trimOID(orderedTag.config.Symbol.OID)
386
+ for _, tag := range ctx.orderedTags {
387
+ if tag.tagType == tagTypeSameTable && tag.config.Symbol.OID != "" {
388
+ oid := trimOID(tag.config.Symbol.OID)
389
allColumnOIDs = append(allColumnOIDs, oid)
390
}
391
}
@@ -427,6 +430,7 @@ func (tc *tableCollector) processRows(ctx *tableProcessingContext) ([]ddsnmp.Met
430
pdus: rowPDUs,
431
tags: make(map[string]string),
432
staticTags: ctx.staticTags,
433
+ tableName: ctx.config.Table.Name,
434
}
435
crossTableCtx.rowTags = row.tags
436
@@ -523,7 +527,7 @@ func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext) ([]
527
continue
528
}
529
526
- metric, err := buildTableMetric(sym, pdu, value, rowTags, staticTags)
530
+ metric, err := buildTableMetric(sym, pdu, value, rowTags, staticTags, ctx.tableName)
531
if err != nil {
532
errs = append(errs, err)
533
continue
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table_test.go
+7
@@ -3658,6 +3658,11 @@ func TestTableCollector_Collect(t *testing.T) {
3658
3659
result, err := collector.Collect(tc.profile)
3660
3661
+ // TODO: the Table field is now compared as part of the metric; ensure expectedResult includes correct Table values
3662
+ for i := range result {
3663
+ result[i].Table = ""
3664
+ }
3665
+
3666
if tc.expectedError {
3667
assert.Error(t, err)
3668
if tc.errorContains != "" {
@@ -4448,6 +4453,8 @@ func TestCollector_Collect_TableCaching(t *testing.T) {
4453
for _, profile := range result {
4454
for i := range profile.Metrics {
4455
profile.Metrics[i].Profile = nil
4456
+ // TODO: the Table field is now compared as part of the metric; ensure expectedResult includes correct Table values
4457
+ profile.Metrics[i].Table = ""
4458
}
4459
}
4460
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics.go
new
+134
@@ -0,0 +1,134 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "github.com/netdata/netdata/go/plugins/logger"
7
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
8
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
9
+)
10
+
11
+type vmetricsCollector struct {
12
+ log *logger.Logger
13
+}
14
+
15
+func newVirtualMetricsCollector(log *logger.Logger) *vmetricsCollector {
16
+ return &vmetricsCollector{
17
+ log: log,
18
+ }
19
+}
20
+
21
+// vmetricsSourceKey identifies a metric source
22
+type vmetricsSourceKey struct {
23
+ metricName string
24
+ tableName string
25
+}
26
+
27
+// vmetricsAggregator holds accumulation state for a virtual metric
28
+type vmetricsAggregator struct {
29
+ config ddprofiledefinition.VirtualMetricConfig
30
+ sum int64
31
+ sourceCount int
32
+ metricType ddprofiledefinition.ProfileMetricType
33
+}
34
+
35
+func (p *vmetricsCollector) Collect(profDef *ddprofiledefinition.ProfileDefinition, collectedMetrics []ddsnmp.Metric) []ddsnmp.Metric {
36
+ if len(profDef.VirtualMetrics) == 0 {
37
+ return nil
38
+ }
39
+
40
+ sourceToAggregators, aggregators := p.buildAggregators(profDef)
41
+
42
+ for _, metric := range collectedMetrics {
43
+ if !metric.IsTable || metric.Table == "" {
44
+ continue
45
+ }
46
+
47
+ key := vmetricsSourceKey{
48
+ metricName: metric.Name,
49
+ tableName: metric.Table,
50
+ }
51
+
52
+ // Find all aggregators that need this metric
53
+ if aggrs, found := sourceToAggregators[key]; found {
54
+ for _, agg := range aggrs {
55
+ agg.sum += metric.Value
56
+ agg.sourceCount++
57
+ if agg.metricType == "" {
58
+ agg.metricType = metric.MetricType
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ // Build virtual metrics from aggregators
65
+ var virtualMetrics []ddsnmp.Metric
66
+ for _, agg := range aggregators {
67
+ if agg.sourceCount == 0 {
68
+ p.log.Debugf("no source metrics found for virtual metric '%s'", agg.config.Name)
69
+ continue
70
+ }
71
+
72
+ virtualMetrics = append(virtualMetrics, ddsnmp.Metric{
73
+ Name: agg.config.Name,
74
+ Value: agg.sum,
75
+ Description: agg.config.ChartMeta.Description,
76
+ Family: agg.config.ChartMeta.Family,
77
+ Unit: agg.config.ChartMeta.Unit,
78
+ MetricType: agg.metricType,
79
+ })
80
+ }
81
+
82
+ return virtualMetrics
83
+}
84
+
85
+func (p *vmetricsCollector) buildAggregators(profDef *ddprofiledefinition.ProfileDefinition) (map[vmetricsSourceKey][]*vmetricsAggregator, []*vmetricsAggregator) {
86
+ sourceToAggregators := make(map[vmetricsSourceKey][]*vmetricsAggregator)
87
+ aggregators := make([]*vmetricsAggregator, 0, len(profDef.VirtualMetrics))
88
+
89
+ existingNames := p.getDefinedMetricNames(profDef.Metrics)
90
+
91
+ for _, config := range profDef.VirtualMetrics {
92
+ if existingNames[config.Name] {
93
+ p.log.Warningf("virtual metric '%s' conflicts with existing metric, skipping", config.Name)
94
+ continue
95
+ }
96
+
97
+ agg := &vmetricsAggregator{
98
+ config: config,
99
+ }
100
+ aggregators = append(aggregators, agg)
101
+
102
+ // Register this aggregator for each source it needs
103
+ for _, source := range config.Sources {
104
+ if source.Table == "" {
105
+ p.log.Warningf("virtual metric '%s' source '%s' missing table, skipping source", config.Name, source.Metric)
106
+ continue
107
+ }
108
+
109
+ key := vmetricsSourceKey{
110
+ metricName: source.Metric,
111
+ tableName: source.Table,
112
+ }
113
+
114
+ sourceToAggregators[key] = append(sourceToAggregators[key], agg)
115
+ }
116
+ }
117
+
118
+ return sourceToAggregators, aggregators
119
+}
120
+
121
+func (p *vmetricsCollector) getDefinedMetricNames(profMetrics []ddprofiledefinition.MetricsConfig) map[string]bool {
122
+ names := make(map[string]bool)
123
+ for _, m := range profMetrics {
124
+ switch {
125
+ case m.IsScalar():
126
+ names[m.Symbol.Name] = true
127
+ case m.IsColumn():
128
+ for _, sym := range m.Symbols {
129
+ names[sym.Name] = true
130
+ }
131
+ }
132
+ }
133
+ return names
134
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics_test.go
new
+443
@@ -0,0 +1,443 @@
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
+ "github.com/netdata/netdata/go/plugins/logger"
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13
+)
14
+
15
+func TestVirtualMetricsCollector_Collect(t *testing.T) {
16
+ tests := map[string]struct {
17
+ profileDef *ddprofiledefinition.ProfileDefinition
18
+ collectedMetrics []ddsnmp.Metric
19
+ expected []ddsnmp.Metric
20
+ }{
21
+ "basic sum of single table metric": {
22
+ profileDef: &ddprofiledefinition.ProfileDefinition{
23
+ Metrics: []ddprofiledefinition.MetricsConfig{
24
+ {
25
+ Table: ddprofiledefinition.SymbolConfig{
26
+ OID: "1.3.6.1.2.1.31.1.1",
27
+ Name: "ifXTable",
28
+ },
29
+ Symbols: []ddprofiledefinition.SymbolConfig{
30
+ {OID: "1.3.6.1.2.1.31.1.1.1.6", Name: "ifHCInOctets"},
31
+ },
32
+ },
33
+ },
34
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
35
+ {
36
+ Name: "ifTotalInOctets",
37
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
38
+ {Metric: "ifHCInOctets", Table: "ifXTable"},
39
+ },
40
+ ChartMeta: ddprofiledefinition.ChartMeta{
41
+ Description: "Total inbound traffic",
42
+ Family: "Network/Total/Traffic/In",
43
+ Unit: "bit/s",
44
+ },
45
+ },
46
+ },
47
+ },
48
+ collectedMetrics: []ddsnmp.Metric{
49
+ {Name: "ifHCInOctets", Value: 1000, IsTable: true, Table: "ifXTable", MetricType: ddprofiledefinition.ProfileMetricTypeGauge},
50
+ {Name: "ifHCInOctets", Value: 2000, IsTable: true, Table: "ifXTable"},
51
+ {Name: "ifHCInOctets", Value: 3000, IsTable: true, Table: "ifXTable"},
52
+ },
53
+ expected: []ddsnmp.Metric{
54
+ {
55
+ Name: "ifTotalInOctets",
56
+ Value: 6000,
57
+ Description: "Total inbound traffic",
58
+ Family: "Network/Total/Traffic/In",
59
+ Unit: "bit/s",
60
+ MetricType: ddprofiledefinition.ProfileMetricTypeGauge,
61
+ },
62
+ },
63
+ },
64
+
65
+ "sum from multiple sources": {
66
+ profileDef: &ddprofiledefinition.ProfileDefinition{
67
+ Metrics: []ddprofiledefinition.MetricsConfig{
68
+ {
69
+ Table: ddprofiledefinition.SymbolConfig{
70
+ OID: "1.3.6.1.2.1.2.2",
71
+ Name: "ifTable",
72
+ },
73
+ Symbols: []ddprofiledefinition.SymbolConfig{
74
+ {OID: "1.3.6.1.2.1.2.2.1.14", Name: "ifInErrors"},
75
+ {OID: "1.3.6.1.2.1.2.2.1.20", Name: "ifOutErrors"},
76
+ },
77
+ },
78
+ },
79
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
80
+ {
81
+ Name: "ifTotalErrors",
82
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
83
+ {Metric: "ifInErrors", Table: "ifTable"},
84
+ {Metric: "ifOutErrors", Table: "ifTable"},
85
+ },
86
+ ChartMeta: ddprofiledefinition.ChartMeta{
87
+ Description: "Total errors",
88
+ Family: "Network/Total/Errors",
89
+ Unit: "{error}/s",
90
+ },
91
+ },
92
+ },
93
+ },
94
+ collectedMetrics: []ddsnmp.Metric{
95
+ // Interface 1
96
+ {Name: "ifInErrors", Value: 10, IsTable: true, Table: "ifTable", MetricType: ddprofiledefinition.ProfileMetricTypeRate},
97
+ {Name: "ifOutErrors", Value: 5, IsTable: true, Table: "ifTable"},
98
+ // Interface 2
99
+ {Name: "ifInErrors", Value: 20, IsTable: true, Table: "ifTable"},
100
+ {Name: "ifOutErrors", Value: 15, IsTable: true, Table: "ifTable"},
101
+ },
102
+ expected: []ddsnmp.Metric{
103
+ {
104
+ Name: "ifTotalErrors",
105
+ Value: 50, // 10 + 5 + 20 + 15
106
+ Description: "Total errors",
107
+ Family: "Network/Total/Errors",
108
+ Unit: "{error}/s",
109
+ MetricType: ddprofiledefinition.ProfileMetricTypeRate,
110
+ },
111
+ },
112
+ },
113
+
114
+ "multiple virtual metrics": {
115
+ profileDef: &ddprofiledefinition.ProfileDefinition{
116
+ Metrics: []ddprofiledefinition.MetricsConfig{
117
+ {
118
+ Table: ddprofiledefinition.SymbolConfig{
119
+ OID: "1.3.6.1.2.1.31.1.1",
120
+ Name: "ifXTable",
121
+ },
122
+ Symbols: []ddprofiledefinition.SymbolConfig{
123
+ {OID: "1.3.6.1.2.1.31.1.1.1.6", Name: "ifHCInOctets"},
124
+ {OID: "1.3.6.1.2.1.31.1.1.1.10", Name: "ifHCOutOctets"},
125
+ },
126
+ },
127
+ },
128
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
129
+ {
130
+ Name: "ifTotalInOctets",
131
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
132
+ {Metric: "ifHCInOctets", Table: "ifXTable"},
133
+ },
134
+ ChartMeta: ddprofiledefinition.ChartMeta{
135
+ Description: "Total in",
136
+ Family: "Network/In",
137
+ Unit: "bit/s",
138
+ },
139
+ },
140
+ {
141
+ Name: "ifTotalOutOctets",
142
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
143
+ {Metric: "ifHCOutOctets", Table: "ifXTable"},
144
+ },
145
+ ChartMeta: ddprofiledefinition.ChartMeta{
146
+ Description: "Total out",
147
+ Family: "Network/Out",
148
+ Unit: "bit/s",
149
+ },
150
+ },
151
+ },
152
+ },
153
+ collectedMetrics: []ddsnmp.Metric{
154
+ {Name: "ifHCInOctets", Value: 1000, IsTable: true, Table: "ifXTable"},
155
+ {Name: "ifHCInOctets", Value: 2000, IsTable: true, Table: "ifXTable"},
156
+ {Name: "ifHCOutOctets", Value: 500, IsTable: true, Table: "ifXTable"},
157
+ {Name: "ifHCOutOctets", Value: 1500, IsTable: true, Table: "ifXTable"},
158
+ },
159
+ expected: []ddsnmp.Metric{
160
+ {
161
+ Name: "ifTotalInOctets",
162
+ Value: 3000,
163
+ Description: "Total in",
164
+ Family: "Network/In",
165
+ Unit: "bit/s",
166
+ },
167
+ {
168
+ Name: "ifTotalOutOctets",
169
+ Value: 2000,
170
+ Description: "Total out",
171
+ Family: "Network/Out",
172
+ Unit: "bit/s",
173
+ },
174
+ },
175
+ },
176
+
177
+ "skip missing sources": {
178
+ profileDef: &ddprofiledefinition.ProfileDefinition{
179
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
180
+ {
181
+ Name: "totalMissing",
182
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
183
+ {Metric: "nonExistentMetric", Table: "someTable"},
184
+ },
185
+ ChartMeta: ddprofiledefinition.ChartMeta{
186
+ Description: "Should be skipped",
187
+ },
188
+ },
189
+ },
190
+ },
191
+ collectedMetrics: []ddsnmp.Metric{
192
+ {Name: "ifInOctets", Value: 100, IsTable: true, Table: "ifTable"},
193
+ },
194
+ expected: []ddsnmp.Metric{},
195
+ },
196
+
197
+ "naming conflict with existing metric": {
198
+ profileDef: &ddprofiledefinition.ProfileDefinition{
199
+ Metrics: []ddprofiledefinition.MetricsConfig{
200
+ {
201
+ Symbol: ddprofiledefinition.SymbolConfig{
202
+ OID: "1.3.6.1.2.1.2.2.1.10",
203
+ Name: "ifInOctets",
204
+ },
205
+ },
206
+ },
207
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
208
+ {
209
+ Name: "ifInOctets", // Conflicts with existing metric
210
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
211
+ {Metric: "ifOutOctets", Table: "ifTable"},
212
+ },
213
+ ChartMeta: ddprofiledefinition.ChartMeta{
214
+ Description: "Should be skipped due to conflict",
215
+ },
216
+ },
217
+ },
218
+ },
219
+ collectedMetrics: []ddsnmp.Metric{
220
+ {Name: "ifInOctets", Value: 100, IsTable: true, Table: "ifTable"},
221
+ {Name: "ifOutOctets", Value: 200, IsTable: true, Table: "ifTable"},
222
+ },
223
+ expected: []ddsnmp.Metric{},
224
+ },
225
+
226
+ "source without table name": {
227
+ profileDef: &ddprofiledefinition.ProfileDefinition{
228
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
229
+ {
230
+ Name: "invalidVirtual",
231
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
232
+ {Metric: "someMetric", Table: ""}, // Missing table
233
+ },
234
+ ChartMeta: ddprofiledefinition.ChartMeta{
235
+ Description: "Should skip source without table",
236
+ },
237
+ },
238
+ },
239
+ },
240
+ collectedMetrics: []ddsnmp.Metric{
241
+ {Name: "someMetric", Value: 100, IsTable: true, Table: "someTable"},
242
+ },
243
+ expected: []ddsnmp.Metric{},
244
+ },
245
+
246
+ "ignore scalar metrics": {
247
+ profileDef: &ddprofiledefinition.ProfileDefinition{
248
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
249
+ {
250
+ Name: "totalOctets",
251
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
252
+ {Metric: "ifHCInOctets", Table: "ifXTable"},
253
+ },
254
+ ChartMeta: ddprofiledefinition.ChartMeta{
255
+ Description: "Total octets",
256
+ },
257
+ },
258
+ },
259
+ },
260
+ collectedMetrics: []ddsnmp.Metric{
261
+ {Name: "ifHCInOctets", Value: 1000, IsTable: true, Table: "ifXTable"},
262
+ {Name: "ifHCInOctets", Value: 2000, IsTable: false, Table: ""}, // Scalar, should be ignored
263
+ {Name: "ifHCInOctets", Value: 3000, IsTable: true, Table: "ifXTable"},
264
+ },
265
+ expected: []ddsnmp.Metric{
266
+ {
267
+ Name: "totalOctets",
268
+ Value: 4000, // Only 1000 + 3000
269
+ Description: "Total octets",
270
+ },
271
+ },
272
+ },
273
+
274
+ "metrics from different tables": {
275
+ profileDef: &ddprofiledefinition.ProfileDefinition{
276
+ Metrics: []ddprofiledefinition.MetricsConfig{
277
+ {
278
+ Table: ddprofiledefinition.SymbolConfig{
279
+ OID: "1.3.6.1.2.1.2.2",
280
+ Name: "ifTable",
281
+ },
282
+ Symbols: []ddprofiledefinition.SymbolConfig{
283
+ {OID: "1.3.6.1.2.1.2.2.1.10", Name: "ifInOctets"},
284
+ },
285
+ },
286
+ {
287
+ Table: ddprofiledefinition.SymbolConfig{
288
+ OID: "1.3.6.1.2.1.31.1.1",
289
+ Name: "ifXTable",
290
+ },
291
+ Symbols: []ddprofiledefinition.SymbolConfig{
292
+ {OID: "1.3.6.1.2.1.31.1.1.1.6", Name: "ifHCInOctets"},
293
+ },
294
+ },
295
+ },
296
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
297
+ {
298
+ Name: "totalTraffic",
299
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
300
+ {Metric: "ifInOctets", Table: "ifTable"},
301
+ {Metric: "ifHCInOctets", Table: "ifXTable"},
302
+ },
303
+ ChartMeta: ddprofiledefinition.ChartMeta{
304
+ Description: "Total traffic from both tables",
305
+ },
306
+ },
307
+ },
308
+ },
309
+ collectedMetrics: []ddsnmp.Metric{
310
+ {Name: "ifInOctets", Value: 100, IsTable: true, Table: "ifTable"},
311
+ {Name: "ifInOctets", Value: 200, IsTable: true, Table: "ifTable"},
312
+ {Name: "ifHCInOctets", Value: 1000, IsTable: true, Table: "ifXTable"},
313
+ {Name: "ifHCInOctets", Value: 2000, IsTable: true, Table: "ifXTable"},
314
+ },
315
+ expected: []ddsnmp.Metric{
316
+ {
317
+ Name: "totalTraffic",
318
+ Value: 3300, // 100 + 200 + 1000 + 2000
319
+ Description: "Total traffic from both tables",
320
+ },
321
+ },
322
+ },
323
+
324
+ "empty config": {
325
+ profileDef: &ddprofiledefinition.ProfileDefinition{
326
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{},
327
+ },
328
+ collectedMetrics: []ddsnmp.Metric{
329
+ {Name: "ifInOctets", Value: 100, IsTable: true, Table: "ifTable"},
330
+ },
331
+ expected: nil,
332
+ },
333
+
334
+ "no collected metrics": {
335
+ profileDef: &ddprofiledefinition.ProfileDefinition{
336
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
337
+ {
338
+ Name: "totalOctets",
339
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
340
+ {Metric: "ifInOctets", Table: "ifTable"},
341
+ },
342
+ },
343
+ },
344
+ },
345
+ collectedMetrics: []ddsnmp.Metric{},
346
+ expected: []ddsnmp.Metric{},
347
+ },
348
+
349
+ "large scale test": {
350
+ profileDef: &ddprofiledefinition.ProfileDefinition{
351
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
352
+ {
353
+ Name: "totalInterfaceTraffic",
354
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
355
+ {Metric: "ifHCInOctets", Table: "ifXTable"},
356
+ },
357
+ ChartMeta: ddprofiledefinition.ChartMeta{
358
+ Description: "Total traffic across many interfaces",
359
+ },
360
+ },
361
+ },
362
+ },
363
+ collectedMetrics: func() []ddsnmp.Metric {
364
+ // Simulate 1000 interfaces
365
+ metrics := make([]ddsnmp.Metric, 0, 1000)
366
+ for i := 0; i < 1000; i++ {
367
+ metrics = append(metrics, ddsnmp.Metric{
368
+ Name: "ifHCInOctets",
369
+ Value: int64(i * 100),
370
+ IsTable: true,
371
+ Table: "ifXTable",
372
+ })
373
+ }
374
+ return metrics
375
+ }(),
376
+ expected: []ddsnmp.Metric{
377
+ {
378
+ Name: "totalInterfaceTraffic",
379
+ Value: 49950000, // Sum of 0*100 + 1*100 + ... + 999*100
380
+ Description: "Total traffic across many interfaces",
381
+ },
382
+ },
383
+ },
384
+
385
+ "zero values included": {
386
+ profileDef: &ddprofiledefinition.ProfileDefinition{
387
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
388
+ {
389
+ Name: "totalWithZeros",
390
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
391
+ {Metric: "someMetric", Table: "someTable"},
392
+ },
393
+ },
394
+ },
395
+ },
396
+ collectedMetrics: []ddsnmp.Metric{
397
+ {Name: "someMetric", Value: 0, IsTable: true, Table: "someTable"},
398
+ {Name: "someMetric", Value: 100, IsTable: true, Table: "someTable"},
399
+ {Name: "someMetric", Value: 0, IsTable: true, Table: "someTable"},
400
+ },
401
+ expected: []ddsnmp.Metric{
402
+ {
403
+ Name: "totalWithZeros",
404
+ Value: 100,
405
+ },
406
+ },
407
+ },
408
+
409
+ "negative values": {
410
+ profileDef: &ddprofiledefinition.ProfileDefinition{
411
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
412
+ {
413
+ Name: "totalWithNegatives",
414
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
415
+ {Metric: "someMetric", Table: "someTable"},
416
+ },
417
+ },
418
+ },
419
+ },
420
+ collectedMetrics: []ddsnmp.Metric{
421
+ {Name: "someMetric", Value: 100, IsTable: true, Table: "someTable"},
422
+ {Name: "someMetric", Value: -50, IsTable: true, Table: "someTable"},
423
+ {Name: "someMetric", Value: 200, IsTable: true, Table: "someTable"},
424
+ },
425
+ expected: []ddsnmp.Metric{
426
+ {
427
+ Name: "totalWithNegatives",
428
+ Value: 250, // 100 - 50 + 200
429
+ },
430
+ },
431
+ },
432
+ }
433
+
434
+ for name, tc := range tests {
435
+ t.Run(name, func(t *testing.T) {
436
+ vmc := newVirtualMetricsCollector(logger.New())
437
+ result := vmc.Collect(tc.profileDef, tc.collectedMetrics)
438
+
439
+ // Sort both slices for consistent comparison
440
+ assert.ElementsMatch(t, tc.expected, result)
441
+ })
442
+ }
443
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collectors_meta.go
+1
-1
@@ -70,7 +70,7 @@ func (gc *globalTagsCollector) processDynamicTags(metricTags []ddprofiledefiniti
70
return fmt.Errorf("failed to fetch global tag values: %w", err)
71
}
72
73
- // Process each tag configuration
73
+ // Collect each tag configuration
74
var errs []error
75
for _, tagCfg := range metricTags {
76
if tagCfg.Symbol.OID == "" {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/metric_builder.go
+4
-3
@@ -41,8 +41,9 @@ func (mb *metricBuilder) withStaticTags(tags map[string]string) *metricBuilder {
41
return mb
42
}
43
44
-func (mb *metricBuilder) asTableMetric() *metricBuilder {
44
+func (mb *metricBuilder) asTableMetric(table string) *metricBuilder {
45
mb.metric.IsTable = true
46
+ mb.metric.Table = table
47
return mb
48
}
49
@@ -74,12 +75,12 @@ func buildScalarMetric(cfg ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU,
75
return &metric, nil
76
}
77
77
-func buildTableMetric(cfg ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU, value int64, tags, staticTags map[string]string) (*ddsnmp.Metric, error) {
78
+func buildTableMetric(cfg ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU, value int64, tags, staticTags map[string]string, tableName string) (*ddsnmp.Metric, error) {
79
metric := newMetricBuilder(cfg.Name, value).
80
withTags(tags).
81
withStaticTags(staticTags).
82
fromSymbol(cfg, pdu).
82
- asTableMetric().
83
+ asTableMetric(tableName).
84
build()
85
86
if cfg.TransformCompiled != nil {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/table_row_processor.go
+3
-2
@@ -25,6 +25,7 @@ type (
25
pdus map[string]gosnmp.SnmpPDU
26
tags map[string]string
27
staticTags map[string]string
28
+ tableName string
29
}
30
// tableRowProcessingContext contains context needed for processing a row
31
tableRowProcessingContext struct {
@@ -53,7 +54,7 @@ func (p *tableRowProcessor) processRow(row *tableRowData, ctx *tableRowProcessin
54
}
55
56
func (p *tableRowProcessor) processRowTags(row *tableRowData, ctx *tableRowProcessingContext) error {
56
- // Process tags in the order they appear in the profile
57
+ // Collect tags in the order they appear in the profile
58
for _, orderedTag := range ctx.orderedTags {
59
switch orderedTag.tagType {
60
case tagTypeSameTable:
@@ -166,7 +167,7 @@ func (p *tableRowProcessor) createMetric(sym ddprofiledefinition.SymbolConfig, p
167
return nil, fmt.Errorf("error processing value: %w", err)
168
}
169
169
- return buildTableMetric(sym, pdu, value, row.tags, row.staticTags)
170
+ return buildTableMetric(sym, pdu, value, row.tags, row.staticTags, row.tableName)
171
}
172
173
type (
src/go/plugin/go.d/collector/snmp/ddsnmp/metric.go
+5
-2
@@ -20,7 +20,10 @@ type Metric struct {
20
MetricType ddprofiledefinition.ProfileMetricType
21
StaticTags map[string]string
22
Tags map[string]string
23
- Mappings map[int64]string
24
- IsTable bool
23
+ Table string
24
Value int64
25
+ MultiValue map[string]int64
26
+
27
+ Mappings map[int64]string
28
+ IsTable bool
29
}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile.go
+34
-8
@@ -154,6 +154,18 @@ func (p *Profile) mergeMetrics(base *Profile) {
154
}
155
}
156
}
157
+
158
+ seenVmetrics := make(map[string]bool)
159
+
160
+ for _, m := range p.Definition.VirtualMetrics {
161
+ seenVmetrics[m.Name] = true
162
+ }
163
+ for _, bm := range base.Definition.VirtualMetrics {
164
+ if !seenVmetrics[bm.Name] {
165
+ p.Definition.VirtualMetrics = append(p.Definition.VirtualMetrics, bm)
166
+ seenVmetrics[bm.Name] = true
167
+ }
168
+ }
169
}
170
171
func (p *Profile) mergeMetadata(base *Profile) {
@@ -286,20 +298,34 @@ func deduplicateMetricsAcrossProfiles(profiles []*Profile) {
298
copy(profiles, sortedProfiles)
299
300
seenMetrics := make(map[string]bool)
301
+ seenVmetrics := make(map[string]bool)
302
303
for _, prof := range profiles {
304
if prof.Definition == nil {
305
continue
306
}
307
295
- prof.Definition.Metrics = slices.DeleteFunc(prof.Definition.Metrics, func(metric ddprofiledefinition.MetricsConfig) bool {
296
- key := generateMetricKey(metric)
297
- if seenMetrics[key] {
298
- return true
299
- }
300
- seenMetrics[key] = true
301
- return false
302
- })
308
+ prof.Definition.Metrics = slices.DeleteFunc(
309
+ prof.Definition.Metrics,
310
+ func(metric ddprofiledefinition.MetricsConfig) bool {
311
+ key := generateMetricKey(metric)
312
+ if seenMetrics[key] {
313
+ return true
314
+ }
315
+ seenMetrics[key] = true
316
+ return false
317
+ },
318
+ )
319
+ prof.Definition.VirtualMetrics = slices.DeleteFunc(
320
+ prof.Definition.VirtualMetrics,
321
+ func(vm ddprofiledefinition.VirtualMetricConfig) bool {
322
+ if seenVmetrics[vm.Name] {
323
+ return true
324
+ }
325
+ seenVmetrics[vm.Name] = true
326
+ return false
327
+ },
328
+ )
329
}
330
}
331
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-if-mib.yaml
+18
@@ -156,3 +156,21 @@ metrics:
156
OID: 1.3.6.1.2.1.2.2.1.3
157
name: ifType
158
mapping_ref: ifType
159
+
160
+virtual_metrics:
161
+ - name: ifTotalTrafficIn
162
+ sources:
163
+ - metric: ifHCInOctets
164
+ table: ifXTable
165
+ chart_meta:
166
+ description: Total inbound traffic across all interfaces
167
+ family: 'Network/Total/Traffic/In'
168
+ unit: "bit/s"
169
+ - name: ifTotalTrafficOut
170
+ sources:
171
+ - metric: ifHCOutOctets
172
+ table: ifXTable
173
+ chart_meta:
174
+ description: Total outbound traffic across all interfaces
175
+ family: 'Network/Total/Traffic/Out'
176
+ unit: "bit/s"