| 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 | } |