@cryptotaxi247 / netdata-1 / commits / 369a28ba0

test(go.d/prometheus): add V1 compatibility manifest golden tests (#22641)

Ilya Mashchenko committed Jun 6, 2026 at 13:50 UTC 369a28ba0b8569f0c56d81d566131d978b050f5d
14 files changed +742
src/go/plugin/go.d/collector/prometheus/manifest_test.go new
+310
@@ -0,0 +1,310 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package prometheus
4 +
5 +import (
6 + "context"
7 + "encoding/json"
8 + "flag"
9 + "net/http"
10 + "net/http/httptest"
11 + "os"
12 + "path/filepath"
13 + "sort"
14 + "strings"
15 + "testing"
16 +
17 + "github.com/stretchr/testify/assert"
18 + "github.com/stretchr/testify/require"
19 +
20 + "github.com/netdata/netdata/go/plugins/pkg/prometheus/selector"
21 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
22 +)
23 +
24 +// updateGolden, when set, makes the manifest test (re)write the golden files and
25 +// skip the comparison. The goldens are a frozen baseline of the V1 collector's
26 +// observable contract; the V2 migration (PR5) checks parity by diffing its render
27 +// against them. Regenerate ONLY when intentionally adding a fixture or accepting a
28 +// contract change, then review the git diff — never to silently absorb V2 drift,
29 +// which would defeat the baseline. Hand-editing the JSON is error-prone, so this is
30 +// the supported way to maintain them:
31 +//
32 +// go test ./plugin/go.d/collector/prometheus/ -run TestCollector_compatManifest -update-golden
33 +var updateGolden = flag.Bool("update-golden", false, "regenerate the prometheus compat-manifest golden files")
34 +
35 +// The compat manifest captures the V1 collector's observable CONTRACT, so the V2
36 +// migration (PR5) can be verified to preserve it.
37 +//
38 +// manifestChart top-level fields are the HARD contract a V2 migration must
39 +// reproduce: the chart context, its labels (incl. label_prefix), and its dims by
40 +// semantic name with algo + the real (de-scaled) value. `soft` holds best-effort
41 +// fields autogen may derive differently (units/family/type). The V1 chart-ID
42 +// strings and the ×1000 / ×1e6 precision divisor are INTENTIONALLY excluded — both
43 +// change by design in V2 (autogen chart-IDs; float dimensions). Values are
44 +// de-scaled (mx ÷ Div) to the real number V2's float dims emit directly.
45 +//
46 +// Note: V1 scales values into int64 (×1000 / ×1e6), so sub-precision values are
47 +// truncated (0.00147889 → 1 → 0.001). The manifest records V1's truncated value;
48 +// the PR5 verification must diff values float-tolerantly (V2 is more precise).
49 +type manifestChart struct {
50 + Context string `json:"context"`
51 + Labels map[string]string `json:"labels,omitempty"`
52 + Dims []manifestDim `json:"dims"`
53 + Soft manifestSoft `json:"soft"`
54 +}
55 +
56 +type manifestDim struct {
57 + Name string `json:"name"`
58 + Algo string `json:"algo"`
59 + Value float64 `json:"value"`
60 +}
61 +
62 +type manifestSoft struct {
63 + Units string `json:"units"`
64 + Family string `json:"family"`
65 + Type string `json:"type"`
66 +}
67 +
68 +func renderManifest(charts *collectorapi.Charts, mx map[string]int64) []manifestChart {
69 + out := make([]manifestChart, 0, len(*charts))
70 +
71 + for _, ch := range *charts {
72 + if ch.Obsolete {
73 + continue
74 + }
75 +
76 + mc := manifestChart{
77 + Context: ch.Ctx,
78 + Soft: manifestSoft{Units: ch.Units, Family: ch.Fam, Type: string(ch.Type)},
79 + }
80 + if len(ch.Labels) > 0 {
81 + mc.Labels = make(map[string]string, len(ch.Labels))
82 + for _, l := range ch.Labels {
83 + mc.Labels[l.Key] = l.Value
84 + }
85 + }
86 + for _, d := range ch.Dims {
87 + div := d.Div
88 + if div == 0 {
89 + div = 1
90 + }
91 + algo := "absolute"
92 + if d.Algo != "" {
93 + algo = string(d.Algo)
94 + }
95 + mc.Dims = append(mc.Dims, manifestDim{
96 + Name: d.Name,
97 + Algo: algo,
98 + Value: float64(mx[d.ID]) / float64(div),
99 + })
100 + }
101 + sort.Slice(mc.Dims, func(i, j int) bool { return mc.Dims[i].Name < mc.Dims[j].Name })
102 +
103 + out = append(out, mc)
104 + }
105 +
106 + sort.Slice(out, func(i, j int) bool {
107 + if out[i].Context != out[j].Context {
108 + return out[i].Context < out[j].Context
109 + }
110 + return manifestLabelsKey(out[i].Labels) < manifestLabelsKey(out[j].Labels)
111 + })
112 +
113 + return out
114 +}
115 +
116 +func manifestLabelsKey(m map[string]string) string {
117 + keys := make([]string, 0, len(m))
118 + for k := range m {
119 + keys = append(keys, k)
120 + }
121 + sort.Strings(keys)
122 +
123 + var sb strings.Builder
124 + for _, k := range keys {
125 + sb.WriteString(k + "=" + m[k] + ";")
126 + }
127 + return sb.String()
128 +}
129 +
130 +func TestCollector_compatManifest(t *testing.T) {
131 + tests := map[string]struct {
132 + prepare func() *Collector
133 + input string
134 + }{
135 + "gauge": {
136 + prepare: New,
137 + input: `
138 +# HELP test_gauge_metric A gauge.
139 +# TYPE test_gauge_metric gauge
140 +test_gauge_metric{label1="value1"} 11
141 +test_gauge_metric{label1="value2"} 12.5
142 +`,
143 + },
144 + "counter": {
145 + prepare: New,
146 + input: `
147 +# TYPE test_counter_metric_total counter
148 +test_counter_metric_total{label1="value1"} 11
149 +`,
150 + },
151 + "summary": {
152 + prepare: New,
153 + input: `
154 +# TYPE test_summary_duration_seconds summary
155 +test_summary_duration_seconds{label1="value1",quantile="0.5"} 0.25
156 +test_summary_duration_seconds{label1="value1",quantile="0.99"} 0.5
157 +test_summary_duration_seconds_sum{label1="value1"} 12.5
158 +test_summary_duration_seconds_count{label1="value1"} 42
159 +`,
160 + },
161 + "histogram": {
162 + prepare: New,
163 + input: `
164 +# TYPE test_histogram_duration_seconds histogram
165 +test_histogram_duration_seconds_bucket{label1="value1",le="0.1"} 4
166 +test_histogram_duration_seconds_bucket{label1="value1",le="+Inf"} 6
167 +test_histogram_duration_seconds_sum{label1="value1"} 2.5
168 +test_histogram_duration_seconds_count{label1="value1"} 6
169 +`,
170 + },
171 + "untyped_total": {
172 + prepare: New,
173 + input: `
174 +test_untyped_metric_total{label1="value1"} 11
175 +`,
176 + },
177 + "app": {
178 + prepare: func() *Collector { c := New(); c.Application = "custom_app"; return c },
179 + input: `
180 +# TYPE test_gauge_metric gauge
181 +test_gauge_metric{label1="value1"} 11
182 +`,
183 + },
184 + "app_job_name": {
185 + // Application empty -> the app segment falls back to the job Name (charts.go:238-241).
186 + prepare: func() *Collector { c := New(); c.Name = "job_app"; return c },
187 + input: `
188 +# TYPE test_gauge_metric gauge
189 +test_gauge_metric{label1="value1"} 11
190 +`,
191 + },
192 + "label_prefix": {
193 + prepare: func() *Collector { c := New(); c.LabelPrefix = "px"; return c },
194 + input: `
195 +# TYPE test_gauge_metric gauge
196 +test_gauge_metric{label1="value1"} 11
197 +`,
198 + },
199 + "snmp_units": {
200 + // Special unit mappings (charts.go getChartUnits): uppercase snmp-exporter
201 + // names octets->bytes, pkts->packets, mtu->octets, speed->bits; underscore
202 + // suffix hertz->Hz.
203 + prepare: New,
204 + input: `
205 +# TYPE ifOutOctets gauge
206 +ifOutOctets{ifDescr="eth0"} 12345
207 +# TYPE ifOutUcastPkts gauge
208 +ifOutUcastPkts{ifDescr="eth0"} 678
209 +# TYPE ifMtu gauge
210 +ifMtu{ifDescr="eth0"} 1500
211 +# TYPE ifHighSpeed gauge
212 +ifHighSpeed{ifDescr="eth0"} 1000
213 +# TYPE test_clock_hertz gauge
214 +test_clock_hertz{cpu="0"} 2400
215 +`,
216 + },
217 + "selector": {
218 + prepare: func() *Collector {
219 + c := New()
220 + c.Selector = selector.Expr{Allow: []string{"test_gauge_metric_keep"}}
221 + return c
222 + },
223 + input: `
224 +# TYPE test_gauge_metric_keep gauge
225 +test_gauge_metric_keep{label1="value1"} 11
226 +# TYPE test_gauge_metric_drop gauge
227 +test_gauge_metric_drop{label1="value1"} 22
228 +`,
229 + },
230 + "info_skipped": {
231 + prepare: New,
232 + input: `
233 +# TYPE test_metric gauge
234 +test_metric{label1="value1"} 11
235 +# TYPE test_metric_info gauge
236 +test_metric_info{version="1.2.3"} 1
237 +`,
238 + },
239 + "fallback_gauge": {
240 + prepare: func() *Collector {
241 + c := New()
242 + c.FallbackType.Gauge = []string{"test_untyped_metric"}
243 + return c
244 + },
245 + input: `
246 +test_untyped_metric{label1="value1"} 11
247 +`,
248 + },
249 + "fallback_counter": {
250 + // Untyped metric forced to counter by regex — a distinct path from the
251 + // _total auto-counter (independent `if` at collect.go:176); algo incremental.
252 + prepare: func() *Collector {
253 + c := New()
254 + c.FallbackType.Counter = []string{"test_untyped_metric"}
255 + return c
256 + },
257 + input: `
258 +test_untyped_metric{label1="value1"} 11
259 +`,
260 + },
261 + }
262 +
263 + for name, tc := range tests {
264 + t.Run(name, func(t *testing.T) {
265 + srv := httptest.NewServer(http.HandlerFunc(
266 + func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(tc.input)) }))
267 + defer srv.Close()
268 +
269 + collr := tc.prepare()
270 + collr.URL = srv.URL
271 + require.NoError(t, collr.Init(context.Background()))
272 +
273 + mx := collr.Collect(context.Background())
274 + require.NotNil(t, mx)
275 +
276 + got := renderManifest(collr.Charts(), mx)
277 + data, err := json.MarshalIndent(got, "", " ")
278 + require.NoError(t, err)
279 + data = append(data, '\n')
280 +
281 + path := filepath.Join("testdata", "golden", goldenName(name)+".json")
282 + if *updateGolden {
283 + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
284 + require.NoError(t, os.WriteFile(path, data, 0o644))
285 + return
286 + }
287 +
288 + want, err := os.ReadFile(path)
289 + require.NoErrorf(t, err, "missing golden %q — run: go test -run TestCollector_compatManifest -update-golden ./...", path)
290 + assert.Equal(t, string(want), string(data))
291 + })
292 + }
293 +}
294 +
295 +// Config defaults the V2 migration (PR5) must preserve: update_every is the
296 +// registered Creator default (collectorapi.Defaults); max_time_series[_per_metric]
297 +// are New() defaults. A V2 re-registration can silently drop them.
298 +func TestCollector_compatConfigDefaults(t *testing.T) {
299 + creator, ok := collectorapi.DefaultRegistry.Lookup("prometheus")
300 + require.True(t, ok, "prometheus collector must be registered")
301 + assert.Equal(t, 10, creator.Defaults.UpdateEvery, "update_every default")
302 +
303 + c := New()
304 + assert.Equal(t, 2000, c.MaxTS, "max_time_series default")
305 + assert.Equal(t, 200, c.MaxTSPerMetric, "max_time_series_per_metric default")
306 +}
307 +
308 +func goldenName(name string) string {
309 + return strings.NewReplacer(" ", "_", "(", "", ")", "", ">", "", "-", "_", ".", "_", "/", "_", "<", "").Replace(name)
310 +}
src/go/plugin/go.d/collector/prometheus/testdata/golden/app.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.custom_app.test_gauge_metric",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_gauge_metric",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric",
16 + "family": "test_gauge",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/app_job_name.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.job_app.test_gauge_metric",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_gauge_metric",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric",
16 + "family": "test_gauge",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/counter.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_counter_metric_total",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_counter_metric_total",
10 + "algo": "incremental",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric/s",
16 + "family": "test_counter",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/fallback_counter.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_untyped_metric",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_untyped_metric",
10 + "algo": "incremental",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric/s",
16 + "family": "test_untyped",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/fallback_gauge.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_untyped_metric",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_untyped_metric",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric",
16 + "family": "test_untyped",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/gauge.json new
+38
@@ -0,0 +1,38 @@
1 +[
2 + {
3 + "context": "prometheus.test_gauge_metric",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_gauge_metric",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric",
16 + "family": "test_gauge",
17 + "type": "line"
18 + }
19 + },
20 + {
21 + "context": "prometheus.test_gauge_metric",
22 + "labels": {
23 + "label1": "value2"
24 + },
25 + "dims": [
26 + {
27 + "name": "test_gauge_metric",
28 + "algo": "absolute",
29 + "value": 12.5
30 + }
31 + ],
32 + "soft": {
33 + "units": "metric",
34 + "family": "test_gauge",
35 + "type": "line"
36 + }
37 + }
38 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/histogram.json new
+61
@@ -0,0 +1,61 @@
1 +[
2 + {
3 + "context": "prometheus.test_histogram_duration_seconds",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "bucket_+Inf",
10 + "algo": "incremental",
11 + "value": 6
12 + },
13 + {
14 + "name": "bucket_0.1",
15 + "algo": "incremental",
16 + "value": 4
17 + }
18 + ],
19 + "soft": {
20 + "units": "observations/s",
21 + "family": "test_histogram",
22 + "type": ""
23 + }
24 + },
25 + {
26 + "context": "prometheus.test_histogram_duration_seconds_count",
27 + "labels": {
28 + "label1": "value1"
29 + },
30 + "dims": [
31 + {
32 + "name": "test_histogram_duration_seconds_count",
33 + "algo": "incremental",
34 + "value": 6
35 + }
36 + ],
37 + "soft": {
38 + "units": "events/s",
39 + "family": "test_histogram",
40 + "type": ""
41 + }
42 + },
43 + {
44 + "context": "prometheus.test_histogram_duration_seconds_sum",
45 + "labels": {
46 + "label1": "value1"
47 + },
48 + "dims": [
49 + {
50 + "name": "test_histogram_duration_seconds_sum",
51 + "algo": "incremental",
52 + "value": 2.5
53 + }
54 + ],
55 + "soft": {
56 + "units": "seconds",
57 + "family": "test_histogram",
58 + "type": ""
59 + }
60 + }
61 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/info_skipped.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_metric",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_metric",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric",
16 + "family": "test_metric",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/label_prefix.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_gauge_metric",
4 + "labels": {
5 + "px_label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_gauge_metric",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric",
16 + "family": "test_gauge",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/selector.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_gauge_metric_keep",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_gauge_metric_keep",
10 + "algo": "absolute",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "keep",
16 + "family": "test_gauge",
17 + "type": "line"
18 + }
19 + }
20 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/snmp_units.json new
+92
@@ -0,0 +1,92 @@
1 +[
2 + {
3 + "context": "prometheus.ifHighSpeed",
4 + "labels": {
5 + "ifDescr": "eth0"
6 + },
7 + "dims": [
8 + {
9 + "name": "ifHighSpeed",
10 + "algo": "absolute",
11 + "value": 1000
12 + }
13 + ],
14 + "soft": {
15 + "units": "bits",
16 + "family": "ifHighSpeed",
17 + "type": "line"
18 + }
19 + },
20 + {
21 + "context": "prometheus.ifMtu",
22 + "labels": {
23 + "ifDescr": "eth0"
24 + },
25 + "dims": [
26 + {
27 + "name": "ifMtu",
28 + "algo": "absolute",
29 + "value": 1500
30 + }
31 + ],
32 + "soft": {
33 + "units": "octets",
34 + "family": "ifMtu",
35 + "type": "line"
36 + }
37 + },
38 + {
39 + "context": "prometheus.ifOutOctets",
40 + "labels": {
41 + "ifDescr": "eth0"
42 + },
43 + "dims": [
44 + {
45 + "name": "ifOutOctets",
46 + "algo": "absolute",
47 + "value": 12345
48 + }
49 + ],
50 + "soft": {
51 + "units": "bytes",
52 + "family": "ifOutOctets",
53 + "type": "area"
54 + }
55 + },
56 + {
57 + "context": "prometheus.ifOutUcastPkts",
58 + "labels": {
59 + "ifDescr": "eth0"
60 + },
61 + "dims": [
62 + {
63 + "name": "ifOutUcastPkts",
64 + "algo": "absolute",
65 + "value": 678
66 + }
67 + ],
68 + "soft": {
69 + "units": "packets",
70 + "family": "ifOutUcastPkts",
71 + "type": "line"
72 + }
73 + },
74 + {
75 + "context": "prometheus.test_clock_hertz",
76 + "labels": {
77 + "cpu": "0"
78 + },
79 + "dims": [
80 + {
81 + "name": "test_clock_hertz",
82 + "algo": "absolute",
83 + "value": 2400
84 + }
85 + ],
86 + "soft": {
87 + "units": "Hz",
88 + "family": "test_clock",
89 + "type": "line"
90 + }
91 + }
92 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/summary.json new
+61
@@ -0,0 +1,61 @@
1 +[
2 + {
3 + "context": "prometheus.test_summary_duration_seconds",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "quantile_0.5",
10 + "algo": "absolute",
11 + "value": 0.25
12 + },
13 + {
14 + "name": "quantile_0.99",
15 + "algo": "absolute",
16 + "value": 0.5
17 + }
18 + ],
19 + "soft": {
20 + "units": "seconds",
21 + "family": "test_summary",
22 + "type": ""
23 + }
24 + },
25 + {
26 + "context": "prometheus.test_summary_duration_seconds_count",
27 + "labels": {
28 + "label1": "value1"
29 + },
30 + "dims": [
31 + {
32 + "name": "test_summary_duration_seconds_count",
33 + "algo": "incremental",
34 + "value": 42
35 + }
36 + ],
37 + "soft": {
38 + "units": "events/s",
39 + "family": "test_summary",
40 + "type": ""
41 + }
42 + },
43 + {
44 + "context": "prometheus.test_summary_duration_seconds_sum",
45 + "labels": {
46 + "label1": "value1"
47 + },
48 + "dims": [
49 + {
50 + "name": "test_summary_duration_seconds_sum",
51 + "algo": "incremental",
52 + "value": 12.5
53 + }
54 + ],
55 + "soft": {
56 + "units": "seconds",
57 + "family": "test_summary",
58 + "type": ""
59 + }
60 + }
61 +]
src/go/plugin/go.d/collector/prometheus/testdata/golden/untyped_total.json new
+20
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "context": "prometheus.test_untyped_metric_total",
4 + "labels": {
5 + "label1": "value1"
6 + },
7 + "dims": [
8 + {
9 + "name": "test_untyped_metric_total",
10 + "algo": "incremental",
11 + "value": 11
12 + }
13 + ],
14 + "soft": {
15 + "units": "metric/s",
16 + "family": "test_untyped",
17 + "type": "line"
18 + }
19 + }
20 +]