@cryptotaxi247 / netdata-1 / commits / d763e6f90

chore(go.d/snmp): add collection stats (#21409)

Ilya Mashchenko committed Dec 5, 2025 at 22:23 UTC d763e6f909eb1a25afbbd6dfc0b0cc9c4fc1dd5d
17 files changed +543 -72
src/go/plugin/go.d/collector/snmp/charts.go
+115
@@ -16,6 +16,12 @@ const (
16 prioProfileChart = module.Priority + iota
17 prioPingRtt
18 prioPingStdDev
19 +
20 + prioInternalStatsTimings
21 + prioInternalStatsSnmpOps
22 + prioInternalStatsMetrics
23 + prioInternalStatsTableCache
24 + prioInternalStatsErrors
25 )
26
27 var (
@@ -66,6 +72,115 @@ func (c *Collector) addPingCharts() {
72 }
73 }
74
75 +var (
76 + profileStatsChartsTmpl = module.Charts{
77 + profileStatsTimingsChartTmpl.Copy(),
78 + profileStatsSnmpChartTmpl.Copy(),
79 + profileStatsMetricsChartTmpl.Copy(),
80 + profileStatsTableCacheChartTmpl.Copy(),
81 + profileStatsErrorsChartTmpl.Copy(),
82 + }
83 +
84 + profileStatsTimingsChartTmpl = module.Chart{
85 + ID: "snmp_device_prof_%s_stats_timings",
86 + Title: "SNMP profile collection timings",
87 + Units: "milliseconds",
88 + Fam: "Internal/Stats",
89 + Ctx: "snmp.device_prof_stats_timings",
90 + Priority: prioInternalStatsTimings,
91 + Dims: module.Dims{
92 + {ID: "snmp_device_prof_%s_stats_timings_scalar", Name: "scalar"},
93 + {ID: "snmp_device_prof_%s_stats_timings_table", Name: "table"},
94 + {ID: "snmp_device_prof_%s_stats_timings_virtual", Name: "virtual"},
95 + },
96 + }
97 +
98 + profileStatsSnmpChartTmpl = module.Chart{
99 + ID: "snmp_device_prof_%s_stats_snmp",
100 + Title: "SNMP profile operations",
101 + Units: "operations",
102 + Fam: "Internal/Stats",
103 + Ctx: "snmp.device_prof_stats_snmp",
104 + Priority: prioInternalStatsSnmpOps,
105 + Dims: module.Dims{
106 + {ID: "snmp_device_prof_%s_stats_snmp_get_requests", Name: "get_requests"},
107 + {ID: "snmp_device_prof_%s_stats_snmp_get_oids", Name: "get_oids"},
108 + {ID: "snmp_device_prof_%s_stats_snmp_walk_requests", Name: "walk_requests"},
109 + {ID: "snmp_device_prof_%s_stats_snmp_walk_pdus", Name: "walk_pdus"},
110 + {ID: "snmp_device_prof_%s_stats_snmp_tables_walked", Name: "tables_walked"},
111 + {ID: "snmp_device_prof_%s_stats_snmp_tables_cached", Name: "tables_cached"},
112 + },
113 + }
114 +
115 + profileStatsMetricsChartTmpl = module.Chart{
116 + ID: "snmp_device_prof_%s_stats_metrics",
117 + Title: "SNMP profile metric counts",
118 + Units: "metrics",
119 + Fam: "Internal/Stats",
120 + Ctx: "snmp.device_prof_stats_metrics",
121 + Priority: prioInternalStatsMetrics,
122 + Dims: module.Dims{
123 + {ID: "snmp_device_prof_%s_stats_metrics_scalar", Name: "scalar"},
124 + {ID: "snmp_device_prof_%s_stats_metrics_table", Name: "table"},
125 + {ID: "snmp_device_prof_%s_stats_metrics_virtual", Name: "virtual"},
126 + {ID: "snmp_device_prof_%s_stats_metrics_tables", Name: "tables"},
127 + {ID: "snmp_device_prof_%s_stats_metrics_rows", Name: "rows"},
128 + },
129 + }
130 +
131 + profileStatsTableCacheChartTmpl = module.Chart{
132 + ID: "snmp_device_prof_%s_stats_table_cache",
133 + Title: "SNMP profile table cache",
134 + Units: "tables",
135 + Fam: "Internal/Stats",
136 + Ctx: "snmp.device_prof_stats_table_cache",
137 + Priority: prioInternalStatsTableCache,
138 + Dims: module.Dims{
139 + {ID: "snmp_device_prof_%s_stats_table_cache_hits", Name: "hits"},
140 + {ID: "snmp_device_prof_%s_stats_table_cache_misses", Name: "misses"},
141 + },
142 + }
143 +
144 + profileStatsErrorsChartTmpl = module.Chart{
145 + ID: "snmp_device_prof_%s_stats_errors",
146 + Title: "SNMP profile errors",
147 + Units: "errors",
148 + Fam: "Internal/Stats",
149 + Ctx: "snmp.device_prof_stats_errors",
150 + Priority: prioInternalStatsErrors,
151 + Dims: module.Dims{
152 + {ID: "snmp_device_prof_%s_stats_errors_snmp", Name: "snmp"},
153 + {ID: "snmp_device_prof_%s_stats_errors_processing_scalar", Name: "processing_scalar"},
154 + {ID: "snmp_device_prof_%s_stats_errors_processing_table", Name: "processing_table"},
155 + },
156 + }
157 +)
158 +
159 +func (c *Collector) addProfileStatsCharts(name string) {
160 + if name == "" {
161 + return
162 + }
163 +
164 + charts := profileStatsChartsTmpl.Copy()
165 +
166 + labels := c.chartBaseLabels()
167 + labels["profile"] = name
168 +
169 + for _, chart := range *charts {
170 + chart.ID = fmt.Sprintf(chart.ID, name)
171 + for _, dim := range chart.Dims {
172 + dim.ID = fmt.Sprintf(dim.ID, name)
173 + }
174 + for k, v := range labels {
175 + chart.Labels = append(chart.Labels, module.Label{Key: k, Value: v})
176 + }
177 + }
178 +
179 + if err := c.Charts().Add(*charts...); err != nil {
180 + c.Warningf("failed to add profile stats charts for %s: %v", name, err)
181 + }
182 +}
183 +
184 func (c *Collector) addProfileScalarMetricChart(m ddsnmp.Metric) {
185 if m.Name == "" {
186 return
src/go/plugin/go.d/collector/snmp/collect_snmp.go
+38
@@ -4,6 +4,7 @@ package snmp
4
5 import (
6 "fmt"
7 + "path/filepath"
8 "sort"
9 "strings"
10
@@ -22,6 +23,7 @@ func (c *Collector) collectSNMP(mx map[string]int64) error {
23
24 c.collectProfileScalarMetrics(mx, pms)
25 c.collectProfileTableMetrics(mx, pms)
26 + c.collectProfileStats(mx, pms)
27
28 return nil
29 }
@@ -92,6 +94,38 @@ func (c *Collector) collectProfileTableMetrics(mx map[string]int64, pms []*ddsnm
94 }
95 }
96
97 +func (c *Collector) collectProfileStats(mx map[string]int64, pms []*ddsnmp.ProfileMetrics) {
98 + for _, pm := range pms {
99 + name := stripFileNameExt(pm.Source)
100 +
101 + if !c.seenProfiles[name] {
102 + c.seenProfiles[name] = true
103 + c.addProfileStatsCharts(name)
104 + }
105 +
106 + px := fmt.Sprintf("snmp_device_prof_%s_stats_", name)
107 + mx[px+"timings_scalar"] = pm.Stats.Timing.Scalar.Milliseconds()
108 + mx[px+"timings_table"] = pm.Stats.Timing.Table.Milliseconds()
109 + mx[px+"timings_virtual"] = pm.Stats.Timing.VirtualMetrics.Milliseconds()
110 + mx[px+"snmp_get_requests"] = pm.Stats.SNMP.GetRequests
111 + mx[px+"snmp_get_oids"] = pm.Stats.SNMP.GetOIDs
112 + mx[px+"snmp_walk_pdus"] = pm.Stats.SNMP.WalkPDUs
113 + mx[px+"snmp_walk_requests"] = pm.Stats.SNMP.WalkRequests
114 + mx[px+"snmp_tables_walked"] = pm.Stats.SNMP.TablesWalked
115 + mx[px+"snmp_tables_cached"] = pm.Stats.SNMP.TablesCached
116 + mx[px+"metrics_scalar"] = pm.Stats.Metrics.Scalar
117 + mx[px+"metrics_table"] = pm.Stats.Metrics.Table
118 + mx[px+"metrics_virtual"] = pm.Stats.Metrics.Virtual
119 + mx[px+"metrics_tables"] = pm.Stats.Metrics.Tables
120 + mx[px+"metrics_rows"] = pm.Stats.Metrics.Rows
121 + mx[px+"table_cache_hits"] = pm.Stats.TableCache.Hits
122 + mx[px+"table_cache_misses"] = pm.Stats.TableCache.Misses
123 + mx[px+"errors_snmp"] = pm.Stats.Errors.SNMP
124 + mx[px+"errors_processing_scalar"] = pm.Stats.Errors.Processing.Scalar
125 + mx[px+"errors_processing_table"] = pm.Stats.Errors.Processing.Table
126 + }
127 +}
128 +
129 func tableMetricKey(m ddsnmp.Metric) string {
130 if m.Name == "" {
131 return ""
@@ -124,3 +158,7 @@ func tableMetricKey(m ddsnmp.Metric) string {
158
159 return sb.String()
160 }
161 +
162 +func stripFileNameExt(path string) string {
163 + return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
164 +}
src/go/plugin/go.d/collector/snmp/collector.go
+2
@@ -67,6 +67,7 @@ func New() *Collector {
67 charts: &module.Charts{},
68 seenScalarMetrics: make(map[string]bool),
69 seenTableMetrics: make(map[string]bool),
70 + seenProfiles: make(map[string]bool),
71
72 newProber: ping.NewProber,
73 newSnmpClient: gosnmp.NewHandler,
@@ -86,6 +87,7 @@ type (
87 charts *module.Charts
88 seenScalarMetrics map[string]bool
89 seenTableMetrics map[string]bool
90 + seenProfiles map[string]bool
91
92 prober ping.Prober
93 newProber func(ping.ProberConfig, *logger.Logger) ping.Prober
src/go/plugin/go.d/collector/snmp/collector_test.go
+43 -3
@@ -226,6 +226,7 @@ func TestCollector_Collect(t *testing.T) {
226 collr.newDdSnmpColl = func(ddsnmpcollector.Config) ddCollector {
227 return &mockDdSnmpCollector{pms: []*ddsnmp.ProfileMetrics{
228 {
229 + Source: "test",
230 Metrics: []ddsnmp.Metric{
231 {
232 Name: "uptime",
@@ -243,7 +244,26 @@ func TestCollector_Collect(t *testing.T) {
244 },
245 want: map[string]int64{
246 // scalar → "snmp_device_prof_<name>"
246 - "snmp_device_prof_uptime": 123,
247 + "snmp_device_prof_test_stats_errors_processing_scalar": 0,
248 + "snmp_device_prof_test_stats_errors_processing_table": 0,
249 + "snmp_device_prof_test_stats_errors_snmp": 0,
250 + "snmp_device_prof_test_stats_metrics_rows": 0,
251 + "snmp_device_prof_test_stats_metrics_scalar": 0,
252 + "snmp_device_prof_test_stats_metrics_table": 0,
253 + "snmp_device_prof_test_stats_metrics_tables": 0,
254 + "snmp_device_prof_test_stats_metrics_virtual": 0,
255 + "snmp_device_prof_test_stats_snmp_get_oids": 0,
256 + "snmp_device_prof_test_stats_snmp_get_requests": 0,
257 + "snmp_device_prof_test_stats_snmp_tables_cached": 0,
258 + "snmp_device_prof_test_stats_snmp_tables_walked": 0,
259 + "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
260 + "snmp_device_prof_test_stats_snmp_walk_requests": 0,
261 + "snmp_device_prof_test_stats_table_cache_hits": 0,
262 + "snmp_device_prof_test_stats_table_cache_misses": 0,
263 + "snmp_device_prof_test_stats_timings_scalar": 0,
264 + "snmp_device_prof_test_stats_timings_table": 0,
265 + "snmp_device_prof_test_stats_timings_virtual": 0,
266 + "snmp_device_prof_uptime": 123,
267 },
268 },
269 "collects table multivalue metric": {
@@ -260,6 +280,7 @@ func TestCollector_Collect(t *testing.T) {
280 collr.newDdSnmpColl = func(ddsnmpcollector.Config) ddCollector {
281 return &mockDdSnmpCollector{pms: []*ddsnmp.ProfileMetrics{
282 {
283 + Source: "test",
284 Metrics: []ddsnmp.Metric{
285 {
286 Name: "if_octets",
@@ -281,8 +302,27 @@ func TestCollector_Collect(t *testing.T) {
302 want: map[string]int64{
303 // table key: "snmp_device_prof_<name>_<sorted tag values>_<subkey>"
304 // here tags = {"ifName":"eth0"} → key part becomes "_eth0"
284 - "snmp_device_prof_if_octets_eth0_in": 1,
285 - "snmp_device_prof_if_octets_eth0_out": 2,
305 + "snmp_device_prof_test_stats_errors_processing_scalar": 0,
306 + "snmp_device_prof_test_stats_errors_processing_table": 0,
307 + "snmp_device_prof_test_stats_errors_snmp": 0,
308 + "snmp_device_prof_test_stats_metrics_rows": 0,
309 + "snmp_device_prof_test_stats_metrics_scalar": 0,
310 + "snmp_device_prof_test_stats_metrics_table": 0,
311 + "snmp_device_prof_test_stats_metrics_tables": 0,
312 + "snmp_device_prof_test_stats_metrics_virtual": 0,
313 + "snmp_device_prof_test_stats_snmp_get_oids": 0,
314 + "snmp_device_prof_test_stats_snmp_get_requests": 0,
315 + "snmp_device_prof_test_stats_snmp_tables_cached": 0,
316 + "snmp_device_prof_test_stats_snmp_tables_walked": 0,
317 + "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
318 + "snmp_device_prof_test_stats_snmp_walk_requests": 0,
319 + "snmp_device_prof_test_stats_table_cache_hits": 0,
320 + "snmp_device_prof_test_stats_table_cache_misses": 0,
321 + "snmp_device_prof_test_stats_timings_scalar": 0,
322 + "snmp_device_prof_test_stats_timings_table": 0,
323 + "snmp_device_prof_test_stats_timings_virtual": 0,
324 + "snmp_device_prof_if_octets_eth0_in": 1,
325 + "snmp_device_prof_if_octets_eth0_out": 2,
326 },
327 },
328 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+33 -22
@@ -63,10 +63,12 @@ type (
63 vmetricsCollector *vmetricsCollector
64 }
65 profileState struct {
66 - profile *ddsnmp.Profile
67 - initialized bool
68 - globalTags map[string]string
69 - deviceMetadata map[string]ddsnmp.MetaTag
66 + profile *ddsnmp.Profile
67 + initialized bool
68 + cache struct {
69 + globalTags map[string]string
70 + deviceMetadata map[string]ddsnmp.MetaTag
71 + }
72 }
73 )
74
@@ -74,7 +76,7 @@ func (c *Collector) CollectDeviceMetadata() (map[string]ddsnmp.MetaTag, error) {
76 meta := make(map[string]ddsnmp.MetaTag)
77
78 for _, prof := range c.profiles {
77 - profDeviceMeta, err := c.deviceMetadataCollector.Collect(prof.profile)
79 + profDeviceMeta, err := c.deviceMetadataCollector.collect(prof.profile)
80 if err != nil {
81 return nil, err
82 }
@@ -91,7 +93,8 @@ func (c *Collector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
93 var metrics []*ddsnmp.ProfileMetrics
94 var errs []error
95
94 - if expired := c.tableCache.clearExpired(); len(expired) > 0 {
96 + expired := c.tableCache.clearExpired()
97 + if len(expired) > 0 {
98 c.log.Debugf("Cleared %d expired table cache entries", len(expired))
99 }
100
@@ -106,13 +109,16 @@ func (c *Collector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
109
110 metrics = append(metrics, pm)
111
109 - if vmetrics := c.vmetricsCollector.Collect(prof.profile.Definition, pm.Metrics); len(vmetrics) > 0 {
112 + now := time.Now()
113 + if vmetrics := c.vmetricsCollector.collect(prof.profile.Definition, pm.Metrics); len(vmetrics) > 0 {
114 for i := range vmetrics {
115 vmetrics[i].Profile = pm
116 }
117
118 pm.Metrics = slices.DeleteFunc(pm.Metrics, func(m ddsnmp.Metric) bool { return strings.HasPrefix(m.Name, "_") })
119 pm.Metrics = append(pm.Metrics, vmetrics...)
120 + pm.Stats.Metrics.Virtual += int64(len(vmetrics))
121 + pm.Stats.Timing.VirtualMetrics = time.Since(now)
122 }
123 }
124
@@ -142,42 +148,47 @@ func (c *Collector) SetSNMPClient(snmpClient gosnmp.Handler) {
148 }
149
150 func (c *Collector) collectProfile(ps *profileState) (*ddsnmp.ProfileMetrics, error) {
151 + pm := &ddsnmp.ProfileMetrics{
152 + Source: ps.profile.SourceFile,
153 + }
154 +
155 if !ps.initialized {
146 - globalTag, err := c.globalTagsCollector.Collect(ps.profile)
156 + globalTag, err := c.globalTagsCollector.collect(ps.profile)
157 if err != nil {
158 return nil, fmt.Errorf("failed to collect global tags: %w", err)
159 }
160 + ps.cache.globalTags = globalTag
161
151 - deviceMeta, err := c.deviceMetadataCollector.Collect(ps.profile)
162 + deviceMeta, err := c.deviceMetadataCollector.collect(ps.profile)
163 if err != nil {
164 return nil, fmt.Errorf("failed to collect device metadata: %w", err)
165 }
166 + ps.cache.deviceMetadata = deviceMeta
167
156 - ps.globalTags = globalTag
157 - ps.deviceMetadata = deviceMeta
168 ps.initialized = true
169 }
170
161 - var metrics []ddsnmp.Metric
171 + pm.Tags = maps.Clone(ps.cache.globalTags)
172 + pm.DeviceMetadata = maps.Clone(ps.cache.deviceMetadata)
173
163 - scalarMetrics, err := c.scalarCollector.Collect(ps.profile)
174 + now := time.Now()
175 + scalarMetrics, err := c.scalarCollector.collect(ps.profile, &pm.Stats)
176 if err != nil {
177 return nil, err
178 }
167 - metrics = append(metrics, scalarMetrics...)
179 + pm.Metrics = append(pm.Metrics, scalarMetrics...)
180 + pm.Stats.Timing.Scalar = time.Since(now)
181 + pm.Stats.Metrics.Scalar += int64(len(scalarMetrics))
182
169 - tableMetrics, err := c.tableCollector.Collect(ps.profile)
183 + now = time.Now()
184 + tableMetrics, err := c.tableCollector.collect(ps.profile, &pm.Stats)
185 if err != nil {
186 return nil, err
187 }
173 - metrics = append(metrics, tableMetrics...)
188 + pm.Metrics = append(pm.Metrics, tableMetrics...)
189 + pm.Stats.Timing.Table = time.Since(now)
190 + pm.Stats.Metrics.Table += int64(len(tableMetrics))
191
175 - pm := &ddsnmp.ProfileMetrics{
176 - Source: ps.profile.SourceFile,
177 - DeviceMetadata: maps.Clone(ps.deviceMetadata),
178 - Tags: maps.Clone(ps.globalTags),
179 - Metrics: metrics,
180 - }
192 for i := range pm.Metrics {
193 pm.Metrics[i].Profile = pm
194 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_device_meta.go
+1 -1
@@ -31,7 +31,7 @@ func newDeviceMetadataCollector(snmpClient gosnmp.Handler, missingOIDs map[strin
31 }
32 }
33
34 -func (dc *deviceMetadataCollector) Collect(prof *ddsnmp.Profile) (map[string]ddsnmp.MetaTag, error) {
34 +func (dc *deviceMetadataCollector) collect(prof *ddsnmp.Profile) (map[string]ddsnmp.MetaTag, error) {
35 if len(prof.Definition.Metadata) == 0 && len(prof.Definition.SysobjectIDMetadata) == 0 {
36 return nil, nil
37 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_device_meta_test.go
+1 -1
@@ -915,7 +915,7 @@ func TestDeviceMetadataCollector_Collect(t *testing.T) {
915 missingOIDs := make(map[string]bool)
916 collector := newDeviceMetadataCollector(mockHandler, missingOIDs, logger.New(), tc.sysobjectid)
917
918 - result, err := collector.Collect(tc.profile)
918 + result, err := collector.collect(tc.profile)
919
920 if tc.expectedError {
921 assert.Error(t, err)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_global_tags.go
+1 -1
@@ -32,7 +32,7 @@ func newGlobalTagsCollector(snmpClient gosnmp.Handler, missingOIDs map[string]bo
32 }
33
34 // Collect gathers all global tags from the profile
35 -func (gc *globalTagsCollector) Collect(prof *ddsnmp.Profile) (map[string]string, error) {
35 +func (gc *globalTagsCollector) collect(prof *ddsnmp.Profile) (map[string]string, error) {
36 if len(prof.Definition.MetricTags) == 0 && len(prof.Definition.StaticTags) == 0 {
37 return nil, nil
38 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_global_tags_test.go
+1 -1
@@ -389,7 +389,7 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
389 missingOIDs := make(map[string]bool)
390 collector := newGlobalTagsCollector(mockHandler, missingOIDs, logger.New())
391
392 - result, err := collector.Collect(tc.profile)
392 + result, err := collector.collect(tc.profile)
393
394 if tc.expectedError {
395 assert.Error(t, err)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar.go
+12 -5
@@ -32,23 +32,25 @@ func newScalarCollector(snmpClient gosnmp.Handler, missingOIDs map[string]bool,
32 }
33
34 // Collect gathers all scalar metrics from the profile
35 -func (sc *scalarCollector) Collect(prof *ddsnmp.Profile) ([]ddsnmp.Metric, error) {
35 +func (sc *scalarCollector) collect(prof *ddsnmp.Profile, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
36 oids, missingOIDs := sc.identifyScalarOIDs(prof.Definition.Metrics)
37
38 if len(missingOIDs) > 0 {
39 sc.log.Debugf("scalar metrics missing OIDs: %v", missingOIDs)
40 + stats.Errors.MissingOIDs += int64(len(missingOIDs))
41 }
42
43 if len(oids) == 0 {
44 return nil, nil
45 }
46
46 - pdus, err := sc.getScalarValues(oids)
47 + pdus, err := sc.getScalarValues(oids, stats)
48 if err != nil {
49 + stats.Errors.SNMP++
50 return nil, err
51 }
52
51 - return sc.processScalarMetrics(prof.Definition.Metrics, pdus)
53 + return sc.processScalarMetrics(prof.Definition.Metrics, pdus, stats)
54 }
55
56 // identifyScalarOIDs returns OIDs to collect and OIDs that are known to be missing
@@ -77,11 +79,14 @@ func (sc *scalarCollector) identifyScalarOIDs(configs []ddprofiledefinition.Metr
79 return oids, missingOIDs
80 }
81
80 -func (sc *scalarCollector) getScalarValues(oids []string) (map[string]gosnmp.SnmpPDU, error) {
82 +func (sc *scalarCollector) getScalarValues(oids []string, stats *ddsnmp.CollectionStats) (map[string]gosnmp.SnmpPDU, error) {
83 pdus := make(map[string]gosnmp.SnmpPDU)
84 maxOids := sc.snmpClient.MaxOids()
85
86 for chunk := range slices.Chunk(oids, maxOids) {
87 + stats.SNMP.GetOIDs += int64(len(chunk))
88 + stats.SNMP.GetRequests++
89 +
90 result, err := sc.snmpClient.Get(chunk)
91 if err != nil {
92 return nil, err
@@ -90,6 +95,7 @@ func (sc *scalarCollector) getScalarValues(oids []string) (map[string]gosnmp.Snm
95 for _, pdu := range result.Variables {
96 if !isPduWithData(pdu) {
97 sc.missingOIDs[trimOID(pdu.Name)] = true
98 + stats.Errors.MissingOIDs++
99 continue
100 }
101 pdus[trimOID(pdu.Name)] = pdu
@@ -100,7 +106,7 @@ func (sc *scalarCollector) getScalarValues(oids []string) (map[string]gosnmp.Snm
106 }
107
108 // processScalarMetrics converts PDUs into metrics
103 -func (sc *scalarCollector) processScalarMetrics(configs []ddprofiledefinition.MetricsConfig, pdus map[string]gosnmp.SnmpPDU) ([]ddsnmp.Metric, error) {
109 +func (sc *scalarCollector) processScalarMetrics(configs []ddprofiledefinition.MetricsConfig, pdus map[string]gosnmp.SnmpPDU, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
110 var metrics []ddsnmp.Metric
111 var errs []error
112
@@ -113,6 +119,7 @@ func (sc *scalarCollector) processScalarMetrics(configs []ddprofiledefinition.Me
119 if err != nil {
120 errs = append(errs, fmt.Errorf("metric '%s': %w", cfg.Symbol.Name, err))
121 sc.log.Debugf("Error processing scalar metric '%s': %v", cfg.Symbol.Name, err)
122 + stats.Errors.Processing.Scalar++
123 continue
124 }
125
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar_test.go
+2 -1
@@ -709,7 +709,8 @@ func TestScalarCollector_Collect(t *testing.T) {
709 missingOIDs := make(map[string]bool)
710 collector := newScalarCollector(mockHandler, missingOIDs, logger.New())
711
712 - result, err := collector.Collect(tc.profile)
712 + var stats ddsnmp.CollectionStats
713 + result, err := collector.collect(tc.profile, &stats)
714
715 if tc.expectedError {
716 assert.Error(t, err)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go
+59 -30
@@ -40,13 +40,13 @@ func newTableCollector(snmpClient gosnmp.Handler, missingOIDs map[string]bool, t
40 }
41
42 // Collect gathers all table metrics from the profile
43 -func (tc *tableCollector) Collect(prof *ddsnmp.Profile) ([]ddsnmp.Metric, error) {
44 - walkResults, err := tc.walkTablesAsNeeded(prof)
43 +func (tc *tableCollector) collect(prof *ddsnmp.Profile, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
44 + walkResults, err := tc.walkTablesAsNeeded(prof, stats)
45 if err != nil {
46 return nil, err
47 }
48
49 - return tc.processWalkResults(walkResults)
49 + return tc.processWalkResults(walkResults, stats)
50 }
51
52 // tableWalkResult holds the walked data for a single table
@@ -159,10 +159,10 @@ type cacheProcessingContext struct {
159 }
160
161 // walkTablesAsNeeded walks only tables that aren't fully cached
162 -func (tc *tableCollector) walkTablesAsNeeded(prof *ddsnmp.Profile) ([]tableWalkResult, error) {
163 - toWalk := tc.identifyTablesToWalk(prof)
162 +func (tc *tableCollector) walkTablesAsNeeded(prof *ddsnmp.Profile, stats *ddsnmp.CollectionStats) ([]tableWalkResult, error) {
163 + toWalk := tc.identifyTablesToWalk(prof, stats)
164
165 - walkedData, errs := tc.walkTables(toWalk.tablesToWalk)
165 + walkedData, errs := tc.walkTables(toWalk.tablesToWalk, stats)
166
167 results := tc.buildWalkResults(walkedData, toWalk)
168
@@ -181,7 +181,7 @@ type tablesToWalkInfo struct {
181 }
182
183 // identifyTablesToWalk determines which tables need to be walked
184 -func (tc *tableCollector) identifyTablesToWalk(prof *ddsnmp.Profile) *tablesToWalkInfo {
184 +func (tc *tableCollector) identifyTablesToWalk(prof *ddsnmp.Profile, stats *ddsnmp.CollectionStats) *tablesToWalkInfo {
185 info := &tablesToWalkInfo{
186 tablesToWalk: make(map[string]bool),
187 tableConfigs: make(map[string][]ddprofiledefinition.MetricsConfig),
@@ -194,6 +194,7 @@ func (tc *tableCollector) identifyTablesToWalk(prof *ddsnmp.Profile) *tablesToWa
194
195 tableOID := cfg.Table.OID
196 if tc.missingOIDs[trimOID(tableOID)] {
197 + stats.Errors.MissingOIDs++
198 info.missingOIDs = append(info.missingOIDs, tableOID)
199 continue
200 }
@@ -202,6 +203,9 @@ func (tc *tableCollector) identifyTablesToWalk(prof *ddsnmp.Profile) *tablesToWa
203
204 if !tc.tableCache.isConfigCached(cfg) {
205 info.tablesToWalk[tableOID] = true
206 + stats.TableCache.Misses++
207 + } else {
208 + stats.TableCache.Hits++
209 }
210 }
211
@@ -216,18 +220,20 @@ func (tc *tableCollector) identifyTablesToWalk(prof *ddsnmp.Profile) *tablesToWa
220 }
221
222 // walkTables performs SNMP walks for the specified tables
219 -func (tc *tableCollector) walkTables(tablesToWalk map[string]bool) (map[string]map[string]gosnmp.SnmpPDU, []error) {
223 +func (tc *tableCollector) walkTables(tablesToWalk map[string]bool, stats *ddsnmp.CollectionStats) (map[string]map[string]gosnmp.SnmpPDU, []error) {
224 walkedData := make(map[string]map[string]gosnmp.SnmpPDU)
225 var errs []error
226
227 for tableOID := range tablesToWalk {
224 - pdus, err := tc.snmpWalk(tableOID)
228 + pdus, err := tc.snmpWalk(tableOID, stats)
229 if err != nil {
230 + stats.Errors.SNMP++
231 errs = append(errs, fmt.Errorf("failed to walk table OID '%s': %w", tableOID, err))
232 continue
233 }
234
235 if len(pdus) > 0 {
236 + stats.SNMP.TablesWalked++
237 walkedData[tableOID] = pdus
238 }
239 }
@@ -263,7 +269,7 @@ func (tc *tableCollector) buildWalkResults(walkedData map[string]map[string]gosn
269 }
270
271 // processWalkResults processes all table walk results
266 -func (tc *tableCollector) processWalkResults(walkResults []tableWalkResult) ([]ddsnmp.Metric, error) {
272 +func (tc *tableCollector) processWalkResults(walkResults []tableWalkResult, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
273 // Build lookup maps
274 walkedData := tc.buildWalkedDataMap(walkResults)
275 tableNameToOID := tc.buildTableNameMap(walkResults)
@@ -271,14 +277,19 @@ func (tc *tableCollector) processWalkResults(walkResults []tableWalkResult) ([]d
277 var metrics []ddsnmp.Metric
278 var errs []error
279
280 + tablesSeen := make(map[string]bool)
281 +
282 for _, result := range walkResults {
275 - tableMetrics, err := tc.processTableResult(result, walkedData, tableNameToOID)
283 + tableMetrics, err := tc.processTableResult(result, walkedData, tableNameToOID, stats)
284 if err != nil {
285 + stats.Errors.Processing.Table++
286 errs = append(errs, fmt.Errorf("table '%s': %w", result.config.Table.Name, err))
287 continue
288 }
289 metrics = append(metrics, tableMetrics...)
290 + tablesSeen[result.tableOID] = true
291 }
292 + stats.Metrics.Tables = int64(len(tablesSeen))
293
294 if len(metrics) == 0 && len(errs) > 0 {
295 return nil, errors.Join(errs...)
@@ -310,9 +321,10 @@ func (tc *tableCollector) buildTableNameMap(walkResults []tableWalkResult) map[s
321 }
322
323 // processTableResult processes a single table result
313 -func (tc *tableCollector) processTableResult(result tableWalkResult, walkedData map[string]map[string]gosnmp.SnmpPDU, tableNameToOID map[string]string) ([]ddsnmp.Metric, error) {
324 +func (tc *tableCollector) processTableResult(result tableWalkResult, walkedData map[string]map[string]gosnmp.SnmpPDU, tableNameToOID map[string]string, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
325 // Try cache first
315 - if metrics := tc.tryCollectFromCache(result.config); metrics != nil {
326 + if metrics := tc.tryCollectFromCache(result.config, stats); metrics != nil {
327 + stats.SNMP.TablesCached++
328 return metrics, nil
329 }
330
@@ -324,14 +336,16 @@ func (tc *tableCollector) processTableResult(result tableWalkResult, walkedData
336 walkedData: walkedData,
337 tableNameToOID: tableNameToOID,
338 }
327 - return tc.processTableData(ctx)
339 + metrics, err := tc.processTableData(ctx, stats)
340 + stats.Metrics.Rows += int64(len(ctx.rows))
341 + return metrics, err
342 }
343
344 return nil, nil
345 }
346
347 // tryCollectFromCache attempts to collect metrics using cached data
334 -func (tc *tableCollector) tryCollectFromCache(cfg ddprofiledefinition.MetricsConfig) []ddsnmp.Metric {
348 +func (tc *tableCollector) tryCollectFromCache(cfg ddprofiledefinition.MetricsConfig, stats *ddsnmp.CollectionStats) []ddsnmp.Metric {
349 cachedOIDs, cachedTags, ok := tc.tableCache.getCachedData(cfg)
350 if !ok {
351 return nil
@@ -347,18 +361,19 @@ func (tc *tableCollector) tryCollectFromCache(cfg ddprofiledefinition.MetricsCon
361 tableName: cfg.Table.Name,
362 }
363
350 - metrics, err := tc.collectWithCache(ctx)
351 - if err == nil {
352 - tc.log.Debugf("Successfully collected table %s using cache", cfg.Table.Name)
353 - return metrics
364 + metrics, err := tc.collectWithCache(ctx, stats)
365 + if err != nil {
366 + tc.log.Debugf("Cached collection failed for table %s: %v", cfg.Table.Name, err)
367 + return nil
368 }
369
356 - tc.log.Debugf("Cached collection failed for table %s: %v", cfg.Table.Name, err)
357 - return nil
370 + stats.Metrics.Rows += int64(len(cachedOIDs))
371 + tc.log.Debugf("Successfully collected table %s using cache", cfg.Table.Name)
372 + return metrics
373 }
374
375 // processTableData processes walked table data
361 -func (tc *tableCollector) processTableData(ctx *tableProcessingContext) ([]ddsnmp.Metric, error) {
376 +func (tc *tableCollector) processTableData(ctx *tableProcessingContext, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
377 ctx.columnOIDs = buildColumnOIDs(ctx.config)
378
379 ctx.orderedTags = buildOrderedTags(ctx.config)
@@ -367,7 +382,7 @@ func (tc *tableCollector) processTableData(ctx *tableProcessingContext) ([]ddsnm
382
383 ctx.staticTags = parseStaticTags(ctx.config.StaticTags)
384
370 - metrics, err := tc.processRows(ctx)
385 + metrics, err := tc.processRows(ctx, stats)
386
387 // Cache the processed data
388 deps := extractTableDependencies(ctx.config, ctx.tableNameToOID)
@@ -417,7 +432,7 @@ func (tc *tableCollector) organizePDUsByRow(ctx *tableProcessingContext) (rows m
432 }
433
434 // processRows processes all rows and returns metrics
420 -func (tc *tableCollector) processRows(ctx *tableProcessingContext) ([]ddsnmp.Metric, error) {
435 +func (tc *tableCollector) processRows(ctx *tableProcessingContext, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
436 var metrics []ddsnmp.Metric
437 var errs []error
438
@@ -444,6 +459,7 @@ func (tc *tableCollector) processRows(ctx *tableProcessingContext) ([]ddsnmp.Met
459 }
460 rowMetrics, err := tc.rowProcessor.processRow(row, rowCtx)
461 if err != nil {
462 + stats.Errors.Processing.Table++
463 errs = append(errs, err)
464 continue
465 }
@@ -464,7 +480,7 @@ func (tc *tableCollector) processRows(ctx *tableProcessingContext) ([]ddsnmp.Met
480 }
481
482 // collectWithCache collects metrics using cached structure
467 -func (tc *tableCollector) collectWithCache(ctx *cacheProcessingContext) ([]ddsnmp.Metric, error) {
483 +func (tc *tableCollector) collectWithCache(ctx *cacheProcessingContext, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
484 // Build list of OIDs to GET
485 var oidsToGet []string
486 for _, columns := range ctx.cachedOIDs {
@@ -480,7 +496,7 @@ func (tc *tableCollector) collectWithCache(ctx *cacheProcessingContext) ([]ddsnm
496 }
497
498 // GET current values
483 - pdus, err := tc.snmpGet(oidsToGet)
499 + pdus, err := tc.snmpGet(oidsToGet, stats)
500 if err != nil {
501 return nil, fmt.Errorf("failed to get cached OIDs: %w", err)
502 }
@@ -492,11 +508,11 @@ func (tc *tableCollector) collectWithCache(ctx *cacheProcessingContext) ([]ddsnm
508
509 // Add PDUs to context and build metrics
510 ctx.pdus = pdus
495 - return tc.buildMetricsFromCache(ctx)
511 + return tc.buildMetricsFromCache(ctx, stats)
512 }
513
514 // buildMetricsFromCache builds metrics from cached structure and current values
499 -func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext) ([]ddsnmp.Metric, error) {
515 +func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
516 staticTags := parseStaticTags(ctx.config.StaticTags)
517 var metrics []ddsnmp.Metric
518 var errs []error
@@ -525,12 +541,14 @@ func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext) ([]
541
542 value, err := tc.valProc.processValue(sym, pdu)
543 if err != nil {
544 + stats.Errors.Processing.Table++
545 tc.log.Debugf("Error processing value for %s: %v", sym.Name, err)
546 continue
547 }
548
549 metric, err := buildTableMetric(sym, pdu, value, rowTags, staticTags, ctx.tableName)
550 if err != nil {
551 + stats.Errors.Processing.Table++
552 errs = append(errs, err)
553 continue
554 }
@@ -548,12 +566,14 @@ func (tc *tableCollector) buildMetricsFromCache(ctx *cacheProcessingContext) ([]
566
567 // SNMP operations
568
551 -func (tc *tableCollector) snmpWalk(oid string) (map[string]gosnmp.SnmpPDU, error) {
569 +func (tc *tableCollector) snmpWalk(oid string, stats *ddsnmp.CollectionStats) (map[string]gosnmp.SnmpPDU, error) {
570 pdus := make(map[string]gosnmp.SnmpPDU)
571
572 var resp []gosnmp.SnmpPDU
573 var err error
574
575 + stats.SNMP.WalkRequests++
576 +
577 if tc.snmpClient.Version() == gosnmp.Version1 || tc.disableBulkWalk {
578 resp, err = tc.snmpClient.WalkAll(oid)
579 } else {
@@ -563,9 +583,13 @@ func (tc *tableCollector) snmpWalk(oid string) (map[string]gosnmp.SnmpPDU, error
583 return nil, err
584 }
585
586 + stats.SNMP.WalkPDUs += int64(len(resp))
587 +
588 for _, pdu := range resp {
589 if isPduWithData(pdu) {
590 pdus[trimOID(pdu.Name)] = pdu
591 + } else {
592 + stats.Errors.MissingOIDs++
593 }
594 }
595
@@ -576,17 +600,22 @@ func (tc *tableCollector) snmpWalk(oid string) (map[string]gosnmp.SnmpPDU, error
600 return pdus, nil
601 }
602
579 -func (tc *tableCollector) snmpGet(oids []string) (map[string]gosnmp.SnmpPDU, error) {
603 +func (tc *tableCollector) snmpGet(oids []string, stats *ddsnmp.CollectionStats) (map[string]gosnmp.SnmpPDU, error) {
604 pdus := make(map[string]gosnmp.SnmpPDU)
605
606 for chunk := range slices.Chunk(oids, tc.snmpClient.MaxOids()) {
607 + stats.SNMP.GetRequests++
608 + stats.SNMP.GetOIDs += int64(len(chunk))
609 +
610 result, err := tc.snmpClient.Get(chunk)
611 if err != nil {
612 + stats.Errors.SNMP++
613 return nil, err
614 }
615
616 for _, pdu := range result.Variables {
617 if !isPduWithData(pdu) {
618 + stats.Errors.MissingOIDs++
619 tc.missingOIDs[trimOID(pdu.Name)] = true
620 continue
621 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table_test.go
+4 -3
@@ -3820,10 +3820,11 @@ func TestTableCollector_Collect(t *testing.T) {
3820 }
3821
3822 missingOIDs := make(map[string]bool)
3823 - tableCache := newTableCache(0, 0) // Cache disabled
3824 - collector := newTableCollector(mockHandler, missingOIDs, tableCache, logger.New(), false)
3823 + tcache := newTableCache(0, 0) // Cache disabled
3824 + collector := newTableCollector(mockHandler, missingOIDs, tcache, logger.New(), false)
3825
3826 - result, err := collector.Collect(tc.profile)
3826 + var stats ddsnmp.CollectionStats
3827 + result, err := collector.collect(tc.profile, &stats)
3828
3829 if tc.expectedError {
3830 assert.Error(t, err)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go new
+151
@@ -0,0 +1,151 @@
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"
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
15 +)
16 +
17 +func TestCollector_Collect_StatsSnapshot(t *testing.T) {
18 + ctrl, mockHandler := setupMockHandler(t)
19 + defer ctrl.Finish()
20 +
21 + // --- SNMP expectations ---------------------------------------------------
22 +
23 + // Scalar: sysUpTime.0
24 + expectSNMPGet(mockHandler,
25 + []string{"1.3.6.1.2.1.1.3.0"},
26 + []gosnmp.SnmpPDU{
27 + createTimeTicksPDU("1.3.6.1.2.1.1.3.0", 123456),
28 + },
29 + )
30 +
31 + // Table: ifTable, we only care about ifInOctets with 2 rows
32 + expectSNMPWalk(mockHandler,
33 + gosnmp.Version2c,
34 + "1.3.6.1.2.1.2.2",
35 + []gosnmp.SnmpPDU{
36 + // Row 1
37 + createCounter32PDU("1.3.6.1.2.1.2.2.1.10.1", 1000), // ifInOctets.1
38 + // Row 2
39 + createCounter32PDU("1.3.6.1.2.1.2.2.1.10.2", 2000), // ifInOctets.2
40 + },
41 + )
42 +
43 + // --- Profile definition --------------------------------------------------
44 +
45 + profile := &ddsnmp.Profile{
46 + SourceFile: "stats-toy-profile.yaml",
47 + Definition: &ddprofiledefinition.ProfileDefinition{
48 + Metrics: []ddprofiledefinition.MetricsConfig{
49 + // Simple scalar metric
50 + {
51 + Symbol: ddprofiledefinition.SymbolConfig{
52 + OID: "1.3.6.1.2.1.1.3.0",
53 + Name: "sysUpTime",
54 + },
55 + },
56 + // Simple table metric: ifInOctets over ifTable
57 + {
58 + Table: ddprofiledefinition.SymbolConfig{
59 + OID: "1.3.6.1.2.1.2.2",
60 + Name: "ifTable",
61 + },
62 + Symbols: []ddprofiledefinition.SymbolConfig{
63 + {
64 + OID: "1.3.6.1.2.1.2.2.1.10",
65 + Name: "ifInOctets",
66 + },
67 + },
68 + },
69 + },
70 + // One virtual metric that sums ifInOctets across the table.
71 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
72 + {
73 + Name: "ifInOctets_total",
74 + Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
75 + {
76 + Metric: "ifInOctets",
77 + Table: "ifTable",
78 + },
79 + },
80 + },
81 + },
82 + },
83 + }
84 +
85 + handleCrossTableTagsWithoutMetrics(profile)
86 + require.NoError(t, ddsnmp.CompileTransforms(profile))
87 +
88 + collector := New(Config{
89 + SnmpClient: mockHandler,
90 + Profiles: []*ddsnmp.Profile{profile},
91 + Log: logger.New(),
92 + SysObjectID: "",
93 + })
94 +
95 + // --- Run collection ------------------------------------------------------
96 +
97 + results, err := collector.Collect()
98 + require.NoError(t, err)
99 + require.Len(t, results, 1)
100 +
101 + pm := results[0]
102 +
103 + // --- Sanity check on actual metrics -------------------------------------
104 +
105 + // We expect:
106 + // - 1 scalar metric (sysUpTime)
107 + // - 2 table metrics (ifInOctets for 2 rows)
108 + // - 1 virtual metric (ifInOctets_total)
109 + require.Len(t, pm.Metrics, 4, "total number of metrics")
110 +
111 + // --- Assert CollectionStats as a snapshot -------------------------------
112 +
113 + // Ignore timing (it's inherently variable).
114 + stats := pm.Stats
115 + stats.Timing = ddsnmp.TimingStats{}
116 + pm.Stats = stats
117 +
118 + expected := ddsnmp.CollectionStats{
119 + SNMP: ddsnmp.SNMPOperationStats{
120 + // Scalar: 1 GET with 1 OID
121 + GetRequests: 1,
122 + GetOIDs: 1,
123 +
124 + // Table: 1 WALK with 2 PDUs, 1 table walked, no cached tables
125 + WalkRequests: 1,
126 + WalkPDUs: 2,
127 + TablesWalked: 1,
128 + // TablesCached should be 0 on first run
129 + },
130 + Metrics: ddsnmp.MetricCountStats{
131 + Scalar: 1, // sysUpTime
132 + Table: 2, // ifInOctets.1, ifInOctets.2
133 + Virtual: 1, // ifInOctets_total
134 + Tables: 1, // ifTable
135 + Rows: 2, // 2 interfaces
136 + },
137 + TableCache: ddsnmp.TableCacheStats{
138 + Hits: 0, // first run → no cache hits
139 + Misses: 1, // one table config had to be walked
140 + // Expired intentionally ignored / omitted
141 + },
142 + Errors: ddsnmp.ErrorStats{
143 + SNMP: 0,
144 + MissingOIDs: 0,
145 + },
146 + // Timing left as zero-value for comparison
147 + Timing: ddsnmp.TimingStats{},
148 + }
149 +
150 + assert.Equal(t, expected, pm.Stats)
151 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics.go
+2 -3
@@ -22,7 +22,7 @@ func newVirtualMetricsCollector(log *logger.Logger) *vmetricsCollector {
22 }
23 }
24
25 -func (p *vmetricsCollector) Collect(profDef *ddprofiledefinition.ProfileDefinition, collected []ddsnmp.Metric) []ddsnmp.Metric {
25 +func (p *vmetricsCollector) collect(profDef *ddprofiledefinition.ProfileDefinition, collected []ddsnmp.Metric) []ddsnmp.Metric {
26 if len(profDef.VirtualMetrics) == 0 {
27 return nil
28 }
@@ -418,8 +418,7 @@ type aggregatorsBuilder struct {
418 aggregators []*vmetricsAggregator
419 }
420
421 -func newAggregatorsBuilder(
422 - log *logger.Logger, prof *ddprofiledefinition.ProfileDefinition, existingNames map[string]bool) *aggregatorsBuilder {
421 +func newAggregatorsBuilder(log *logger.Logger, prof *ddprofiledefinition.ProfileDefinition, existingNames map[string]bool) *aggregatorsBuilder {
422 return &aggregatorsBuilder{
423 log: log,
424 prof: prof,
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_vmetrics_test.go
+1 -1
@@ -1517,7 +1517,7 @@ func TestVirtualMetricsCollector_Collect(t *testing.T) {
1517 for name, tc := range tests {
1518 t.Run(name, func(t *testing.T) {
1519 vmc := newVirtualMetricsCollector(logger.New())
1520 - result := vmc.Collect(tc.profileDef, tc.collectedMetrics)
1520 + result := vmc.collect(tc.profileDef, tc.collectedMetrics)
1521
1522 // Sort both slices for consistent comparison
1523 assert.ElementsMatch(t, tc.expected, result)
src/go/plugin/go.d/collector/snmp/ddsnmp/metric.go
+77
@@ -1,6 +1,8 @@
1 package ddsnmp
2
3 import (
4 + "time"
5 +
6 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
7 )
8
@@ -9,6 +11,7 @@ type ProfileMetrics struct {
11 DeviceMetadata map[string]MetaTag
12 Tags map[string]string
13 Metrics []Metric
14 + Stats CollectionStats
15 }
16
17 type Metric struct {
@@ -32,3 +35,77 @@ type MetaTag struct {
35 Value string
36 IsExactMatch bool // whether this value is from an exact match context
37 }
38 +
39 +// CollectionStats contains statistics for a single profile collection cycle.
40 +type CollectionStats struct {
41 + Timing TimingStats
42 + SNMP SNMPOperationStats
43 + Metrics MetricCountStats
44 + TableCache TableCacheStats
45 + Errors ErrorStats
46 +}
47 +
48 +// TimingStats captures duration of each collection phase.
49 +type TimingStats struct {
50 + // Scalar is time spent collecting scalar (non-table) metrics.
51 + Scalar time.Duration
52 + // Table is time spent collecting table metrics.
53 + Table time.Duration
54 + // VirtualMetrics is time spent computing derived/aggregated metrics.
55 + VirtualMetrics time.Duration
56 +}
57 +
58 +func (s TimingStats) Total() time.Duration {
59 + return s.Scalar + s.Table + s.VirtualMetrics
60 +}
61 +
62 +// SNMPOperationStats captures SNMP protocol-level operations.
63 +type SNMPOperationStats struct {
64 + // GetRequests is the number of SNMP GET operations performed.
65 + GetRequests int64
66 + // GetOIDs is the total number of OIDs requested across all GETs.
67 + GetOIDs int64
68 + // WalkRequests is the number of SNMP Walk/BulkWalk operations.
69 + WalkRequests int64
70 + // WalkPDUs is the total number of PDUs returned from all walks.
71 + WalkPDUs int64
72 + // TablesWalked is the count of tables that required walking.
73 + TablesWalked int64
74 + // TablesCached is the count of tables served from cache.
75 + TablesCached int64
76 +}
77 +
78 +// MetricCountStats captures the number of metrics produced.
79 +type MetricCountStats struct {
80 + // Scalar is the count of scalar (non-table) metrics.
81 + Scalar int64
82 + // Table is the count of table metrics.
83 + Table int64
84 + // Virtual is the count of computed/derived metrics.
85 + Virtual int64
86 + // Tables is the count of unique tables with metrics.
87 + Tables int64
88 + // Rows is the total number of table rows across all tables.
89 + Rows int64
90 +}
91 +
92 +// TableCacheStats captures table cache performance.
93 +type TableCacheStats struct {
94 + // Hits is the number of table configs served from cache.
95 + Hits int64
96 + // Misses is the number of table configs that required walking.
97 + Misses int64
98 +}
99 +
100 +// ErrorStats captures categorized error counts.
101 +type ErrorStats struct {
102 + // SNMP is the count of SNMP-level errors (timeouts, network issues).
103 + SNMP int64
104 + // Processing is the count of value conversion/transform errors.
105 + Processing struct {
106 + Scalar int64
107 + Table int64
108 + }
109 + // MissingOIDs is the count of NoSuchObject/NoSuchName responses.
110 + MissingOIDs int64
111 +}