go.d/snmp: add reusable profile engine helpers (#22181)
* go.d/snmp: add reusable profile engine helpers * go.d/snmp: address profile engine review feedback * go.d/snmp: drop unused constant row helper changes * go.d/snmp: handle no-value text dates * go.d/snmp: refine text date parsing * go.d/snmp: avoid redundant text date parse * go.d/snmp: add missing license header * go.d/snmp: address profile engine maintainer feedback * go.d/snmp: make metric tag labels deterministic * go.d/snmp: clarify profile value formatting * go.d/snmp: backfill empty metric tag labels * go.d/snmp: preserve existing chart labels * update profile-format.md --------- Co-authored-by: ilyam8 <ilya@netdata.cloud>
Costa Tsaousis committed
Apr 10, 2026 at 16:53 UTC
7d939b03e46bbe5ec92f48b415d8ca9bc436df06
26 files changed
+1314
-65
src/go/plugin/go.d/collector/snmp/charts.go
+27
-4
@@ -211,6 +211,7 @@ func (c *Collector) addProfileScalarMetricChart(m ddsnmp.Metric) {
211
tags := c.chartBaseLabels()
212
213
maps.Copy(tags, m.Profile.Tags)
214
+ addMetricTagLabels(tags, m.Tags)
215
for k, v := range tags {
216
chart.Labels = append(chart.Labels, collectorapi.Label{Key: k, Value: v})
217
}
@@ -267,10 +268,7 @@ func (c *Collector) addProfileTableMetricChart(m ddsnmp.Metric) {
268
tags := c.chartBaseLabels()
269
270
maps.Copy(tags, m.Profile.Tags)
270
- for k, v := range m.Tags {
271
- newKey := strings.TrimPrefix(k, "_")
272
- tags[newKey] = v
273
- }
271
+ addMetricTagLabels(tags, m.Tags)
272
273
for k, v := range tags {
274
chart.Labels = append(chart.Labels, collectorapi.Label{Key: k, Value: v})
@@ -328,6 +326,31 @@ func (c *Collector) chartBaseLabels() map[string]string {
326
return labels
327
}
328
329
+func addMetricTagLabels(labels, tags map[string]string) {
330
+ for k, v := range tags {
331
+ if strings.HasPrefix(k, "_") {
332
+ continue
333
+ }
334
+ addMetricTagLabel(labels, k, v)
335
+ }
336
+ for k, v := range tags {
337
+ if !strings.HasPrefix(k, "_") {
338
+ continue
339
+ }
340
+ key := strings.TrimPrefix(k, "_")
341
+ if key == "" {
342
+ continue
343
+ }
344
+ addMetricTagLabel(labels, key, v)
345
+ }
346
+}
347
+
348
+func addMetricTagLabel(labels map[string]string, key, value string) {
349
+ if existing, ok := labels[key]; !ok || existing == "" {
350
+ labels[key] = value
351
+ }
352
+}
353
+
354
func dimAlgoFromDdSnmpType(m ddsnmp.Metric) collectorapi.DimAlgo {
355
switch m.MetricType {
356
case ddprofiledefinition.ProfileMetricTypeGauge,
src/go/plugin/go.d/collector/snmp/charts_test.go
new
+86
@@ -0,0 +1,86 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmp
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/plugin/framework/collectorapi"
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
14
+)
15
+
16
+func TestCollector_AddProfileScalarMetricChart_LabelsIncludeMetricTags(t *testing.T) {
17
+ collr := New()
18
+ collr.Hostname = "192.0.2.1"
19
+ collr.sysInfo = &snmputils.SysInfo{
20
+ Name: "test-device",
21
+ Vendor: "test-vendor",
22
+ }
23
+
24
+ collr.addProfileScalarMetricChart(ddsnmp.Metric{
25
+ Name: "license.status",
26
+ Value: 1,
27
+ Tags: map[string]string{
28
+ "component": "vpn",
29
+ "_component": "private-vpn",
30
+ "_license_state_raw": "active",
31
+ },
32
+ Profile: &ddsnmp.ProfileMetrics{
33
+ Tags: map[string]string{"profile_tag": "profile_value"},
34
+ },
35
+ })
36
+
37
+ chart := collr.Charts().Get("snmp_device_prof_license_status")
38
+ require.NotNil(t, chart)
39
+
40
+ assert.Equal(t, map[string]string{
41
+ "address": "192.0.2.1",
42
+ "component": "vpn",
43
+ "license_state_raw": "active",
44
+ "profile_tag": "profile_value",
45
+ "sysName": "test-device",
46
+ "vendor": "test-vendor",
47
+ }, chartLabels(chart))
48
+}
49
+
50
+func TestAddMetricTagLabels_PrefersUnprefixedTags(t *testing.T) {
51
+ labels := map[string]string{
52
+ "empty_profile_label": "",
53
+ "profile_tag": "profile-value",
54
+ "vendor": "device-vendor",
55
+ }
56
+
57
+ addMetricTagLabels(labels, map[string]string{
58
+ "component": "vpn",
59
+ "empty_metric_label": "",
60
+ "profile_tag": "metric-profile-value",
61
+ "vendor": "",
62
+ "_component": "private-vpn",
63
+ "_empty_metric_label": "metric-fallback",
64
+ "_empty_profile_label": "profile-fallback",
65
+ "_license_state_raw": "active",
66
+ "_vendor": "private-vendor",
67
+ "_": "ignored",
68
+ })
69
+
70
+ assert.Equal(t, map[string]string{
71
+ "component": "vpn",
72
+ "empty_metric_label": "metric-fallback",
73
+ "empty_profile_label": "profile-fallback",
74
+ "license_state_raw": "active",
75
+ "profile_tag": "profile-value",
76
+ "vendor": "device-vendor",
77
+ }, labels)
78
+}
79
+
80
+func chartLabels(chart *collectorapi.Chart) map[string]string {
81
+ labels := make(map[string]string, len(chart.Labels))
82
+ for _, label := range chart.Labels {
83
+ labels[label.Key] = label.Value
84
+ }
85
+ return labels
86
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation.go
+16
@@ -222,6 +222,22 @@ func validateEnrichMetrics(metrics []MetricsConfig) error {
222
}
223
if metricConfig.IsScalar() {
224
errs = append(errs, validateEnrichSymbol(&metricConfig.Symbol, ScalarSymbol))
225
+ for j := range metricConfig.MetricTags {
226
+ metricTag := &metricConfig.MetricTags[j]
227
+ errs = append(errs, validateEnrichMetricTag(metricTag))
228
+ if metricTag.Table != "" {
229
+ errs = append(errs, fmt.Errorf("scalar metric_tags do not support `table` lookups (tag=%q, table=%q)", metricTag.Tag, metricTag.Table))
230
+ }
231
+ if metricTag.Index != 0 {
232
+ errs = append(errs, fmt.Errorf("scalar metric_tags do not support `index` lookups (tag=%q, index=%d)", metricTag.Tag, metricTag.Index))
233
+ }
234
+ if len(metricTag.IndexTransform) > 0 {
235
+ errs = append(errs, fmt.Errorf("scalar metric_tags do not support `index_transform` (tag=%q)", metricTag.Tag))
236
+ }
237
+ if metricTag.Symbol.OID == "" {
238
+ errs = append(errs, fmt.Errorf("scalar metric_tags require `symbol.OID` (tag=%q)", metricTag.Tag))
239
+ }
240
+ }
241
}
242
if metricConfig.IsColumn() {
243
for j := range metricConfig.Symbols {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation_test.go
+101
@@ -459,6 +459,107 @@ func Test_validateEnrichMetrics(t *testing.T) {
459
},
460
},
461
},
462
+ "scalar metric_tags with scalar OID symbol are supported": {
463
+ wantError: false,
464
+ metrics: []MetricsConfig{
465
+ {
466
+ Symbol: SymbolConfig{
467
+ OID: "1.2.3",
468
+ Name: "myMetric",
469
+ },
470
+ MetricTags: MetricTagConfigList{
471
+ {
472
+ OID: "1.2.4",
473
+ Symbol: SymbolConfigCompat{
474
+ Name: "stateSource",
475
+ },
476
+ Tag: "state",
477
+ },
478
+ },
479
+ },
480
+ },
481
+ wantMetrics: []MetricsConfig{
482
+ {
483
+ Symbol: SymbolConfig{
484
+ OID: "1.2.3",
485
+ Name: "myMetric",
486
+ },
487
+ MetricTags: MetricTagConfigList{
488
+ {
489
+ Tag: "state",
490
+ Symbol: SymbolConfigCompat{
491
+ OID: "1.2.4",
492
+ Name: "stateSource",
493
+ },
494
+ },
495
+ },
496
+ },
497
+ },
498
+ },
499
+ "scalar metric_tags do not support index lookups": {
500
+ wantError: true,
501
+ metrics: []MetricsConfig{
502
+ {
503
+ Symbol: SymbolConfig{
504
+ OID: "1.2.3",
505
+ Name: "myMetric",
506
+ },
507
+ MetricTags: MetricTagConfigList{
508
+ {
509
+ Tag: "idx",
510
+ Index: 1,
511
+ },
512
+ },
513
+ },
514
+ },
515
+ },
516
+ "scalar metric_tags do not support table lookups": {
517
+ wantError: true,
518
+ metrics: []MetricsConfig{
519
+ {
520
+ Symbol: SymbolConfig{
521
+ OID: "1.2.3",
522
+ Name: "myMetric",
523
+ },
524
+ MetricTags: MetricTagConfigList{
525
+ {
526
+ Tag: "peer",
527
+ Table: "ifTable",
528
+ Symbol: SymbolConfigCompat{
529
+ OID: "1.2.4",
530
+ Name: "ifDescr",
531
+ },
532
+ },
533
+ },
534
+ },
535
+ },
536
+ },
537
+ "scalar metric_tags do not support index transforms": {
538
+ wantError: true,
539
+ metrics: []MetricsConfig{
540
+ {
541
+ Symbol: SymbolConfig{
542
+ OID: "1.2.3",
543
+ Name: "myMetric",
544
+ },
545
+ MetricTags: MetricTagConfigList{
546
+ {
547
+ Tag: "peer",
548
+ Symbol: SymbolConfigCompat{
549
+ OID: "1.2.4",
550
+ Name: "peerState",
551
+ },
552
+ IndexTransform: []MetricIndexTransform{
553
+ {
554
+ Start: 1,
555
+ End: 1,
556
+ },
557
+ },
558
+ },
559
+ },
560
+ },
561
+ },
562
+ },
563
}
564
for name, tc := range tests {
565
t.Run(name, func(t *testing.T) {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+13
-1
@@ -114,11 +114,13 @@ func (c *Collector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
114
vmetrics[i].Profile = pm
115
}
116
117
- pm.Metrics = slices.DeleteFunc(pm.Metrics, func(m ddsnmp.Metric) bool { return strings.HasPrefix(m.Name, "_") })
117
pm.Metrics = append(pm.Metrics, vmetrics...)
118
pm.Stats.Metrics.Virtual += int64(len(vmetrics))
119
pm.Stats.Timing.VirtualMetrics = time.Since(now)
120
}
121
+
122
+ pm.HiddenMetrics = collectHiddenMetrics(pm.Metrics)
123
+ pm.Metrics = slices.DeleteFunc(pm.Metrics, func(m ddsnmp.Metric) bool { return strings.HasPrefix(m.Name, "_") })
124
}
125
126
if len(metrics) == 0 && len(errs) > 0 {
@@ -131,6 +133,16 @@ func (c *Collector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
133
return metrics, nil
134
}
135
136
+func collectHiddenMetrics(metrics []ddsnmp.Metric) []ddsnmp.Metric {
137
+ var hidden []ddsnmp.Metric
138
+ for _, metric := range metrics {
139
+ if strings.HasPrefix(metric.Name, "_") {
140
+ hidden = append(hidden, metric)
141
+ }
142
+ }
143
+ return hidden
144
+}
145
+
146
func (c *Collector) SetSNMPClient(snmpClient gosnmp.Handler) {
147
if c.globalTagsCollector != nil {
148
c.globalTagsCollector.snmpClient = snmpClient
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_device_meta.go
+3
@@ -176,6 +176,9 @@ func (dc *deviceMetadataCollector) processSymbolValue(cfg ddprofiledefinition.Sy
176
177
val, err := convPduToStringf(pdu, cfg.Format)
178
if err != nil {
179
+ if errors.Is(err, errNoTextDateValue) {
180
+ return "", nil
181
+ }
182
return "", err
183
}
184
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar.go
+33
-1
@@ -20,6 +20,7 @@ type scalarCollector struct {
20
missingOIDs map[string]bool
21
log *logger.Logger
22
valProc *valueProcessor
23
+ tagProc *globalTagProcessor
24
}
25
26
func newScalarCollector(snmpClient gosnmp.Handler, missingOIDs map[string]bool, log *logger.Logger) *scalarCollector {
@@ -28,6 +29,7 @@ func newScalarCollector(snmpClient gosnmp.Handler, missingOIDs map[string]bool,
29
missingOIDs: missingOIDs,
30
log: log,
31
valProc: newValueProcessor(),
32
+ tagProc: newGlobalTagProcessor(),
33
}
34
}
35
@@ -70,6 +72,20 @@ func (sc *scalarCollector) identifyScalarOIDs(configs []ddprofiledefinition.Metr
72
}
73
74
oids = append(oids, cfg.Symbol.OID)
75
+
76
+ for _, tagCfg := range cfg.MetricTags {
77
+ if tagCfg.Symbol.OID == "" {
78
+ continue
79
+ }
80
+
81
+ tagOID := trimOID(tagCfg.Symbol.OID)
82
+ if sc.missingOIDs[tagOID] {
83
+ missingOIDs = append(missingOIDs, tagCfg.Symbol.OID)
84
+ continue
85
+ }
86
+
87
+ oids = append(oids, tagCfg.Symbol.OID)
88
+ }
89
}
90
91
// Sort and deduplicate
@@ -144,10 +160,26 @@ func (sc *scalarCollector) processScalarMetric(cfg ddprofiledefinition.MetricsCo
160
161
value, err := sc.valProc.processValue(cfg.Symbol, pdu)
162
if err != nil {
163
+ if errors.Is(err, errNoTextDateValue) {
164
+ return nil, nil
165
+ }
166
return nil, fmt.Errorf("error processing value for OID %s (%s): %w", cfg.Symbol.Name, cfg.Symbol.OID, err)
167
}
168
169
staticTags := parseStaticTags(cfg.StaticTags)
170
+ var tags map[string]string
171
+ if len(cfg.MetricTags) > 0 {
172
+ tags = make(map[string]string)
173
+ ta := tagAdder{tags: tags}
174
+ for _, tagCfg := range cfg.MetricTags {
175
+ if tagCfg.Symbol.OID == "" {
176
+ continue
177
+ }
178
+ if err := sc.tagProc.processTag(tagCfg, pdus, ta); err != nil {
179
+ sc.log.Debugf("Error processing scalar tag '%s' for metric '%s': %v", tagCfg.Tag, cfg.Symbol.Name, err)
180
+ }
181
+ }
182
+ }
183
152
- return buildScalarMetric(cfg.Symbol, pdu, value, staticTags)
184
+ return buildScalarMetric(cfg.Symbol, pdu, value, tags, staticTags)
185
}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar_test.go
+137
@@ -103,6 +103,115 @@ func TestScalarCollector_Collect(t *testing.T) {
103
},
104
expectedError: false,
105
},
106
+ "scalar metric with dynamic metric tags": {
107
+ profile: &ddsnmp.Profile{
108
+ SourceFile: "test-profile.yaml",
109
+ Definition: &ddprofiledefinition.ProfileDefinition{
110
+ Metrics: []ddprofiledefinition.MetricsConfig{
111
+ {
112
+ Symbol: ddprofiledefinition.SymbolConfig{
113
+ OID: "1.3.6.1.4.1.2604.5.1.5.1.1.0",
114
+ Name: "_license_row",
115
+ Mapping: map[string]string{
116
+ "1": "1",
117
+ "4": "2",
118
+ },
119
+ },
120
+ StaticTags: []ddprofiledefinition.StaticMetricTagConfig{
121
+ {Tag: "_license_id", Value: "base_firewall"},
122
+ },
123
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
124
+ {
125
+ Tag: "license_state",
126
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
127
+ OID: "1.3.6.1.4.1.2604.5.1.5.1.1.0",
128
+ },
129
+ Mapping: map[string]string{
130
+ "1": "trial",
131
+ "4": "expired",
132
+ },
133
+ },
134
+ {
135
+ Tag: "license_expiry",
136
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
137
+ OID: "1.3.6.1.4.1.2604.5.1.5.1.2.0",
138
+ },
139
+ },
140
+ },
141
+ },
142
+ },
143
+ },
144
+ },
145
+ setupMock: func(m *snmpmock.MockHandler) {
146
+ expectSNMPGet(m, []string{"1.3.6.1.4.1.2604.5.1.5.1.1.0", "1.3.6.1.4.1.2604.5.1.5.1.2.0"}, []gosnmp.SnmpPDU{
147
+ createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.1.1.0", 4),
148
+ createStringPDU("1.3.6.1.4.1.2604.5.1.5.1.2.0", "11 Nov 2031"),
149
+ })
150
+ },
151
+ expectedResult: []ddsnmp.Metric{
152
+ {
153
+ Name: "_license_row",
154
+ Value: 2,
155
+ MetricType: "gauge",
156
+ Tags: map[string]string{
157
+ "_license_id": "base_firewall",
158
+ "license_state": "expired",
159
+ "license_expiry": "11 Nov 2031",
160
+ },
161
+ StaticTags: map[string]string{
162
+ "_license_id": "base_firewall",
163
+ },
164
+ },
165
+ },
166
+ expectedError: false,
167
+ },
168
+ "text_date sentinel skips scalar metric without processing error": {
169
+ profile: &ddsnmp.Profile{
170
+ SourceFile: "test-profile.yaml",
171
+ Definition: &ddprofiledefinition.ProfileDefinition{
172
+ Metrics: []ddprofiledefinition.MetricsConfig{
173
+ {
174
+ Symbol: ddprofiledefinition.SymbolConfig{
175
+ OID: "1.3.6.1.4.1.999.1.1.0",
176
+ Name: "license.expiry",
177
+ Format: "text_date",
178
+ },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ setupMock: func(m *snmpmock.MockHandler) {
184
+ expectSNMPGet(m, []string{"1.3.6.1.4.1.999.1.1.0"}, []gosnmp.SnmpPDU{
185
+ createStringPDU("1.3.6.1.4.1.999.1.1.0", "never"),
186
+ })
187
+ },
188
+ expectedResult: []ddsnmp.Metric{},
189
+ expectedError: false,
190
+ },
191
+ "invalid text_date still fails when no scalar metrics are usable": {
192
+ profile: &ddsnmp.Profile{
193
+ SourceFile: "test-profile.yaml",
194
+ Definition: &ddprofiledefinition.ProfileDefinition{
195
+ Metrics: []ddprofiledefinition.MetricsConfig{
196
+ {
197
+ Symbol: ddprofiledefinition.SymbolConfig{
198
+ OID: "1.3.6.1.4.1.999.1.1.0",
199
+ Name: "license.expiry",
200
+ Format: "text_date",
201
+ },
202
+ },
203
+ },
204
+ },
205
+ },
206
+ setupMock: func(m *snmpmock.MockHandler) {
207
+ expectSNMPGet(m, []string{"1.3.6.1.4.1.999.1.1.0"}, []gosnmp.SnmpPDU{
208
+ createStringPDU("1.3.6.1.4.1.999.1.1.0", "not-a-date"),
209
+ })
210
+ },
211
+ expectedResult: nil,
212
+ expectedError: true,
213
+ errorContains: `text_date: cannot parse "not-a-date"`,
214
+ },
215
"OID not found - returns empty metrics": {
216
profile: createTestProfile("test-profile.yaml", []ddprofiledefinition.MetricsConfig{
217
createScalarMetric("1.3.6.1.2.1.1.3.0", "sysUpTime"),
@@ -733,3 +842,31 @@ func TestScalarCollector_Collect(t *testing.T) {
842
})
843
}
844
}
845
+
846
+func TestScalarCollector_IdentifyScalarOIDs_SkipsTagOIDsWhenPrimaryOIDIsKnownMissing(t *testing.T) {
847
+ sc := &scalarCollector{
848
+ missingOIDs: map[string]bool{
849
+ "1.3.6.1.4.1.2604.5.1.5.1.1.0": true,
850
+ },
851
+ }
852
+
853
+ oids, missing := sc.identifyScalarOIDs([]ddprofiledefinition.MetricsConfig{
854
+ {
855
+ Symbol: ddprofiledefinition.SymbolConfig{
856
+ OID: "1.3.6.1.4.1.2604.5.1.5.1.1.0",
857
+ Name: "_license_row",
858
+ },
859
+ MetricTags: []ddprofiledefinition.MetricTagConfig{
860
+ {
861
+ Tag: "license_state",
862
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
863
+ OID: "1.3.6.1.4.1.2604.5.1.5.1.2.0",
864
+ },
865
+ },
866
+ },
867
+ },
868
+ })
869
+
870
+ assert.Empty(t, oids)
871
+ assert.Equal(t, []string{"1.3.6.1.4.1.2604.5.1.5.1.1.0"}, missing)
872
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go
+3
@@ -541,6 +541,9 @@ func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext, sta
541
for _, sym := range syms {
542
value, err := tc.valProc.processValue(sym, pdu)
543
if err != nil {
544
+ if errors.Is(err, errNoTextDateValue) {
545
+ continue
546
+ }
547
stats.Errors.Processing.Table++
548
tc.log.Debugf("Error processing value for %s: %v", sym.Name, err)
549
continue
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go
+74
@@ -150,6 +150,80 @@ func TestCollector_Collect_StatsSnapshot(t *testing.T) {
150
assert.Equal(t, expected, pm.Stats)
151
}
152
153
+func TestCollector_Collect_PreservesHiddenMetrics(t *testing.T) {
154
+ ctrl, mockHandler := setupMockHandler(t)
155
+ defer ctrl.Finish()
156
+
157
+ expectSNMPWalk(mockHandler,
158
+ gosnmp.Version2c,
159
+ "1.3.6.1.4.1.99999.1",
160
+ []gosnmp.SnmpPDU{
161
+ createCounter32PDU("1.3.6.1.4.1.99999.1.1.1", 100),
162
+ },
163
+ )
164
+
165
+ profile := &ddsnmp.Profile{
166
+ SourceFile: "hidden-metrics-profile.yaml",
167
+ Definition: &ddprofiledefinition.ProfileDefinition{
168
+ Metrics: []ddprofiledefinition.MetricsConfig{
169
+ {
170
+ Table: ddprofiledefinition.SymbolConfig{
171
+ OID: "1.3.6.1.4.1.99999.1",
172
+ Name: "privateTable",
173
+ },
174
+ Symbols: []ddprofiledefinition.SymbolConfig{
175
+ {
176
+ OID: "1.3.6.1.4.1.99999.1.1",
177
+ Name: "_privateMetric",
178
+ },
179
+ },
180
+ },
181
+ },
182
+ VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
183
+ {
184
+ Name: "privateMetric_total",
185
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
186
+ {
187
+ Metric: "_privateMetric",
188
+ Table: "privateTable",
189
+ },
190
+ },
191
+ },
192
+ {
193
+ Name: "_privateMetric_total",
194
+ Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
195
+ {
196
+ Metric: "_privateMetric",
197
+ Table: "privateTable",
198
+ },
199
+ },
200
+ },
201
+ },
202
+ },
203
+ }
204
+
205
+ handleCrossTableTagsWithoutMetrics(profile)
206
+ require.NoError(t, ddsnmp.CompileTransforms(profile))
207
+
208
+ collector := New(Config{
209
+ SnmpClient: mockHandler,
210
+ Profiles: []*ddsnmp.Profile{profile},
211
+ Log: logger.New(),
212
+ SysObjectID: "",
213
+ })
214
+
215
+ results, err := collector.Collect()
216
+ require.NoError(t, err)
217
+ require.Len(t, results, 1)
218
+
219
+ pm := results[0]
220
+ require.Len(t, pm.HiddenMetrics, 2)
221
+ assert.Equal(t, "_privateMetric", pm.HiddenMetrics[0].Name)
222
+ assert.Equal(t, "_privateMetric_total", pm.HiddenMetrics[1].Name)
223
+ require.Len(t, pm.Metrics, 1)
224
+ assert.Equal(t, "privateMetric_total", pm.Metrics[0].Name)
225
+}
226
+
227
func TestLongestCommonPrefix(t *testing.T) {
228
assert.Equal(t, "1.3.6.1.2.1.31.1.1.1", longestCommonPrefix([]string{
229
"1.3.6.1.2.1.31.1.1.1.1",
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/common_test.go
+14
@@ -5,6 +5,7 @@ package ddsnmpcollector
5
import (
6
"regexp"
7
"testing"
8
+ "time"
9
10
"github.com/golang/mock/gomock"
11
"github.com/gosnmp/gosnmp"
@@ -110,6 +111,19 @@ func createTimeTicksPDU(name string, value uint32) gosnmp.SnmpPDU {
111
return createPDU(name, gosnmp.TimeTicks, value)
112
}
113
114
+func createDateAndTimePDU(name string, value time.Time) gosnmp.SnmpPDU {
115
+ return createPDU(name, gosnmp.OctetString, []byte{
116
+ byte(value.Year() >> 8),
117
+ byte(value.Year()),
118
+ byte(value.Month()),
119
+ byte(value.Day()),
120
+ byte(value.Hour()),
121
+ byte(value.Minute()),
122
+ byte(value.Second()),
123
+ 0,
124
+ })
125
+}
126
+
127
func createNoSuchObjectPDU(name string) gosnmp.SnmpPDU {
128
return createPDU(name, gosnmp.NoSuchObject, nil)
129
}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/metric_builder.go
+2
-1
@@ -71,8 +71,9 @@ func (mb *metricBuilder) build() ddsnmp.Metric {
71
return mb.metric
72
}
73
74
-func buildScalarMetric(cfg ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU, value int64, staticTags map[string]string) (*ddsnmp.Metric, error) {
74
+func buildScalarMetric(cfg ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU, value int64, tags, staticTags map[string]string) (*ddsnmp.Metric, error) {
75
metric := newMetricBuilder(cfg.Name, value).
76
+ withTags(tags).
77
withStaticTags(staticTags).
78
fromSymbol(cfg, pdu).
79
build()
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/table_row_processor.go
+9
@@ -1,6 +1,9 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
package ddsnmpcollector
4
5
import (
6
+ "errors"
7
"fmt"
8
"strings"
9
@@ -184,6 +187,9 @@ func (p *tableRowProcessor) processRowMetrics(row *tableRowData, ctx *tableRowPr
187
p.log.Debugf("Error creating metric %s: %v", sym.Name, err)
188
continue
189
}
190
+ if metric == nil {
191
+ continue
192
+ }
193
194
metrics = append(metrics, *metric)
195
}
@@ -195,6 +201,9 @@ func (p *tableRowProcessor) processRowMetrics(row *tableRowData, ctx *tableRowPr
201
func (p *tableRowProcessor) createMetric(sym ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU, row *tableRowData) (*ddsnmp.Metric, error) {
202
value, err := p.valProc.processValue(sym, pdu)
203
if err != nil {
204
+ if errors.Is(err, errNoTextDateValue) {
205
+ return nil, nil
206
+ }
207
return nil, fmt.Errorf("error processing value: %w", err)
208
}
209
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/table_row_processor_test.go
new
+43
@@ -0,0 +1,43 @@
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 TestTableRowProcessor_ProcessRowMetrics_SkipsTextDateNoValue(t *testing.T) {
17
+ p := newTableRowProcessor(logger.New())
18
+
19
+ row := &tableRowData{
20
+ pdus: map[string]gosnmp.SnmpPDU{
21
+ "1.3.6.1.4.1.999.1.1.1": createStringPDU("1.3.6.1.4.1.999.1.1.1.1", "never"),
22
+ },
23
+ tags: map[string]string{},
24
+ staticTags: map[string]string{},
25
+ tableName: "licenseTable",
26
+ }
27
+ ctx := &tableRowProcessingContext{
28
+ columnOIDs: map[string][]ddprofiledefinition.SymbolConfig{
29
+ "1.3.6.1.4.1.999.1.1.1": {
30
+ {
31
+ OID: "1.3.6.1.4.1.999.1.1.1",
32
+ Name: "license.expiry",
33
+ Format: "text_date",
34
+ },
35
+ },
36
+ },
37
+ }
38
+
39
+ metrics, err := p.processRowMetrics(row, ctx)
40
+
41
+ require.NoError(t, err)
42
+ assert.Empty(t, metrics)
43
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/tag_processor.go
+5
@@ -3,6 +3,8 @@
3
package ddsnmpcollector
4
5
import (
6
+ "errors"
7
+
8
"github.com/gosnmp/gosnmp"
9
10
"github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
@@ -56,6 +58,9 @@ func (p *tableTagProcessor) processTag(cfg ddprofiledefinition.MetricTagConfig,
58
59
val, err := convPduToStringf(pdu, cfg.Symbol.Format)
60
if err != nil {
61
+ if errors.Is(err, errNoTextDateValue) {
62
+ return nil
63
+ }
64
return err
65
}
66
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/tag_processor_test.go
+16
@@ -33,6 +33,22 @@ func TestTableTagProcessor_ProcessTag_Uint32Format(t *testing.T) {
33
assert.Equal(t, "4200000000", ta.tags["remote_as"])
34
}
35
36
+func TestTableTagProcessor_ProcessTag_TextDateNoValueSkipsTag(t *testing.T) {
37
+ processor := newTableTagProcessor()
38
+ ta := tagAdder{tags: map[string]string{}}
39
+
40
+ err := processor.processTag(ddprofiledefinition.MetricTagConfig{
41
+ Tag: "license_expiry",
42
+ Symbol: ddprofiledefinition.SymbolConfigCompat{
43
+ OID: "1.3.6.1.4.1.999.1.2",
44
+ Format: "text_date",
45
+ },
46
+ }, createStringPDU("1.3.6.1.4.1.999.1.2.0", "n/a"), ta)
47
+
48
+ require.NoError(t, err)
49
+ assert.Empty(t, ta.tags)
50
+}
51
+
52
func TestMetricTagDisplayName(t *testing.T) {
53
tests := map[string]struct {
54
cfg ddprofiledefinition.MetricTagConfig
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/utils.go
+85
@@ -4,10 +4,12 @@ package ddsnmpcollector
4
5
import (
6
"encoding/hex"
7
+ "errors"
8
"fmt"
9
"regexp"
10
"strconv"
11
"strings"
12
+ "time"
13
"unicode/utf8"
14
15
"github.com/gosnmp/gosnmp"
@@ -16,6 +18,8 @@ import (
18
"github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
19
)
20
21
+var errNoTextDateValue = errors.New("text_date: no timestamp value")
22
+
23
func getMetricTypeFromPDUType(pdu gosnmp.SnmpPDU) ddprofiledefinition.ProfileMetricType {
24
switch pdu.Type {
25
case gosnmp.Counter32, gosnmp.Counter64:
@@ -71,6 +75,27 @@ func convPduToStringf(pdu gosnmp.SnmpPDU, format string) (string, error) {
75
return "", fmt.Errorf("cannot convert %T to hex", pdu.Value)
76
}
77
return hex.EncodeToString(bs), nil
78
+ case "snmp_dateandtime":
79
+ ts, err := convPduToDateAndTimeUnix(pdu)
80
+ if err != nil {
81
+ return "", err
82
+ }
83
+ return strconv.FormatInt(ts, 10), nil
84
+ case "text_date":
85
+ // Decode textual dates into unix timestamps directly from the
86
+ // fresh PDU value on every poll.
87
+ raw, err := convPduToString(pdu)
88
+ if err != nil {
89
+ return "", err
90
+ }
91
+ ts, ok := ddsnmp.ParseTextDate(raw)
92
+ if !ok {
93
+ if ddsnmp.IsTextDateNoValue(raw) {
94
+ return "", errNoTextDateValue
95
+ }
96
+ return "", fmt.Errorf("text_date: cannot parse %q", raw)
97
+ }
98
+ return strconv.FormatInt(ts, 10), nil
99
default:
100
// For unknown formats, use the default string conversion
101
return convPduToString(pdu)
@@ -94,6 +119,66 @@ func convNumericPduToInt64f(pdu gosnmp.SnmpPDU, format string) (int64, error) {
119
return value, nil
120
}
121
122
+func convPduToDateAndTimeUnix(pdu gosnmp.SnmpPDU) (int64, error) {
123
+ var bs []byte
124
+
125
+ switch v := pdu.Value.(type) {
126
+ case []byte:
127
+ bs = v
128
+ case string:
129
+ bs = []byte(v)
130
+ default:
131
+ return 0, fmt.Errorf("cannot convert %T to SNMP DateAndTime", pdu.Value)
132
+ }
133
+
134
+ if len(bs) != 8 && len(bs) != 11 {
135
+ return 0, fmt.Errorf("invalid SNMP DateAndTime length %d", len(bs))
136
+ }
137
+
138
+ year := int(bs[0])<<8 | int(bs[1])
139
+ month := time.Month(bs[2])
140
+ day := int(bs[3])
141
+ hour := int(bs[4])
142
+ minute := int(bs[5])
143
+ second := int(bs[6])
144
+ deci := int(bs[7])
145
+
146
+ // The 8-octet SNMPv2-TC DateAndTime form omits timezone fields when
147
+ // only local time is known. The collector does not know the device's
148
+ // timezone, so it uses UTC as a deterministic fallback; 11-octet values
149
+ // use their embedded UTC offset below.
150
+ loc := time.UTC
151
+ if len(bs) == 11 {
152
+ sign := bs[8]
153
+ tzHours := int(bs[9])
154
+ tzMinutes := int(bs[10])
155
+ if sign != '+' && sign != '-' {
156
+ return 0, fmt.Errorf("invalid SNMP DateAndTime UTC direction %q", sign)
157
+ }
158
+ if tzHours > 13 {
159
+ return 0, fmt.Errorf("invalid SNMP DateAndTime UTC hours offset %d", tzHours)
160
+ }
161
+ if tzMinutes > 59 {
162
+ return 0, fmt.Errorf("invalid SNMP DateAndTime UTC minutes offset %d", tzMinutes)
163
+ }
164
+ offset := tzHours*3600 + tzMinutes*60
165
+ if sign == '-' {
166
+ offset = -offset
167
+ }
168
+ loc = time.FixedZone("snmp", offset)
169
+ }
170
+
171
+ if second == 60 {
172
+ return 0, fmt.Errorf("unsupported SNMP DateAndTime leap second")
173
+ }
174
+
175
+ tm := time.Date(year, month, day, hour, minute, second, deci*100_000_000, loc)
176
+ if tm.Year() != year || tm.Month() != month || tm.Day() != day || tm.Hour() != hour || tm.Minute() != minute || tm.Second() != second || tm.Nanosecond()/100_000_000 != deci {
177
+ return 0, fmt.Errorf("invalid SNMP DateAndTime value")
178
+ }
179
+ return tm.Unix(), nil
180
+}
181
+
182
func convPduToString(pdu gosnmp.SnmpPDU) (string, error) {
183
switch pdu.Type {
184
case gosnmp.NoSuchObject, gosnmp.NoSuchInstance, gosnmp.Null:
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/utils_test.go
new
+132
@@ -0,0 +1,132 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmpcollector
4
+
5
+import (
6
+ "strconv"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/gosnmp/gosnmp"
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
15
+func TestConvPduToStringf_SNMPDateAndTime(t *testing.T) {
16
+ tests := []struct {
17
+ name string
18
+ pdu gosnmp.SnmpPDU
19
+ want int64
20
+ }{
21
+ {
22
+ name: "without timezone",
23
+ pdu: gosnmp.SnmpPDU{
24
+ Type: gosnmp.OctetString,
25
+ Value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x0C, 0x00},
26
+ },
27
+ want: time.Date(2024, time.April, 3, 10, 11, 12, 0, time.UTC).Unix(),
28
+ },
29
+ {
30
+ name: "with timezone",
31
+ pdu: gosnmp.SnmpPDU{
32
+ Type: gosnmp.OctetString,
33
+ Value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x0C, 0x00, '+', 0x02, 0x00},
34
+ },
35
+ want: time.Date(2024, time.April, 3, 8, 11, 12, 0, time.UTC).Unix(),
36
+ },
37
+ }
38
+
39
+ for _, tt := range tests {
40
+ t.Run(tt.name, func(t *testing.T) {
41
+ got, err := convPduToStringf(tt.pdu, "snmp_dateandtime")
42
+ require.NoError(t, err)
43
+ assert.Equal(t, tt.want, mustParseInt64(t, got))
44
+ })
45
+ }
46
+}
47
+
48
+func TestConvPduToStringf_TextDate(t *testing.T) {
49
+ got, err := convPduToStringf(createStringPDU("1.2.3", "2026-12-31"), "text_date")
50
+ require.NoError(t, err)
51
+ assert.EqualValues(t, time.Date(2026, time.December, 31, 0, 0, 0, 0, time.UTC).Unix(), mustParseInt64(t, got))
52
+}
53
+
54
+func TestConvPduToStringf_TextDateNoValue(t *testing.T) {
55
+ for _, raw := range []string{"", "0", "never", "n/a", "4294967295"} {
56
+ got, err := convPduToStringf(createStringPDU("1.2.3", raw), "text_date")
57
+ require.ErrorIs(t, err, errNoTextDateValue, "raw=%q", raw)
58
+ assert.Empty(t, got, "raw=%q", raw)
59
+ }
60
+}
61
+
62
+func TestConvPduToStringf_TextDateInvalid(t *testing.T) {
63
+ _, err := convPduToStringf(createStringPDU("1.2.3", "not-a-date"), "text_date")
64
+ require.Error(t, err)
65
+ assert.Contains(t, err.Error(), `text_date: cannot parse "not-a-date"`)
66
+}
67
+
68
+func TestConvPduToDateAndTimeUnix_Invalid(t *testing.T) {
69
+ tests := []struct {
70
+ name string
71
+ value []byte
72
+ }{
73
+ {
74
+ name: "invalid month",
75
+ value: []byte{0x07, 0xE8, 0x0D, 0x03, 0x0A, 0x0B, 0x0C, 0x00},
76
+ },
77
+ {
78
+ name: "invalid day",
79
+ value: []byte{0x07, 0xE8, 0x04, 0x00, 0x0A, 0x0B, 0x0C, 0x00},
80
+ },
81
+ {
82
+ name: "invalid hour",
83
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x18, 0x0B, 0x0C, 0x00},
84
+ },
85
+ {
86
+ name: "invalid minute",
87
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x3C, 0x0C, 0x00},
88
+ },
89
+ {
90
+ name: "invalid second",
91
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x3D, 0x00},
92
+ },
93
+ {
94
+ name: "leap second unsupported",
95
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x3C, 0x00},
96
+ },
97
+ {
98
+ name: "invalid decisecond",
99
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x0C, 0x0A},
100
+ },
101
+ {
102
+ name: "invalid timezone direction",
103
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x0C, 0x00, 'x', 0x02, 0x00},
104
+ },
105
+ {
106
+ name: "invalid timezone hours",
107
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x0C, 0x00, '+', 0x0E, 0x00},
108
+ },
109
+ {
110
+ name: "invalid timezone minutes",
111
+ value: []byte{0x07, 0xE8, 0x04, 0x03, 0x0A, 0x0B, 0x0C, 0x00, '+', 0x02, 0x3C},
112
+ },
113
+ }
114
+
115
+ for _, tt := range tests {
116
+ t.Run(tt.name, func(t *testing.T) {
117
+ _, err := convPduToDateAndTimeUnix(gosnmp.SnmpPDU{
118
+ Type: gosnmp.OctetString,
119
+ Value: tt.value,
120
+ })
121
+ require.Error(t, err)
122
+ })
123
+ }
124
+}
125
+
126
+func mustParseInt64(t *testing.T, s string) int64 {
127
+ t.Helper()
128
+
129
+ v, err := strconv.ParseInt(s, 10, 64)
130
+ require.NoError(t, err)
131
+ return v
132
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/value_processor.go
+12
@@ -25,12 +25,24 @@ func newValueProcessor() *valueProcessor {
25
}
26
27
func (p *valueProcessor) processValue(sym ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU) (int64, error) {
28
+ if isStringValueFormat(sym.Format) {
29
+ return p.stringProcessor.processValue(sym, pdu)
30
+ }
31
if isPduNumericType(pdu) {
32
return p.numericProcessor.processValue(sym, pdu)
33
}
34
return p.stringProcessor.processValue(sym, pdu)
35
}
36
37
+func isStringValueFormat(format string) bool {
38
+ switch format {
39
+ case "hex", "ip_address", "mac_address", "snmp_dateandtime", "text_date":
40
+ return true
41
+ default:
42
+ return false
43
+ }
44
+}
45
+
46
// numericValueProcessor handles numeric PDU types
47
type numericValueProcessor struct{}
48
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/value_processor_test.go
+24
@@ -114,3 +114,27 @@ func TestNumericValueProcessor_ProcessValue_Uint32Format(t *testing.T) {
114
})
115
}
116
}
117
+
118
+func TestValueProcessor_ProcessValue_TextDateFormatOnNumericPDU(t *testing.T) {
119
+ processor := newValueProcessor()
120
+ symbol := ddprofiledefinition.SymbolConfig{
121
+ OID: "1.3.6.1.4.1.99999.1.1.0",
122
+ Name: "licenseExpiry",
123
+ Format: "text_date",
124
+ }
125
+
126
+ value, err := processor.processValue(symbol, gosnmp.SnmpPDU{
127
+ Name: "1.3.6.1.4.1.99999.1.1.0",
128
+ Type: gosnmp.Gauge32,
129
+ Value: uint32(1798675200),
130
+ })
131
+ require.NoError(t, err)
132
+ require.EqualValues(t, 1798675200, value)
133
+
134
+ _, err = processor.processValue(symbol, gosnmp.SnmpPDU{
135
+ Name: "1.3.6.1.4.1.99999.1.1.0",
136
+ Type: gosnmp.Gauge32,
137
+ Value: uint32(4294967295),
138
+ })
139
+ require.ErrorIs(t, err, errNoTextDateValue)
140
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/metric.go
+1
@@ -11,6 +11,7 @@ type ProfileMetrics struct {
11
DeviceMetadata map[string]MetaTag
12
Tags map[string]string
13
Metrics []Metric
14
+ HiddenMetrics []Metric
15
Stats CollectionStats
16
}
17
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_test.go
+111
@@ -192,6 +192,117 @@ func Test_Profile_merge(t *testing.T) {
192
}
193
}
194
195
+func TestProfileMerge_ColumnSymbolsWithSameNameFromBaseArePreserved(t *testing.T) {
196
+ child := &Profile{
197
+ Definition: &ddprofiledefinition.ProfileDefinition{},
198
+ }
199
+ base := &Profile{
200
+ Definition: &ddprofiledefinition.ProfileDefinition{
201
+ Metrics: []ddprofiledefinition.MetricsConfig{
202
+ {
203
+ Table: ddprofiledefinition.SymbolConfig{OID: "1.2.3", Name: "tableA"},
204
+ Symbols: []ddprofiledefinition.SymbolConfig{
205
+ {OID: "1.2.3.1", Name: "_license_row"},
206
+ {OID: "1.2.3.2", Name: "_license_row"},
207
+ },
208
+ },
209
+ {
210
+ Table: ddprofiledefinition.SymbolConfig{OID: "1.2.4", Name: "tableB"},
211
+ Symbols: []ddprofiledefinition.SymbolConfig{
212
+ {OID: "1.2.4.1", Name: "_license_row"},
213
+ },
214
+ },
215
+ },
216
+ },
217
+ }
218
+
219
+ child.mergeMetrics(base)
220
+
221
+ require.Len(t, child.Definition.Metrics, 2)
222
+ require.Len(t, child.Definition.Metrics[0].Symbols, 2)
223
+ assert.Equal(t, "1.2.3.1", child.Definition.Metrics[0].Symbols[0].OID)
224
+ assert.Equal(t, "1.2.3.2", child.Definition.Metrics[0].Symbols[1].OID)
225
+ require.Len(t, child.Definition.Metrics[1].Symbols, 1)
226
+ assert.Equal(t, "1.2.4.1", child.Definition.Metrics[1].Symbols[0].OID)
227
+}
228
+
229
+func TestProfileMerge_DifferentTablesDoNotOverrideColumnsByName(t *testing.T) {
230
+ child := &Profile{
231
+ Definition: &ddprofiledefinition.ProfileDefinition{
232
+ Metrics: []ddprofiledefinition.MetricsConfig{
233
+ {
234
+ Table: ddprofiledefinition.SymbolConfig{OID: "9.9.9", Name: "childTable"},
235
+ Symbols: []ddprofiledefinition.SymbolConfig{
236
+ {OID: "9.9.9.1", Name: "memory.used"},
237
+ },
238
+ },
239
+ },
240
+ },
241
+ }
242
+ base := &Profile{
243
+ Definition: &ddprofiledefinition.ProfileDefinition{
244
+ Metrics: []ddprofiledefinition.MetricsConfig{
245
+ {
246
+ Table: ddprofiledefinition.SymbolConfig{OID: "1.2.3", Name: "baseTable"},
247
+ Symbols: []ddprofiledefinition.SymbolConfig{
248
+ {OID: "1.2.3.1", Name: "memory.used"},
249
+ {OID: "1.2.3.2", Name: "memory.free"},
250
+ },
251
+ },
252
+ },
253
+ },
254
+ }
255
+
256
+ child.mergeMetrics(base)
257
+
258
+ require.Len(t, child.Definition.Metrics, 2)
259
+ assert.Equal(t, "childTable", child.Definition.Metrics[0].Table.Name)
260
+ require.Len(t, child.Definition.Metrics[0].Symbols, 1)
261
+ assert.Equal(t, "memory.used", child.Definition.Metrics[0].Symbols[0].Name)
262
+ assert.Equal(t, "baseTable", child.Definition.Metrics[1].Table.Name)
263
+ require.Len(t, child.Definition.Metrics[1].Symbols, 2)
264
+ assert.Equal(t, "memory.used", child.Definition.Metrics[1].Symbols[0].Name)
265
+ assert.Equal(t, "memory.free", child.Definition.Metrics[1].Symbols[1].Name)
266
+}
267
+
268
+func TestProfileMerge_BaseScalarDuplicateAddedOnce(t *testing.T) {
269
+ child := &Profile{
270
+ Definition: &ddprofiledefinition.ProfileDefinition{},
271
+ }
272
+ base := &Profile{
273
+ Definition: &ddprofiledefinition.ProfileDefinition{
274
+ Metrics: []ddprofiledefinition.MetricsConfig{
275
+ {
276
+ Symbol: ddprofiledefinition.SymbolConfig{
277
+ OID: "1.2.3.0",
278
+ Name: "license.expiry",
279
+ },
280
+ },
281
+ {
282
+ Symbol: ddprofiledefinition.SymbolConfig{
283
+ OID: "1.2.3.0",
284
+ Name: "license.expiry",
285
+ },
286
+ },
287
+ {
288
+ Symbol: ddprofiledefinition.SymbolConfig{
289
+ OID: "1.2.4.0",
290
+ Name: "license.state",
291
+ },
292
+ },
293
+ },
294
+ },
295
+ }
296
+
297
+ child.mergeMetrics(base)
298
+
299
+ require.Len(t, child.Definition.Metrics, 2)
300
+ assert.Equal(t, "license.expiry", child.Definition.Metrics[0].Symbol.Name)
301
+ assert.Equal(t, "1.2.3.0", child.Definition.Metrics[0].Symbol.OID)
302
+ assert.Equal(t, "license.state", child.Definition.Metrics[1].Symbol.Name)
303
+ assert.Equal(t, "1.2.4.0", child.Definition.Metrics[1].Symbol.OID)
304
+}
305
+
306
func TestDeduplicateMetricsAcrossProfiles(t *testing.T) {
307
tests := map[string]struct {
308
profiles []*Profile
src/go/plugin/go.d/collector/snmp/ddsnmp/transform.go
+116
@@ -9,7 +9,9 @@ import (
9
"math"
10
"net"
11
"strconv"
12
+ "strings"
13
"text/template"
14
+ "time"
15
16
"github.com/Masterminds/sprig/v3"
17
@@ -321,9 +323,123 @@ func newMetricTransformFuncMap() template.FuncMap {
323
324
return ""
325
},
326
+ "licenseDateFromTag": func(m *Metric, tagName, kind string) (string, error) {
327
+ // licenseDateFromTag parses a vendor date string carried in a metric tag,
328
+ // replaces the metric value with its unix epoch, and stamps the licensing
329
+ // value kind. It is intentionally limited to timestamp value kinds; other
330
+ // licensing row kinds can use the generic setTag transform directly.
331
+ if !isLicenseDateValueKind(kind) {
332
+ return "", fmt.Errorf("licenseDateFromTag: unsupported value kind %q", kind)
333
+ }
334
+ if m.Tags == nil {
335
+ return "", nil
336
+ }
337
+ raw := strings.TrimSpace(m.Tags[tagName])
338
+ if raw == "" {
339
+ return "", nil
340
+ }
341
+
342
+ ts, ok := parseTextDate(raw)
343
+ if !ok {
344
+ return "", nil
345
+ }
346
+ m.Value = ts
347
+ m.Tags["_license_value_kind"] = kind
348
+ return "", nil
349
+ },
350
}
351
352
maps.Copy(fm, extra)
353
354
return fm
355
}
356
+
357
+// textDateLayouts is the set of vendor-friendly date formats accepted by
358
+// text_date and licenseDateFromTag. The list is intentionally generous:
359
+// vendors that publish operational dates through SNMP rarely agree on a single
360
+// textual format. Numeric slash-only dates are intentionally excluded because
361
+// dd/mm/yyyy and mm/dd/yyyy are ambiguous for values like 01/02/2024.
362
+var textDateLayouts = []string{
363
+ time.RFC3339,
364
+ "2006-01-02 15:04:05",
365
+ "2006-01-02",
366
+ "Mon Jan 2 15:04:05 2006",
367
+ "Mon Jan 2 2006",
368
+ "Mon 2 January 2006",
369
+ "2 January 2006",
370
+ "January 2 2006",
371
+ "Jan 2 2006",
372
+ "Jan 2 2006 15:04:05",
373
+ "2 Jan 2006",
374
+ "2 Jan 2006 15:04:05",
375
+ "02 Jan 2006",
376
+ "02 Jan 2006 15:04:05",
377
+ "02Jan2006",
378
+ "2Jan2006",
379
+}
380
+
381
+// ParseTextDate accepts integer- and string-encoded SNMP date shapes (epoch
382
+// seconds, milliseconds, decimal no-value sentinels such as 0 and 4294967295,
383
+// and the textual layouts above) and returns the equivalent unix timestamp. It
384
+// is exported so the value-processor format "text_date" in
385
+// ddsnmpcollector/utils.go can apply the same parsing rules.
386
+func ParseTextDate(raw string) (int64, bool) {
387
+ return parseTextDate(raw)
388
+}
389
+
390
+// IsTextDateNoValue reports whether raw is a vendor no-timestamp sentinel
391
+// accepted by ParseTextDate.
392
+func IsTextDateNoValue(raw string) bool {
393
+ return isTextDateNoValue(raw)
394
+}
395
+
396
+func isLicenseDateValueKind(kind string) bool {
397
+ switch kind {
398
+ case "expiry_timestamp", "authorization_timestamp", "certificate_timestamp", "grace_timestamp":
399
+ return true
400
+ default:
401
+ return false
402
+ }
403
+}
404
+
405
+func parseTextDate(raw string) (int64, bool) {
406
+ raw = strings.TrimSpace(raw)
407
+ if isTextDateNoValue(raw) {
408
+ return 0, false
409
+ }
410
+
411
+ if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
412
+ digits := strings.TrimLeft(raw, "+-")
413
+ switch {
414
+ case len(digits) >= 12:
415
+ return n / 1000, true
416
+ default:
417
+ return n, true
418
+ }
419
+ }
420
+
421
+ for _, layout := range textDateLayouts {
422
+ if t, err := time.Parse(layout, raw); err == nil {
423
+ return t.Unix(), true
424
+ }
425
+ }
426
+ return 0, false
427
+}
428
+
429
+func isTextDateNoValue(raw string) bool {
430
+ raw = strings.TrimSpace(raw)
431
+ if raw == "" {
432
+ return true
433
+ }
434
+
435
+ switch strings.ToLower(raw) {
436
+ case "0", "none", "n/a", "na", "perpetual", "permanent", "never", "unlimited":
437
+ return true
438
+ }
439
+
440
+ if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
441
+ return n <= 0 || n == 4_294_967_295
442
+ }
443
+
444
+ return false
445
+}
src/go/plugin/go.d/collector/snmp/ddsnmp/transform_license_test.go
new
+131
@@ -0,0 +1,131 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package ddsnmp
4
+
5
+import (
6
+ "bytes"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+)
12
+
13
+// runLicenseTransform compiles a transform body and applies it to the given
14
+// metric. It mirrors the minimal "execute the template against {Metric: m}"
15
+// contract used by ddsnmpcollector.applyTransform, without crossing package
16
+// boundaries just for the test.
17
+func runLicenseTransform(t *testing.T, body string, m *Metric) {
18
+ t.Helper()
19
+ require.NoError(t, executeLicenseTransform(body, m))
20
+}
21
+
22
+func executeLicenseTransform(body string, m *Metric) error {
23
+ tmpl, err := compileTransform(body)
24
+ if err != nil {
25
+ return err
26
+ }
27
+ var buf bytes.Buffer
28
+ return tmpl.Execute(&buf, struct{ Metric *Metric }{Metric: m})
29
+}
30
+
31
+func TestSetTagTransform_StampsValueKindOnTagsMap(t *testing.T) {
32
+ m := &Metric{Value: 42, Tags: map[string]string{}}
33
+ runLicenseTransform(t, `{{- setTag .Metric "_license_value_kind" "expiry_timestamp" -}}`, m)
34
+
35
+ assert.Equal(t, "expiry_timestamp", m.Tags["_license_value_kind"])
36
+ assert.EqualValues(t, 42, m.Value)
37
+}
38
+
39
+func TestSetTagTransform_AllocatesTagsWhenNil(t *testing.T) {
40
+ m := &Metric{Value: 1}
41
+ runLicenseTransform(t, `{{- setTag .Metric "_license_value_kind" "state_severity" -}}`, m)
42
+
43
+ require.NotNil(t, m.Tags)
44
+ assert.Equal(t, "state_severity", m.Tags["_license_value_kind"])
45
+}
46
+
47
+func TestLicenseDateFromTagTransform_ParsesISODate(t *testing.T) {
48
+ m := &Metric{
49
+ Value: 0,
50
+ Tags: map[string]string{"_license_expiry_text": "2026-12-31"},
51
+ }
52
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "_license_expiry_text" "expiry_timestamp" -}}`, m)
53
+
54
+ assert.Equal(t, "expiry_timestamp", m.Tags["_license_value_kind"])
55
+ // 2026-12-31 00:00:00 UTC
56
+ assert.EqualValues(t, 1798675200, m.Value)
57
+}
58
+
59
+func TestLicenseDateFromTagTransform_ParsesEpochSeconds(t *testing.T) {
60
+ m := &Metric{Value: 0, Tags: map[string]string{"x": "1798675200"}}
61
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
62
+ assert.EqualValues(t, 1798675200, m.Value)
63
+}
64
+
65
+func TestLicenseDateFromTagTransform_ParsesEpochMillis(t *testing.T) {
66
+ m := &Metric{Value: 0, Tags: map[string]string{"x": "1798675200000"}}
67
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
68
+ assert.EqualValues(t, 1798675200, m.Value)
69
+}
70
+
71
+func TestLicenseDateFromTagTransform_ParsesTwelveDigitEpochMillis(t *testing.T) {
72
+ m := &Metric{Value: 0, Tags: map[string]string{"x": "946684800000"}}
73
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
74
+ assert.EqualValues(t, 946684800, m.Value)
75
+}
76
+
77
+func TestLicenseDateFromTagTransform_ParsesCheckpointShortDate(t *testing.T) {
78
+ // Checkpoint sends licensingExpirationDate as "2Jan2030", "1Jan2030", etc.
79
+ m := &Metric{Value: 0, Tags: map[string]string{"x": "1Jan2030"}}
80
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
81
+ assert.NotZero(t, m.Value)
82
+}
83
+
84
+func TestLicenseDateFromTagTransform_RejectsAmbiguousSlashDate(t *testing.T) {
85
+ m := &Metric{Value: 999, Tags: map[string]string{"x": "01/02/2024"}}
86
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
87
+
88
+ assert.Empty(t, m.Tags["_license_value_kind"])
89
+ assert.EqualValues(t, 999, m.Value)
90
+}
91
+
92
+func TestLicenseDateFromTagTransform_RejectsSentinels(t *testing.T) {
93
+ cases := []string{"0", "never", "perpetual", "n/a", "4294967295", ""}
94
+ for _, raw := range cases {
95
+ m := &Metric{Value: 999, Tags: map[string]string{"x": raw}}
96
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
97
+ // Untouched: no value_kind stamp, original value preserved.
98
+ assert.Empty(t, m.Tags["_license_value_kind"], "raw=%q", raw)
99
+ assert.EqualValues(t, 999, m.Value, "raw=%q", raw)
100
+ }
101
+}
102
+
103
+func TestLicenseDateFromTagTransform_RejectsUnsupportedKind(t *testing.T) {
104
+ for _, kind := range []string{"usage", "expiry_remaining", "not_a_kind"} {
105
+ m := &Metric{Value: 999, Tags: map[string]string{"x": "2026-12-31"}}
106
+ err := executeLicenseTransform(`{{- licenseDateFromTag .Metric "x" "`+kind+`" -}}`, m)
107
+
108
+ require.Error(t, err, "kind=%q", kind)
109
+ assert.Contains(t, err.Error(), `licenseDateFromTag: unsupported value kind`, "kind=%q", kind)
110
+ assert.Empty(t, m.Tags["_license_value_kind"], "kind=%q", kind)
111
+ assert.EqualValues(t, 999, m.Value, "kind=%q", kind)
112
+ }
113
+}
114
+
115
+func TestIsTextDateNoValue(t *testing.T) {
116
+ noValues := []string{"", "0", "-1", "never", "perpetual", "permanent", "n/a", "na", "none", "unlimited", "4294967295"}
117
+ for _, raw := range noValues {
118
+ assert.True(t, IsTextDateNoValue(raw), "raw=%q", raw)
119
+ }
120
+
121
+ values := []string{"1", "1798675200", "2026-12-31", "not-a-date"}
122
+ for _, raw := range values {
123
+ assert.False(t, IsTextDateNoValue(raw), "raw=%q", raw)
124
+ }
125
+}
126
+
127
+func TestLicenseDateFromTagTransform_NoTagsMapIsNoop(t *testing.T) {
128
+ m := &Metric{Value: 7}
129
+ runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
130
+ assert.EqualValues(t, 7, m.Value)
131
+}
src/go/plugin/go.d/collector/snmp/profile-format.md
+120
-54
@@ -349,7 +349,7 @@ See also
349
350
#### Underscore-prefixed metrics
351
352
-Metric names that start with an underscore (e.g., `_ifHCInOctets`) are **private**: they’re collected but **not** propagated to the SNMP collector output. Use them as internal building blocks (typically as inputs for [virtual_metrics](#7-virtual_metrics)) so the final metric set remains clean. After virtual metrics are computed, the collector drops underscored metrics from the exported set.
352
+Metric names that start with an underscore (e.g., `_ifHCInOctets`) are **private**: they’re collected but **not** propagated to the SNMP collector output. Use them as internal building blocks (typically as inputs for [virtual_metrics](#7-virtual_metrics)) so the final metric set remains clean. After virtual metrics are computed, the collector drops underscored metrics from the exported set, while preserving them in the internal hidden metric set for collector-level consumers.
353
354
```yaml
355
# IF-MIB::ifXTable
@@ -757,17 +757,16 @@ They let you distinguish between instances (for example, which interface, disk,
757
- Attaches tags to each metric as labels.
758
- Uses tags to differentiate rows when building charts.
759
- Requires at least one tag for every **table metric** (to identify each row).
760
-- Ignores tags for **scalar metrics**, which represent a single value per device.
760
761
**Key Concepts**:
762
764
-| Concept | Description |
765
-|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
766
-| **Table metrics must have tags** | Each table row must be uniquely identified by at least one tag (for example, interface name or index). Without tags, only one row is emitted. |
767
-| **Scalar metrics don’t need tags** | Scalars represent one value for the entire device, not per-instance data. |
768
-| **Static tags** | Fixed values that never change (for example, datacenter, environment). |
769
-| **Dynamic tags** | Extracted from SNMP data — from table columns, related tables, or row indexes. |
770
-| **Global tags** | Defined in the profile’s top-level `metric_tags` section and applied to all metrics. |
763
+| Concept | Description |
764
+|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
765
+| **Table metrics must have tags** | Each table row must be uniquely identified by at least one tag (for example, interface name or index). Without tags, only one row is emitted. |
766
+| **Scalar metrics don’t need tags for identity** | Scalars represent one value for the entire device, so tags are not part of their normal public identity contract. |
767
+| **Static tags** | Fixed values that never change (for example, datacenter, environment). |
768
+| **Dynamic tags** | Extracted from SNMP data — from table columns, related tables, or row indexes. |
769
+| **Global tags** | Defined in the profile’s top-level `metric_tags` section and applied to all metrics. |
770
771
**Tag Types and Available Transformations**:
772
@@ -783,9 +782,15 @@ They let you distinguish between instances (for example, which interface, disk,
782
783
- Each **table metric** must define at least one **tag source** (`metric_tags`) to distinguish rows.
784
- Tags can come from **the same table**, **another table**, or the **row index** itself.
785
+- Scalar metrics do not use tags for identity. Internal bundled profiles may
786
+ attach sidecar scalar values to hidden carrier metrics, but that is not a
787
+ stable public authoring contract.
788
- Tag transformations (`mapping`, `extract_value`, `match_pattern`, `match + tags`) can modify or extract parts of raw values.
789
- **Static tags** apply globally and are not transformed.
790
- **Index transformations** are a special mechanism used only for aligning multi-part indexes between tables.
791
+- Avoid reusing built-in device label names such as `sysName`, `address`,
792
+ `vendor`, `model`, and `device_type` for row tags. Those keys are reserved
793
+ for collector-provided device metadata labels.
794
795
**How the Collector Matches Values and Tags**:
796
@@ -1444,9 +1449,15 @@ metric_tags:
1449
1450
## Value Transformation
1451
1447
-Value transformations let you **process or normalize raw SNMP metric values** before they are stored and charted.
1452
+Value transformations let you **decode, process, or normalize raw SNMP symbol values** before they are stored and charted.
1453
1449
-They are applied **per symbol (per OID)** during SNMP data collection. They modify only **metric values**, not tags or metadata, and are **not applied to virtual metrics**.
1454
+For metrics, they are applied **per symbol (per OID)** during SNMP data
1455
+collection and are **not applied to virtual metrics**.
1456
+
1457
+`format` is the exception to the "metric values only" rule: it is symbol
1458
+decoding, so it also applies when the same symbol is used for metric tags or
1459
+device metadata. After decoding, metric tags and device metadata follow their
1460
+own supported transformation rules.
1461
1462
These transformations are typically used to:
1463
@@ -1456,25 +1467,26 @@ These transformations are typically used to:
1467
1468
**Available Value Transformations**:
1469
1459
-| Transformation | Purpose | Example Input → Output |
1460
-|---------------------------------|-------------------------------------------------------------------|-------------------------------------|
1461
-| `mapping` | Convert numeric or string codes into state dimensions. | `1 → up`, `2 → down`, `3 → testing` |
1462
-| `extract_value` | Extract a numeric substring via regex. | `"23.8 °C" → "23"` |
1463
-| `scale_factor` | Multiply values by a constant to adjust units. | `"1.5" (MBps) × 8 → 12 (Mbps)` |
1464
-| `match_pattern` + `match_value` | *Not applicable* for metric values (use `extract_value` instead). | — |
1470
+| Transformation | Purpose | Example Input → Output |
1471
+|---------------------------------|----------------------------------------------------------------------------------------|-------------------------------------|
1472
+| `mapping` | Convert numeric or string codes into state dimensions. | `1 → up`, `2 → down`, `3 → testing` |
1473
+| `extract_value` | Extract a numeric substring via regex. | `"23.8 °C" → "23"` |
1474
+| `format` | Decode raw SNMP data into a value shape before other processing. | DateAndTime bytes → unix timestamp |
1475
+| `scale_factor` | Multiply values by a constant to adjust units. | `"1.5" (MBps) × 8 → 12 (Mbps)` |
1476
+| `match_pattern` + `match_value` | Replace string metric values using regex groups or static text before numeric parsing. | `"state=2" → "2"` |
1477
1478
**Combination & Behavior**:
1479
1468
-| Rule | Description |
1469
-|---------------------------|---------------------------------------------------------------------------------------------------------------------------|
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. |
1477
-| **Mapping behavior** | Always produces a multi-value metric where each mapped entry becomes a dimension; the active one reports `1`, others `0`. |
1480
+| Rule | Description |
1481
+|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1482
+| **Where** | Metric value transformations are used inside `metrics[*].symbol` or `metrics[*].symbols[]`; `format` also applies when symbols are used for metric tags or device metadata. |
1483
+| **Order of application** | For string-decoded metric values: 1️⃣ `format` (if present) → 2️⃣ `extract_value` (if present) → 3️⃣ `match_pattern` + `match_value` (if present) → 4️⃣ `mapping` → 5️⃣ numeric parsing → 6️⃣ `scale_factor`. Ordinary numeric PDUs skip the string-only `extract_value` and `match_pattern` steps and use numeric parsing → `mapping` → `scale_factor`. |
1484
+| **Scale factor position** | `scale_factor` is always applied **last**, after all other metric value transformations. |
1485
+| **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. |
1486
+| **Data type handling** | Transformations preserve numeric type (integer/float) unless the mapping converts it to a multi-value metric. |
1487
+| **Error handling** | `extract_value` keeps the original value when it does not match; `match_pattern` fails the metric value when it does not match; no-value `format` sentinels are treated as missing. |
1488
+| **Applicability** | Metric value transformations affect metric values only; `format` also decodes tag and metadata symbol values. |
1489
+| **Mapping behavior** | Always produces a multi-value metric where each mapped entry becomes a dimension; the active one reports `1`, others `0`. |
1490
1491
**Quick Syntax Recap**:
1492
@@ -1497,6 +1509,16 @@ These transformations are typically used to:
1509
extract_value: '^([0-9a-f]{2})' # First byte of an OCTET STRING
1510
```
1511
1512
+- `format: snmp_dateandtime`
1513
+ ```yaml
1514
+ format: snmp_dateandtime # SNMPv2-TC DateAndTime OCTET STRING -> unix timestamp
1515
+ ```
1516
+
1517
+- `format: text_date`
1518
+ ```yaml
1519
+ format: text_date # Textual dates such as "2026-12-31" -> unix timestamp
1520
+ ```
1521
+
1522
- `scale_factor`
1523
```yaml
1524
scale_factor: 8 # Octets → bits
@@ -1574,6 +1596,50 @@ metrics:
1596
- Ideal for string metrics that embed numbers, units, or labels.
1597
- If `format: hex` is also set, the extracted value is interpreted as hexadecimal before being stored as a metric.
1598
1599
+### Format
1600
+
1601
+Use `format` to decode raw SNMP values as a symbol is converted into its
1602
+textual or numeric representation.
1603
+
1604
+For metric values, `format` runs before the rest of the value-processing
1605
+pipeline. For metric tags and device metadata, `format` runs before their own
1606
+supported extraction, match, and mapping rules. `scale_factor` remains
1607
+metric-value-only. Ordinary numeric PDUs do not pass through string-only
1608
+processing such as `extract_value` or `match_pattern` unless an explicit
1609
+string-decoding `format` routes them through the string processor first.
1610
+
1611
+If a format yields no value (for example, `text_date` encounters a vendor
1612
+sentinel such as `0`, `4294967295`, `never`, or `n/a`), the result is treated
1613
+as missing. For metrics, no metric value is produced from that symbol. For
1614
+metric tags and device metadata, no tag or metadata value is produced from that
1615
+symbol.
1616
+
1617
+Supported formats:
1618
+
1619
+- `hex`: decodes OCTET STRING bytes to lowercase hexadecimal text.
1620
+- `ip_address`: decodes IP address values.
1621
+- `mac_address`: decodes MAC address values.
1622
+- `snmp_dateandtime`: decodes SNMPv2-TC `DateAndTime` OCTET STRING values
1623
+ into unix timestamps. The 11-octet form uses its embedded UTC offset. The
1624
+ 8-octet form has no timezone fields, so the collector interprets it as UTC
1625
+ because the device timezone is unavailable.
1626
+- `text_date`: parses common textual date strings and epoch strings into
1627
+ unix timestamps. Vendor no-value sentinels such as `0`, `4294967295`,
1628
+ `never`, and `n/a` are treated as missing values.
1629
+- `uint32`: interprets integer values as unsigned 32-bit values.
1630
+
1631
+```yaml
1632
+metrics:
1633
+ - MIB: EXAMPLE-MIB
1634
+ symbol:
1635
+ OID: 1.3.6.1.4.1.99999.1.1.0
1636
+ name: example.expiry_timestamp
1637
+ format: snmp_dateandtime
1638
+```
1639
+
1640
+The decoded value becomes the metric value seen by later processing steps,
1641
+such as `extract_value`, `mapping`, `scale_factor`, or `transform`.
1642
+
1643
### Scale Factor
1644
1645
Use `scale_factor` to **multiply collected metric values** by a constant.
@@ -1670,40 +1736,40 @@ The collector evaluates alternatives **in order** and uses the **first** set tha
1736
1737
### Config reference
1738
1673
-| Item | Field | Type | Required | Default | Applies to | Description |
1674
-|--------------------|----------------|----------------------|----------|---------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1675
-| **Virtual Metric** | `name` | string | yes | — | all | Unique within the profile. Used as metric/chart base name. |
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. |
1739
+| Item | Field | Type | Required | Default | Applies to | Description |
1740
+|--------------------|----------------|----------------------|----------|---------|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1741
+| **Virtual Metric** | `name` | string | yes | — | all | Unique within the profile. Used as metric/chart base name. |
1742
+| | `sources` | array\<Source\> | no* | — | totals, per_row, grouped | Direct source set. Ignored if `alternatives` exist (alternatives take precedence). |
1743
+| | `alternatives` | array\<Alternative\> | no* | — | totals, per_row, grouped | Ordered fallback sets. The first alternative whose sources produce data is used. |
1744
+| | `per_row` | bool | no | false | per-row/grouped | When `true`, emits one output per input row; sources become dimensions; row tags attach. |
1745
| | `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). |
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. |
1746
+| | `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`. |
1747
+| | `chart_meta` | object | no | — | all | Presentation metadata (`description`, `family`, `unit`, `type`). |
1748
+| **Source** | `metric` | string | yes | — | — | Name of an existing metric (scalar or table column metric). |
1749
+| | `table` | string | no* | — | — | Table name for the originating metric. Required for table-derived grouped/per-row virtual metrics. Scalar sources may omit it. |
1750
+| | `as` | string | no | — | — | Optional dimension name within a composite (e.g., `in`, `out`). Single-source virtual metrics do not need it. |
1751
+| | `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. |
1752
+| **EmitTag** | `tag` | string | yes | — | — | Output tag name to emit on the virtual metric. |
1753
+| | `from` | string | yes | — | — | Existing source-tag name to copy from the grouped source rows. |
1754
+| **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. |
1755
1756
> At least one of `sources` or `alternatives` **must be defined**.
1757
1758
#### Rules & Constraints
1759
1694
-| Rule | Description |
1695
-|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
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. |
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 (...)`. |
1760
+| Rule | Description |
1761
+|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1762
+| **Precedence** | If both `sources` and `alternatives` exist, `alternatives` take precedence. |
1763
+| **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. |
1764
+| **per_row: true** | One output per input row; multiple sources become chart dimensions (`as`); row tags attach automatically. |
1765
+| **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. |
1766
+| **group_by (with per_row:false)** | Aggregates rows by the listed labels, similar to PromQL’s `sum by (...)`. |
1767
| **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). |
1768
+| **Alternative evaluation** | Alternatives are checked in order. The first whose sources produce data becomes the “winner”; others are ignored. |
1769
+| **Parent metadata** | The virtual metric emits charts using its own `name` and `chart_meta`, even when data comes from an alternative. |
1770
+| **Dimensions** | Each `as` value defines a dimension in the resulting chart (e.g., `in`, `out`, `total`). |
1771
+| **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. |
1772
+| **Totals vs per-row** | Omitting both `per_row` and `group_by` produces a single total chart across all rows (device-wide view). |
1773
1774
### Examples
1775
src/go/plugin/go.d/config/go.d/snmp.profiles/default/cyberpower-pdu.yaml
-4
@@ -186,10 +186,6 @@ metrics:
186
2: powerSupplyOneFailed
187
3: powerSupplyTwoFailed
188
4: powerSupplyOneandTwoFailed
189
- metric_tags:
190
- - symbol:
191
- OID:
192
- name: ePDUOutletBankIndex
189
- MIB: CPS-MIB
190
table:
191
OID: 1.3.6.1.4.1.3808.1.1.3.5.2