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