@cryptotaxi247 / netdata / commits / da58ac2e9

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"