master
go 455 lines 15.5 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package prometheus
4
5 import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "maps"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "sort"
15 "strings"
16 "testing"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20
21 "github.com/netdata/netdata/go/plugins/pkg/metrix"
22 "github.com/netdata/netdata/go/plugins/pkg/prometheus/selector"
23 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
24 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
25 )
26
27 // The compat manifest captures the V1 collector's observable CONTRACT as a frozen
28 // golden baseline, so the migrated V2 collector can be verified to preserve it.
29 //
30 // manifestChart top-level fields are the HARD contract a V2 migration must
31 // reproduce: the chart context, its labels (incl. label_prefix), and its dims by
32 // semantic name with algo + the real (de-scaled) value. `soft` holds the chart
33 // metadata: units and family are reproduced (the writer feeds the V1 chart helpers
34 // into the metrix instrument meta) and ASSERTED; chart type is autogen-derived and
35 // only logged — V1 left distribution charts type-empty while autogen sets "line",
36 // which is equivalent (an empty type renders as line).
37 //
38 // Chart title and priority are NOT in the manifest: the writer's feed of them is
39 // asserted directly in writer_test.go (mm.Description / mm.ChartPriority), and autogen
40 // carries them through unchanged (the instrument Description becomes the chart title;
41 // effectiveChartPriority is the identity for positive priorities). The units/family
42 // parity exercised here already proves that same instrument-meta → chart-meta path.
43 //
44 // The V1 chart-ID strings and the ×1000 / ×1e6 precision divisor are INTENTIONALLY
45 // excluded — both change by design in V2 (autogen chart-IDs; float dimensions).
46 //
47 // Values: V1 pre-scaled to int64 (×1000 / ×1e6) then de-scaled (mx ÷ Div), so a V1
48 // value can sit up to 1/Div (≤ 1/1000) below the true value while V2 writes the true
49 // float directly; the comparison tolerates that ≤1e-3 truncation (manifestValueTolerance).
50 // V2 does no scaling arithmetic, so it adds no sub-1e-3 error of its own — a real
51 // divergence would be gross, not within tolerance. A gap (a dimension with no value
52 // this cycle, e.g. a skipped NaN summary quantile) is NOT representable in this JSON
53 // shape; the renderer fails loudly on one, and the current cases produce none.
54 type manifestChart struct {
55 Context string `json:"context"`
56 Labels map[string]string `json:"labels,omitempty"`
57 Dims []manifestDim `json:"dims"`
58 Soft manifestSoft `json:"soft"`
59 }
60
61 type manifestDim struct {
62 Name string `json:"name"`
63 Algo string `json:"algo"`
64 Value float64 `json:"value"`
65 }
66
67 type manifestSoft struct {
68 Units string `json:"units"`
69 Family string `json:"family"`
70 Type string `json:"type"`
71 }
72
73 func manifestLabelsKey(m map[string]string) string {
74 keys := make([]string, 0, len(m))
75 for k := range m {
76 keys = append(keys, k)
77 }
78 sort.Strings(keys)
79
80 var sb strings.Builder
81 for _, k := range keys {
82 v := m[k]
83 // Length-prefixed so distinct label sets cannot collide, e.g. {"a":"b;c=d"}
84 // vs {"a":"b","c":"d"}.
85 fmt.Fprintf(&sb, "%d:%s=%d:%s;", len(k), k, len(v), v)
86 }
87 return sb.String()
88 }
89
90 type compatManifestCase struct {
91 prepare func() *Collector
92 input string
93 }
94
95 // compatManifestCases is the fixture for the compat-manifest test: each scraped input
96 // and collector config is run through the V2 collector (the metric-family writer plus
97 // the per-job autogen template rendered by chartengine) and checked against the golden
98 // — the frozen V1 contract the migration must preserve.
99 func compatManifestCases() map[string]compatManifestCase {
100 return map[string]compatManifestCase{
101 "gauge": {
102 prepare: New,
103 input: `
104 # HELP test_gauge_metric A gauge.
105 # TYPE test_gauge_metric gauge
106 test_gauge_metric{label1="value1"} 11
107 test_gauge_metric{label1="value2"} 12.5
108 `,
109 },
110 "counter": {
111 prepare: New,
112 input: `
113 # TYPE test_counter_metric_total counter
114 test_counter_metric_total{label1="value1"} 11
115 `,
116 },
117 "summary": {
118 prepare: New,
119 input: `
120 # TYPE test_summary_duration_seconds summary
121 test_summary_duration_seconds{label1="value1",quantile="0.5"} 0.25
122 test_summary_duration_seconds{label1="value1",quantile="0.99"} 0.5
123 test_summary_duration_seconds_sum{label1="value1"} 12.5
124 test_summary_duration_seconds_count{label1="value1"} 42
125 `,
126 },
127 "histogram": {
128 prepare: New,
129 input: `
130 # TYPE test_histogram_duration_seconds histogram
131 test_histogram_duration_seconds_bucket{label1="value1",le="0.1"} 4
132 test_histogram_duration_seconds_bucket{label1="value1",le="+Inf"} 6
133 test_histogram_duration_seconds_sum{label1="value1"} 2.5
134 test_histogram_duration_seconds_count{label1="value1"} 6
135 `,
136 },
137 "untyped_total": {
138 prepare: New,
139 input: `
140 test_untyped_metric_total{label1="value1"} 11
141 `,
142 },
143 "app": {
144 prepare: func() *Collector { c := New(); c.Application = "custom_app"; return c },
145 input: `
146 # TYPE test_gauge_metric gauge
147 test_gauge_metric{label1="value1"} 11
148 `,
149 },
150 "app_job_name": {
151 // Application empty -> the app segment falls back to the job Name (see application()).
152 prepare: func() *Collector { c := New(); c.Name = "job_app"; return c },
153 input: `
154 # TYPE test_gauge_metric gauge
155 test_gauge_metric{label1="value1"} 11
156 `,
157 },
158 "label_prefix": {
159 prepare: func() *Collector { c := New(); c.LabelPrefix = "px"; return c },
160 input: `
161 # TYPE test_gauge_metric gauge
162 test_gauge_metric{label1="value1"} 11
163 `,
164 },
165 "snmp_units": {
166 // Special unit mappings (getChartUnits): uppercase snmp-exporter names
167 // octets->bytes, pkts->packets, mtu->octets, speed->bits; underscore suffix
168 // hertz->Hz.
169 prepare: New,
170 input: `
171 # TYPE ifOutOctets gauge
172 ifOutOctets{ifDescr="eth0"} 12345
173 # TYPE ifOutUcastPkts gauge
174 ifOutUcastPkts{ifDescr="eth0"} 678
175 # TYPE ifMtu gauge
176 ifMtu{ifDescr="eth0"} 1500
177 # TYPE ifHighSpeed gauge
178 ifHighSpeed{ifDescr="eth0"} 1000
179 # TYPE test_clock_hertz gauge
180 test_clock_hertz{cpu="0"} 2400
181 `,
182 },
183 "selector": {
184 prepare: func() *Collector {
185 c := New()
186 c.Selector = selector.Expr{Allow: []string{"test_gauge_metric_keep"}}
187 return c
188 },
189 input: `
190 # TYPE test_gauge_metric_keep gauge
191 test_gauge_metric_keep{label1="value1"} 11
192 # TYPE test_gauge_metric_drop gauge
193 test_gauge_metric_drop{label1="value1"} 22
194 `,
195 },
196 "info_skipped": {
197 prepare: New,
198 input: `
199 # TYPE test_metric gauge
200 test_metric{label1="value1"} 11
201 # TYPE test_metric_info gauge
202 test_metric_info{version="1.2.3"} 1
203 `,
204 },
205 "fallback_gauge": {
206 prepare: func() *Collector {
207 c := New()
208 c.FallbackType.Gauge = []string{"test_untyped_metric"}
209 return c
210 },
211 input: `
212 test_untyped_metric{label1="value1"} 11
213 `,
214 },
215 "fallback_counter": {
216 // Untyped metric forced to counter via fallback_type — distinct from the
217 // _total auto-counter; both are resolved by the writer's resolveFamilyType,
218 // algo incremental.
219 prepare: func() *Collector {
220 c := New()
221 c.FallbackType.Counter = []string{"test_untyped_metric"}
222 return c
223 },
224 input: `
225 test_untyped_metric{label1="value1"} 11
226 `,
227 },
228 }
229 }
230
231 // Config defaults the V2 migration must preserve: update_every is the registered
232 // Creator default (collectorapi.Defaults); max_time_series[_per_metric] are New()
233 // defaults. A V2 re-registration can silently drop them.
234 func TestCollector_compatConfigDefaults(t *testing.T) {
235 creator, ok := collectorapi.DefaultRegistry.Lookup("prometheus")
236 require.True(t, ok, "prometheus collector must be registered")
237 assert.Equal(t, 10, creator.Defaults.UpdateEvery, "update_every default")
238
239 c := New()
240 assert.Equal(t, 2000, c.MaxTS, "max_time_series default")
241 assert.Equal(t, 200, c.MaxTSPerMetric, "max_time_series_per_metric default")
242 }
243
244 func goldenName(name string) string {
245 return strings.NewReplacer(" ", "_", "(", "", ")", "", ">", "", "-", "_", ".", "_", "/", "_", "<", "").Replace(name)
246 }
247
248 // manifestValueTolerance bounds V1's pre-scale truncation: V1 stored int64(value×Div)
249 // then de-scaled, losing up to 1/Div (≤ 1/1000) of precision, while V2 writes the true
250 // float. V2 does no scaling arithmetic, so it adds no sub-1e-3 error — a real divergence
251 // would exceed this.
252 const manifestValueTolerance = 1e-3
253
254 // algoString maps a chartengine algorithm to the manifest's algo string.
255 func algoString(a chartengine.Algorithm) string {
256 if a == chartengine.AlgorithmIncremental {
257 return "incremental"
258 }
259 return "absolute"
260 }
261
262 // dimValue resolves a chartengine dimension value to the float the manifest records.
263 // Gaps are rejected by the caller (renderManifestV2), so only real values reach here.
264 func dimValue(dv chartengine.UpdateDimensionValue) float64 {
265 if dv.IsFloat {
266 return dv.Float64
267 }
268 return float64(dv.Int64)
269 }
270
271 func manifestLabels(m map[string]string) map[string]string {
272 if len(m) == 0 {
273 return nil
274 }
275 return maps.Clone(m)
276 }
277
278 // renderManifestV2 renders the V2 path into the manifestChart shape: it loads the given chart
279 // template (the collector's ChartTemplateYAML output) into chartengine, plans it against a store
280 // that already holds exactly one freshly-committed cycle of the collector's output, and reads the
281 // plan. Taking the live template (rather than rebuilding it) keeps the Init -> ChartTemplateYAML()
282 // wiring, including the app/Name context namespace, on the tested path. The create actions
283 // (context, labels, dim name+algo, soft fields) are emitted only on the first cycle, so a single
284 // cycle MUST be committed before calling this.
285 func renderManifestV2(t *testing.T, store metrix.CollectorStore, templateYAML string) []manifestChart {
286 t.Helper()
287
288 eng, err := chartengine.New()
289 require.NoError(t, err)
290 require.NoError(t, eng.LoadYAML([]byte(templateYAML), 1))
291
292 attempt, err := eng.PreparePlan(store.Read(metrix.ReadRaw(), metrix.ReadFlatten()))
293 require.NoError(t, err)
294 defer attempt.Abort()
295 plan := attempt.Plan()
296 require.NoError(t, attempt.Commit())
297
298 type chartAcc struct {
299 mc manifestChart
300 dimAlgo map[string]string
301 dimVal map[string]float64
302 }
303 charts := make(map[string]*chartAcc)
304
305 for _, a := range plan.Actions {
306 switch v := a.(type) {
307 case chartengine.CreateChartAction:
308 charts[v.ChartID] = &chartAcc{
309 mc: manifestChart{
310 Context: v.Meta.Context,
311 Labels: manifestLabels(v.Labels),
312 Soft: manifestSoft{Units: v.Meta.Units, Family: v.Meta.Family, Type: string(v.Meta.Type)},
313 },
314 dimAlgo: make(map[string]string),
315 dimVal: make(map[string]float64),
316 }
317 case chartengine.CreateDimensionAction:
318 c := charts[v.ChartID]
319 require.NotNilf(t, c, "dimension %q references unknown chart %q", v.Name, v.ChartID)
320 c.dimAlgo[v.Name] = algoString(v.Algorithm)
321 case chartengine.UpdateChartAction:
322 c := charts[v.ChartID]
323 require.NotNilf(t, c, "values reference unknown chart %q", v.ChartID)
324 for _, dv := range v.Values {
325 require.Falsef(t, dv.IsEmpty, "V2 dim %q is a gap; the manifest cannot represent gaps (the current cases produce none)", dv.Name)
326 c.dimVal[dv.Name] = dimValue(dv)
327 }
328 }
329 }
330
331 out := make([]manifestChart, 0, len(charts))
332 for _, c := range charts {
333 for name, algo := range c.dimAlgo {
334 c.mc.Dims = append(c.mc.Dims, manifestDim{Name: name, Algo: algo, Value: c.dimVal[name]})
335 }
336 sort.Slice(c.mc.Dims, func(i, j int) bool { return c.mc.Dims[i].Name < c.mc.Dims[j].Name })
337 out = append(out, c.mc)
338 }
339 sort.Slice(out, func(i, j int) bool {
340 if out[i].Context != out[j].Context {
341 return out[i].Context < out[j].Context
342 }
343 return manifestLabelsKey(out[i].Labels) < manifestLabelsKey(out[j].Labels)
344 })
345 return out
346 }
347
348 // TestCollector_compatManifestV2 drives the real V2 collector (Init then a framework-style
349 // store cycle around Collect) and proves its rendered chart manifest reproduces the V1
350 // contract captured in the goldens: identical chart contexts, labels, and dimensions
351 // (name, algorithm, value), plus units and family. Only chart type is logged rather than
352 // asserted — V1 left distribution charts type-empty while autogen sets "line" (equivalent).
353 func TestCollector_compatManifestV2(t *testing.T) {
354 for name, tc := range compatManifestCases() {
355 t.Run(name, func(t *testing.T) {
356 srv := httptest.NewServer(http.HandlerFunc(
357 func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(tc.input)) }))
358 defer srv.Close()
359
360 collr := tc.prepare()
361 collr.URL = srv.URL
362 require.NoError(t, collr.Init(context.Background()))
363
364 // Drive Collect exactly as the framework does: one store cycle around it.
365 cc := cycle(t, collr.MetricStore())
366 cc.BeginCycle()
367 require.NoError(t, collr.Collect(context.Background()))
368 require.NoError(t, cc.CommitCycleSuccess())
369
370 got := renderManifestV2(t, collr.MetricStore(), collr.ChartTemplateYAML())
371
372 data, err := os.ReadFile(filepath.Join("testdata", "golden", goldenName(name)+".json"))
373 require.NoError(t, err)
374 var want []manifestChart
375 require.NoError(t, json.Unmarshal(data, &want))
376
377 assertManifestParity(t, want, got)
378 })
379 }
380 }
381
382 // assertManifestParity checks the V2 render against the V1 golden. The chart set,
383 // labels, dimensions (name, algorithm, value), units, and family are asserted; only
384 // chart type is logged (autogen-derived, equivalent to V1's empty type).
385 func assertManifestParity(t *testing.T, want, got []manifestChart) {
386 t.Helper()
387
388 key := func(mc manifestChart) string { return mc.Context + "\x00" + manifestLabelsKey(mc.Labels) }
389
390 wantByKey := make(map[string]manifestChart, len(want))
391 for _, mc := range want {
392 k := key(mc)
393 _, dup := wantByKey[k]
394 require.Falsef(t, dup, "duplicate golden chart key (context=%q labels=%v)", mc.Context, mc.Labels)
395 wantByKey[k] = mc
396 }
397 gotByKey := make(map[string]manifestChart, len(got))
398 for _, mc := range got {
399 k := key(mc)
400 _, dup := gotByKey[k]
401 require.Falsef(t, dup, "duplicate V2 chart key (context=%q labels=%v)", mc.Context, mc.Labels)
402 gotByKey[k] = mc
403 }
404
405 for k, w := range wantByKey {
406 g, ok := gotByKey[k]
407 if !assert.Truef(t, ok, "V2 is missing chart context=%q labels=%v", w.Context, w.Labels) {
408 continue
409 }
410 assertDimsParity(t, w, g)
411 // Units and family are reproduced by feeding the V1 chart helpers into the
412 // metrix instrument meta, so they are asserted. Chart type is the one residual
413 // difference: V1 left distribution charts (histogram/summary) type-empty while
414 // autogen sets "line" — semantically identical (an empty type renders as line),
415 // so it is only logged.
416 assert.Equalf(t, w.Soft.Units, g.Soft.Units, "units for context=%q", w.Context)
417 assert.Equalf(t, w.Soft.Family, g.Soft.Family, "family for context=%q", w.Context)
418 if w.Soft.Type != g.Soft.Type {
419 t.Logf("chart type differs (cosmetic) context=%q: V1=%q V2=%q", w.Context, w.Soft.Type, g.Soft.Type)
420 }
421 }
422 for k, g := range gotByKey {
423 if _, ok := wantByKey[k]; !ok {
424 assert.Failf(t, "V2 produced an unexpected chart", "context=%q labels=%v", g.Context, g.Labels)
425 }
426 }
427 }
428
429 func assertDimsParity(t *testing.T, w, g manifestChart) {
430 t.Helper()
431
432 assert.Equalf(t, dimNames(w.Dims), dimNames(g.Dims), "dim names for context=%q", w.Context)
433
434 gotDims := make(map[string]manifestDim, len(g.Dims))
435 for _, d := range g.Dims {
436 gotDims[d.Name] = d
437 }
438 for _, wd := range w.Dims {
439 gd, ok := gotDims[wd.Name]
440 if !ok {
441 continue // already reported by the dim-names assertion
442 }
443 assert.Equalf(t, wd.Algo, gd.Algo, "algo for dim %q in context=%q", wd.Name, w.Context)
444 assert.InDeltaf(t, wd.Value, gd.Value, manifestValueTolerance, "value for dim %q in context=%q", wd.Name, w.Context)
445 }
446 }
447
448 func dimNames(dims []manifestDim) []string {
449 names := make([]string, 0, len(dims))
450 for _, d := range dims {
451 names = append(names, d.Name)
452 }
453 sort.Strings(names)
454 return names
455 }