master
go 485 lines 17 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package prometheus
4
5 import (
6 "math"
7 "net/http"
8 "net/http/httptest"
9 "strconv"
10 "testing"
11
12 "github.com/netdata/netdata/go/plugins/logger"
13 "github.com/netdata/netdata/go/plugins/pkg/matcher"
14 "github.com/netdata/netdata/go/plugins/pkg/metrix"
15 prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
16 "github.com/netdata/netdata/go/plugins/pkg/web"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20 )
21
22 func TestMetricFamilyWriter(t *testing.T) {
23 tests := map[string]struct {
24 exposition string
25 policy metricFamilyWriterPolicy
26 assert func(t *testing.T, fr metrix.Reader, written int)
27 }{
28 "gauge with label": {
29 exposition: `
30 # TYPE app_temp gauge
31 app_temp{sensor="cpu"} 12.5
32 `,
33 assert: func(t *testing.T, fr metrix.Reader, written int) {
34 assert.Equal(t, 1, written)
35 assert.InDelta(t, 12.5, value(t, fr, "app_temp", metrix.Labels{"sensor": "cpu"}), 1e-9)
36 },
37 },
38 "counter total": {
39 exposition: `
40 # TYPE app_requests_total counter
41 app_requests_total{code="200"} 42
42 `,
43 assert: func(t *testing.T, fr metrix.Reader, written int) {
44 assert.Equal(t, 1, written)
45 assert.InDelta(t, 42, value(t, fr, "app_requests_total", metrix.Labels{"code": "200"}), 1e-9)
46 },
47 },
48 "summary with quantiles sum count": {
49 exposition: `
50 # TYPE app_latency summary
51 app_latency{quantile="0.5"} 0.1
52 app_latency{quantile="0.9"} 0.4
53 app_latency_sum 5.0
54 app_latency_count 10
55 `,
56 assert: func(t *testing.T, fr metrix.Reader, written int) {
57 assert.Equal(t, 1, written)
58 assert.InDelta(t, 0.1, value(t, fr, "app_latency", metrix.Labels{"quantile": "0.5"}), 1e-9)
59 assert.InDelta(t, 0.4, value(t, fr, "app_latency", metrix.Labels{"quantile": "0.9"}), 1e-9)
60 assert.InDelta(t, 5.0, value(t, fr, "app_latency_sum", nil), 1e-9)
61 assert.InDelta(t, 10, value(t, fr, "app_latency_count", nil), 1e-9)
62 },
63 },
64 "summary with all-NaN quantiles (empty window) is skipped": {
65 exposition: `
66 # TYPE app_latency summary
67 app_latency{quantile="0.5"} NaN
68 app_latency{quantile="0.9"} NaN
69 app_latency_sum 0
70 app_latency_count 0
71 `,
72 assert: func(t *testing.T, fr metrix.Reader, written int) {
73 assert.Equal(t, 0, written, "an all-NaN summary is skipped so no chart is created until it has a value")
74 _, ok := fr.Value("app_latency", metrix.Labels{"quantile": "0.5"})
75 assert.False(t, ok, "no quantile series must be written for an all-NaN summary")
76 },
77 },
78 "summary with a mix of NaN and real quantiles is kept": {
79 exposition: `
80 # TYPE app_latency summary
81 app_latency{quantile="0.5"} NaN
82 app_latency{quantile="0.9"} 0.4
83 app_latency_sum 5
84 app_latency_count 10
85 `,
86 assert: func(t *testing.T, fr metrix.Reader, written int) {
87 assert.Equal(t, 1, written, "a summary with at least one observed quantile is written")
88 assert.InDelta(t, 0.4, value(t, fr, "app_latency", metrix.Labels{"quantile": "0.9"}), 1e-9)
89 v, ok := fr.Value("app_latency", metrix.Labels{"quantile": "0.5"})
90 require.True(t, ok, "the unobserved quantile is still stored when the summary has a real value")
91 assert.True(t, math.IsNaN(float64(v)), "the unobserved quantile is stored as NaN (a gap), got %v", v)
92 },
93 },
94 "histogram with buckets": {
95 exposition: `
96 # TYPE app_dur histogram
97 app_dur_bucket{le="0.1"} 1
98 app_dur_bucket{le="0.5"} 3
99 app_dur_bucket{le="+Inf"} 4
100 app_dur_sum 0.9
101 app_dur_count 4
102 `,
103 assert: func(t *testing.T, fr metrix.Reader, written int) {
104 assert.Equal(t, 1, written)
105 assert.InDelta(t, 1, value(t, fr, "app_dur_bucket", metrix.Labels{"le": "0.1"}), 1e-9)
106 assert.InDelta(t, 3, value(t, fr, "app_dur_bucket", metrix.Labels{"le": "0.5"}), 1e-9)
107 assert.InDelta(t, 4, value(t, fr, "app_dur_count", nil), 1e-9)
108 assert.InDelta(t, 0.9, value(t, fr, "app_dur_sum", nil), 1e-9)
109 },
110 },
111 "histogram with a malformed +Inf bucket is normalized, not skipped": {
112 exposition: `
113 # TYPE app_hist histogram
114 app_hist_bucket{le="1"} 5
115 app_hist_bucket{le="+Inf"} 0
116 app_hist_sum 2.5
117 app_hist_count 5
118 `,
119 assert: func(t *testing.T, fr metrix.Reader, written int) {
120 assert.Equal(t, 1, written, "a malformed +Inf count must not skip the whole histogram (Count supersedes +Inf)")
121 assert.InDelta(t, 5, value(t, fr, "app_hist_bucket", metrix.Labels{"le": "1"}), 1e-9)
122 assert.InDelta(t, 5, value(t, fr, "app_hist_count", nil), 1e-9)
123 },
124 },
125 "family with heterogeneous label keys writes every series": {
126 exposition: `
127 # TYPE app_state gauge
128 app_state{az="a"} 1
129 app_state{region="eu",az="b"} 2
130 `,
131 assert: func(t *testing.T, fr metrix.Reader, written int) {
132 assert.Equal(t, 2, written, "both series must be written despite differing label keys")
133 assert.InDelta(t, 1, value(t, fr, "app_state", metrix.Labels{"az": "a"}), 1e-9)
134 assert.InDelta(t, 2, value(t, fr, "app_state", metrix.Labels{"region": "eu", "az": "b"}), 1e-9)
135 },
136 },
137 "skips NaN scalar value": {
138 exposition: `
139 # TYPE app_temp gauge
140 app_temp{sensor="ok"} 3
141 app_temp{sensor="bad"} NaN
142 `,
143 assert: func(t *testing.T, fr metrix.Reader, written int) {
144 assert.Equal(t, 1, written)
145 _, ok := fr.Value("app_temp", metrix.Labels{"sensor": "bad"})
146 assert.False(t, ok, "NaN scalar series must be skipped, not written")
147 },
148 },
149 "skips summary series with Inf quantile value": {
150 exposition: `
151 # TYPE app_latency summary
152 app_latency{quantile="0.5"} +Inf
153 app_latency_sum 1
154 app_latency_count 1
155 `,
156 assert: func(t *testing.T, fr metrix.Reader, written int) {
157 assert.Equal(t, 0, written, "summary with an infinite quantile value must be skipped")
158 },
159 },
160 "applies label_prefix to label keys": {
161 exposition: `
162 # TYPE app_temp gauge
163 app_temp{sensor="cpu"} 7
164 `,
165 policy: metricFamilyWriterPolicy{labelPrefix: "px"},
166 assert: func(t *testing.T, fr metrix.Reader, written int) {
167 assert.Equal(t, 1, written)
168 assert.InDelta(t, 7, value(t, fr, "app_temp", metrix.Labels{"px_sensor": "cpu"}), 1e-9)
169 _, ok := fr.Value("app_temp", metrix.Labels{"sensor": "cpu"})
170 assert.False(t, ok, "unprefixed label key must not exist")
171 },
172 },
173 "skips _info family": {
174 exposition: `
175 # TYPE app_build_info gauge
176 app_build_info{version="1.2.3"} 1
177 `,
178 assert: func(t *testing.T, fr metrix.Reader, written int) {
179 assert.Equal(t, 0, written, "_info family must be skipped entirely")
180 },
181 },
182 "skips family exceeding maxTSPerMetric": {
183 exposition: `
184 # TYPE app_temp gauge
185 app_temp{id="1"} 1
186 app_temp{id="2"} 2
187 app_temp{id="3"} 3
188 `,
189 policy: metricFamilyWriterPolicy{maxTSPerMetric: 2},
190 assert: func(t *testing.T, fr metrix.Reader, written int) {
191 assert.Equal(t, 0, written, "family over the per-metric series limit must be skipped")
192 },
193 },
194 "untyped falls back to gauge and counter": {
195 exposition: `
196 app_fallback_gauge 7
197 app_things_total 5
198 `,
199 policy: metricFamilyWriterPolicy{
200 isFallbackTypeGauge: matcher.Must(matcher.NewGlobMatcher("app_fallback_gauge")),
201 },
202 assert: func(t *testing.T, fr metrix.Reader, written int) {
203 assert.Equal(t, 2, written)
204 assert.InDelta(t, 7, value(t, fr, "app_fallback_gauge", nil), 1e-9)
205 assert.InDelta(t, 5, value(t, fr, "app_things_total", nil), 1e-9)
206 },
207 },
208 "float bucket-bound label format": {
209 exposition: `
210 # TYPE app_size histogram
211 app_size_bucket{le="0.00001"} 1
212 app_size_bucket{le="1000000"} 2
213 app_size_bucket{le="+Inf"} 2
214 app_size_sum 3
215 app_size_count 2
216 `,
217 assert: func(t *testing.T, fr metrix.Reader, written int) {
218 assert.Equal(t, 1, written)
219 // metrix formats the flattened bucket "le" label with strconv 'g' (V1 used 'f'), so
220 // scientific-notation bounds get different dimension names. This pins the metrix
221 // 'g' format.
222 for _, bound := range []float64{0.00001, 1000000} {
223 leG := strconv.FormatFloat(bound, 'g', -1, 64)
224 _, ok := fr.Value("app_size_bucket", metrix.Labels{"le": leG})
225 assert.Truef(t, ok, "bucket le label expected in metrix 'g' format %q (V1 'f' was %q)",
226 leG, strconv.FormatFloat(bound, 'f', -1, 64))
227 }
228 },
229 },
230 "metadata: summary unit gets /s and title is sanitized": {
231 exposition: `
232 # HELP app_resp_bytes Response 'size' in bytes.
233 # TYPE app_resp_bytes summary
234 app_resp_bytes{quantile="0.5"} 100
235 app_resp_bytes_sum 500
236 app_resp_bytes_count 5
237 `,
238 assert: func(t *testing.T, fr metrix.Reader, written int) {
239 assert.Equal(t, 1, written)
240 mm := mustMeta(t, fr, "app_resp_bytes")
241 assert.Equal(t, "bytes/s", mm.Unit, "V1 appends /s to summary quantile units")
242 assert.Equal(t, "Response size in bytes", mm.Description, "title strips apostrophes and a trailing period")
243 assert.True(t, mm.Float)
244 assert.Equal(t, getChartFamily("app_resp_bytes"), mm.ChartFamily)
245 assert.Equal(t, getChartPriority("app_resp_bytes"), mm.ChartPriority)
246 },
247 },
248 "metadata: gauge unit has no /s": {
249 exposition: `
250 # TYPE app_used_bytes gauge
251 app_used_bytes 10
252 `,
253 assert: func(t *testing.T, fr metrix.Reader, written int) {
254 assert.Equal(t, "bytes", mustMeta(t, fr, "app_used_bytes").Unit)
255 },
256 },
257 "metadata: counter unit is the base (autogen's incremental route adds /s)": {
258 exposition: `
259 # TYPE app_io_bytes_total counter
260 app_io_bytes_total 5
261 `,
262 assert: func(t *testing.T, fr metrix.Reader, written int) {
263 assert.Equal(t, "bytes", mustMeta(t, fr, "app_io_bytes_total").Unit)
264 },
265 },
266 "metadata: empty HELP yields V1 default title": {
267 exposition: `
268 # TYPE app_widgets gauge
269 app_widgets 3
270 `,
271 assert: func(t *testing.T, fr metrix.Reader, written int) {
272 assert.Equal(t, `Metric "app_widgets"`, mustMeta(t, fr, "app_widgets").Description)
273 },
274 },
275 }
276
277 for name, tc := range tests {
278 t.Run(name, func(t *testing.T) {
279 store := metrix.NewCollectorStore()
280 w := newMetricFamilyWriter(store, tc.policy, logger.New())
281
282 mfs := scrape(t, tc.exposition)
283
284 cc := cycle(t, store)
285 cc.BeginCycle()
286 written := w.writeMetricFamilies(mfs)
287 require.NoError(t, cc.CommitCycleSuccess())
288
289 tc.assert(t, store.Read(metrix.ReadFlatten()), written)
290 })
291 }
292 }
293
294 func scrape(t *testing.T, exposition string) prompkg.MetricFamilies {
295 t.Helper()
296 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
297 _, _ = w.Write([]byte(exposition))
298 }))
299 t.Cleanup(srv.Close)
300
301 mfs, err := prompkg.New(srv.Client(), web.RequestConfig{URL: srv.URL}).Scrape()
302 require.NoError(t, err)
303 return mfs
304 }
305
306 func cycle(t *testing.T, store metrix.CollectorStore) metrix.CycleController {
307 t.Helper()
308 managed, ok := metrix.AsCycleManagedStore(store)
309 require.True(t, ok)
310 return managed.CycleController()
311 }
312
313 func value(t *testing.T, fr metrix.Reader, name string, labels metrix.Labels) float64 {
314 t.Helper()
315 v, ok := fr.Value(name, labels)
316 require.Truef(t, ok, "expected flattened series %q labels=%v", name, labels)
317 return float64(v)
318 }
319
320 func mustMeta(t *testing.T, fr metrix.Reader, name string) metrix.MetricMeta {
321 t.Helper()
322 mm, ok := fr.MetricMeta(name)
323 require.Truef(t, ok, "expected MetricMeta for %q", name)
324 return mm
325 }
326
327 func TestMetricFamilyWriterEdgeCases(t *testing.T) {
328 t.Run("countWritable counts writable series and skips _info", func(t *testing.T) {
329 store := metrix.NewCollectorStore()
330 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
331 mfs := scrape(t, `
332 # TYPE app_a gauge
333 app_a{x="1"} 1
334 app_a{x="2"} 2
335 # TYPE app_b_info gauge
336 app_b_info{v="x"} 1
337 `)
338 assert.Equal(t, 2, w.countWritable(mfs))
339 })
340
341 t.Run("metric type drift skips the family after the type changes", func(t *testing.T) {
342 store := metrix.NewCollectorStore()
343 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
344 cc := cycle(t, store)
345
346 cc.BeginCycle()
347 require.Equal(t, 1, w.writeMetricFamilies(scrape(t, "# TYPE app_x gauge\napp_x 1\n")))
348 require.NoError(t, cc.CommitCycleSuccess())
349
350 cc.BeginCycle()
351 assert.Equal(t, 0, w.writeMetricFamilies(scrape(t, "# TYPE app_x counter\napp_x 5\n")),
352 "same metric name with a changed type must be skipped")
353 require.NoError(t, cc.CommitCycleSuccess())
354 })
355
356 t.Run("summary distribution-schema drift skips the off-schema series", func(t *testing.T) {
357 store := metrix.NewCollectorStore()
358 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
359 cc := cycle(t, store)
360
361 cc.BeginCycle()
362 written := w.writeMetricFamilies(scrape(t, `
363 # TYPE app_lat summary
364 app_lat{id="a",quantile="0.5"} 1
365 app_lat_sum{id="a"} 1
366 app_lat_count{id="a"} 1
367 app_lat{id="b",quantile="0.5"} 2
368 app_lat{id="b",quantile="0.9"} 3
369 app_lat_sum{id="b"} 5
370 app_lat_count{id="b"} 2
371 `))
372 require.NoError(t, cc.CommitCycleSuccess())
373
374 assert.Equal(t, 1, written, "the series whose quantile set differs from the family canonical is skipped")
375 fr := store.Read(metrix.ReadFlatten())
376 _, ok := fr.Value("app_lat", metrix.Labels{"id": "a", "quantile": "0.5"})
377 assert.True(t, ok, "canonical-schema series is written")
378 _, ok = fr.Value("app_lat", metrix.Labels{"id": "b", "quantile": "0.5"})
379 assert.False(t, ok, "off-schema series is skipped")
380 })
381
382 t.Run("histogram distribution-schema drift skips the off-schema series", func(t *testing.T) {
383 store := metrix.NewCollectorStore()
384 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
385 cc := cycle(t, store)
386
387 cc.BeginCycle()
388 written := w.writeMetricFamilies(scrape(t, `
389 # TYPE app_lat histogram
390 app_lat_bucket{id="a",le="0.1"} 1
391 app_lat_bucket{id="a",le="0.5"} 2
392 app_lat_bucket{id="a",le="+Inf"} 2
393 app_lat_sum{id="a"} 1
394 app_lat_count{id="a"} 2
395 app_lat_bucket{id="b",le="0.1"} 1
396 app_lat_bucket{id="b",le="0.5"} 2
397 app_lat_bucket{id="b",le="1"} 3
398 app_lat_bucket{id="b",le="+Inf"} 3
399 app_lat_sum{id="b"} 5
400 app_lat_count{id="b"} 3
401 `))
402 require.NoError(t, cc.CommitCycleSuccess())
403
404 assert.Equal(t, 1, written, "the series whose bucket bounds differ from the family canonical is skipped")
405 fr := store.Read(metrix.ReadFlatten())
406 _, ok := fr.Value("app_lat_bucket", metrix.Labels{"id": "a", "le": "0.1"})
407 assert.True(t, ok, "canonical-schema series is written")
408 _, ok = fr.Value("app_lat_bucket", metrix.Labels{"id": "b", "le": "0.1"})
409 assert.False(t, ok, "off-schema series is skipped")
410 })
411
412 t.Run("evicts cached series handles after the retention window", func(t *testing.T) {
413 store := metrix.NewCollectorStore()
414 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
415 cc := cycle(t, store)
416
417 cc.BeginCycle()
418 w.writeMetricFamilies(scrape(t, "# TYPE app_g gauge\napp_g{id=\"1\"} 1\n"))
419 require.NoError(t, cc.CommitCycleSuccess())
420 require.Len(t, w.handles["app_g"].gauges, 1)
421
422 // Only id="2" appears for the next retention window, so id="1" goes unobserved and its
423 // cached handle must be evicted (otherwise the cache would grow unbounded under value churn).
424 for range seriesCacheRetentionCycles {
425 cc.BeginCycle()
426 w.writeMetricFamilies(scrape(t, "# TYPE app_g gauge\napp_g{id=\"2\"} 2\n"))
427 require.NoError(t, cc.CommitCycleSuccess())
428 }
429
430 assert.Len(t, w.handles["app_g"].gauges, 1, "stale series handle must be evicted, leaving only the active one")
431 })
432
433 t.Run("reappearing metric name with a changed type is skipped, never re-registered (no panic)", func(t *testing.T) {
434 store := metrix.NewCollectorStore()
435 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
436 cc := cycle(t, store)
437
438 cc.BeginCycle()
439 require.Equal(t, 1, w.writeMetricFamilies(scrape(t, "# TYPE foo gauge\nfoo 1\n")))
440 require.NoError(t, cc.CommitCycleSuccess())
441
442 // foo is absent well beyond the per-series retention window; its series handle is evicted, but
443 // the family handle is kept.
444 for range seriesCacheRetentionCycles + 5 {
445 cc.BeginCycle()
446 require.NoError(t, cc.CommitCycleSuccess())
447 }
448
449 // foo reappears as a counter. metrix's descriptor for "foo" is a permanent gauge, so
450 // re-registering it as a counter would panic; the kept handle must detect the drift and skip.
451 cc.BeginCycle()
452 assert.NotPanics(t, func() {
453 assert.Equal(t, 0, w.writeMetricFamilies(scrape(t, "# TYPE foo counter\nfoo 5\n")),
454 "a reappearing name with a changed type must be skipped")
455 })
456 require.NoError(t, cc.CommitCycleSuccess())
457 })
458
459 t.Run("reappearing metric name with changed summary quantiles is skipped (no panic)", func(t *testing.T) {
460 store := metrix.NewCollectorStore()
461 w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
462 cc := cycle(t, store)
463
464 cc.BeginCycle()
465 require.Equal(t, 1, w.writeMetricFamilies(scrape(t,
466 "# TYPE app_lat summary\napp_lat{quantile=\"0.5\"} 1\napp_lat_sum 1\napp_lat_count 1\n")))
467 require.NoError(t, cc.CommitCycleSuccess())
468
469 // app_lat absent beyond the per-series retention window (series handle evicted, family handle kept).
470 for range seriesCacheRetentionCycles + 5 {
471 cc.BeginCycle()
472 require.NoError(t, cc.CommitCycleSuccess())
473 }
474
475 // app_lat reappears with a different quantile set. metrix's summary descriptor for "app_lat" is
476 // fixed to {0.5}; observing {0.5,0.9} would panic, so the kept handle must skip the drifted series.
477 cc.BeginCycle()
478 assert.NotPanics(t, func() {
479 assert.Equal(t, 0, w.writeMetricFamilies(scrape(t,
480 "# TYPE app_lat summary\napp_lat{quantile=\"0.5\"} 1\napp_lat{quantile=\"0.9\"} 2\napp_lat_sum 3\napp_lat_count 2\n")),
481 "a reappearing summary with a changed quantile set must be skipped")
482 })
483 require.NoError(t, cc.CommitCycleSuccess())
484 })
485 }