master
go 453 lines 11.3 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package collecttest
4
5 import (
6 "context"
7 "errors"
8 "sort"
9 "testing"
10
11 "github.com/netdata/netdata/go/plugins/pkg/metrix"
12 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 func TestScalarKeyFromLabelsMap(t *testing.T) {
18 tests := map[string]struct {
19 name string
20 labels map[string]string
21 want string
22 }{
23 "no labels": {
24 name: "metric",
25 want: "metric",
26 },
27 "sorted labels": {
28 name: "metric",
29 labels: map[string]string{"z": "2", "a": "1"},
30 want: "metric{a=\"1\",z=\"2\"}",
31 },
32 }
33
34 for name, tc := range tests {
35 t.Run(name, func(t *testing.T) {
36 got := scalarKeyFromLabelsMap(tc.name, tc.labels)
37 assert.Equal(t, tc.want, got)
38 })
39 }
40 }
41
42 func TestMaterializedChartsFiltersByContextAndID(t *testing.T) {
43 plan := chartengine.Plan{
44 Actions: []chartengine.EngineAction{
45 chartengine.CreateChartAction{ChartID: "keep", Meta: chartengine.ChartMeta{Context: "ctx.keep"}},
46 chartengine.CreateDimensionAction{ChartID: "keep", ChartMeta: chartengine.ChartMeta{Context: "ctx.keep"}, Name: "dimA"},
47 chartengine.CreateChartAction{ChartID: "drop-by-context", Meta: chartengine.ChartMeta{Context: "ctx.drop"}},
48 chartengine.CreateDimensionAction{ChartID: "drop-by-context", ChartMeta: chartengine.ChartMeta{Context: "ctx.drop"}, Name: "dimB"},
49 chartengine.CreateChartAction{ChartID: "drop-by-id", Meta: chartengine.ChartMeta{Context: "ctx.keep2"}},
50 chartengine.CreateDimensionAction{ChartID: "drop-by-id", ChartMeta: chartengine.ChartMeta{Context: "ctx.keep2"}, Name: "dimC"},
51 },
52 }
53
54 got := materializedCharts(plan, planFilter{
55 ExcludeContexts: map[string]struct{}{"ctx.drop": {}},
56 ExcludeChartIDs: map[string]struct{}{"drop-by-id": {}},
57 })
58
59 assert.Len(t, got, 1)
60 chart, ok := got["keep"]
61 assert.True(t, ok)
62 assert.Equal(t, "ctx.keep", chart.Context)
63 _, hasDim := chart.Dimensions["dimA"]
64 assert.True(t, hasDim)
65 }
66
67 func TestBuildChartCoverage(t *testing.T) {
68 tests := map[string]struct {
69 excludePatterns []string
70 wantExpected map[string][]string
71 wantActual map[string][]string
72 wantErr bool
73 }{
74 "selector-aware expected coverage with glob exclude": {
75 excludePatterns: []string{"test.c"},
76 wantExpected: map[string][]string{
77 "test.a": {"x"},
78 },
79 wantActual: map[string][]string{
80 "test.a": {"x"},
81 },
82 },
83 "invalid glob pattern": {
84 excludePatterns: []string{"["},
85 wantErr: true,
86 },
87 }
88
89 for name, tc := range tests {
90 t.Run(name, func(t *testing.T) {
91 store := newTestCollectorStore(t, func(m metrix.SnapshotMeter) {
92 m.Gauge("metric_a").Observe(1)
93 m.Gauge("metric_b").Observe(2)
94 })
95
96 templateYAML := `
97 version: v1
98 context_namespace: test
99 groups:
100 - family: Root
101 metrics: [metric_a, metric_b]
102 charts:
103 - title: A
104 context: a
105 units: "1"
106 dimensions:
107 - selector: metric_a
108 name: x
109 - title: B
110 context: b
111 units: "1"
112 dimensions:
113 - selector: metric_b{role="missing"}
114 name: y
115 - title: C
116 context: c
117 units: "1"
118 dimensions:
119 - selector: metric_b
120 name: z
121 `
122
123 coverage, err := buildChartCoverage(templateYAML, 1, store.Read(metrix.ReadRaw()), tc.excludePatterns)
124 if tc.wantErr {
125 require.Error(t, err)
126 return
127 }
128 require.NoError(t, err)
129 require.Equal(t, normalizeCoverageDimsList(tc.wantExpected), normalizeCoverageDimsList(coverage.ExpectedByContext))
130 require.Equal(t, normalizeCoverageDimsList(tc.wantActual), normalizeCoverageDims(coverage.ActualByContext))
131 })
132 }
133 }
134
135 func TestValidateChartTemplateSchema(t *testing.T) {
136 tests := map[string]struct {
137 template string
138 wantErr bool
139 }{
140 "valid template": {
141 template: `
142 version: v1
143 groups:
144 - family: Root
145 metrics: [metric_a]
146 charts:
147 - title: A
148 context: a
149 units: "1"
150 dimensions:
151 - selector: metric_a
152 name: x
153 `,
154 },
155 "schema rejects missing version": {
156 template: `
157 groups:
158 - family: Root
159 metrics: [metric_a]
160 charts:
161 - title: A
162 context: a
163 units: "1"
164 dimensions:
165 - selector: metric_a
166 name: x
167 `,
168 wantErr: true,
169 },
170 "schema rejects unknown field": {
171 template: `
172 version: v1
173 groups:
174 - family: Root
175 metrics: [metric_a]
176 charts:
177 - title: A
178 context: a
179 units: "1"
180 unknown_field: true
181 dimensions:
182 - selector: metric_a
183 name: x
184 `,
185 wantErr: true,
186 },
187 "schema rejects non-string YAML key": {
188 template: `
189 version: v1
190 groups:
191 - family: Root
192 metrics: [metric_a]
193 charts:
194 - title: A
195 context: a
196 units: "1"
197 1: invalid
198 dimensions:
199 - selector: metric_a
200 name: x
201 `,
202 wantErr: true,
203 },
204 }
205
206 for name, tc := range tests {
207 t.Run(name, func(t *testing.T) {
208 err := ValidateChartTemplateSchema(tc.template)
209 if tc.wantErr {
210 require.Error(t, err)
211 return
212 }
213 require.NoError(t, err)
214 })
215 }
216 }
217
218 func TestBuildChartCoveragesFromStoreKeepsHostScopesSeparate(t *testing.T) {
219 scopeA := metrix.HostScope{ScopeKey: "scope-a", GUID: "guid-a", Hostname: "host-a"}
220 scopeB := metrix.HostScope{ScopeKey: "scope-b", GUID: "guid-b", Hostname: "host-b"}
221 store := newTestCollectorStore(t, func(m metrix.SnapshotMeter) {
222 m.WithHostScope(scopeA).Gauge("metric_a").Observe(1)
223 m.WithHostScope(scopeB).Gauge("metric_b").Observe(2)
224 })
225
226 templateYAML := `
227 version: v1
228 context_namespace: test
229 groups:
230 - family: Root
231 metrics: [metric_a, metric_b]
232 charts:
233 - title: A
234 context: a
235 units: "1"
236 dimensions:
237 - selector: metric_a
238 name: x
239 - title: B
240 context: b
241 units: "1"
242 dimensions:
243 - selector: metric_b
244 name: y
245 `
246
247 coverages, err := buildChartCoveragesFromStore(templateYAML, 1, store, nil)
248 require.NoError(t, err)
249 require.Len(t, coverages, 2)
250
251 byScope := make(map[string]chartCoverage, len(coverages))
252 for _, scoped := range coverages {
253 byScope[scoped.ScopeKey] = scoped.Coverage
254 }
255
256 require.Equal(t, map[string][]string{"test.a": {"x"}}, normalizeCoverageDimsList(byScope[scopeA.ScopeKey].ExpectedByContext))
257 require.Equal(t, map[string][]string{"test.a": {"x"}}, normalizeCoverageDims(byScope[scopeA.ScopeKey].ActualByContext))
258 require.Equal(t, map[string][]string{"test.b": {"y"}}, normalizeCoverageDimsList(byScope[scopeB.ScopeKey].ExpectedByContext))
259 require.Equal(t, map[string][]string{"test.b": {"y"}}, normalizeCoverageDims(byScope[scopeB.ScopeKey].ActualByContext))
260 }
261
262 func TestCollectOnceAbortsCycleOnPanic(t *testing.T) {
263 store := metrix.NewCollectorStore()
264
265 require.Panics(t, func() {
266 _ = collectOnce(store, func(context.Context) error {
267 panic("boom")
268 })
269 })
270
271 err := collectOnce(store, func(_ context.Context) error {
272 store.Write().SnapshotMeter("").Gauge("metric").Observe(1)
273 return nil
274 })
275 require.NoError(t, err)
276 }
277
278 func TestBuildChartCoverageDynamicDimensions(t *testing.T) {
279 tests := map[string]struct {
280 template string
281 store metrix.CollectorStore
282 want map[string][]string
283 }{
284 "name_from_label dimensions are asserted from selector matches": {
285 template: `
286 version: v1
287 context_namespace: test
288 groups:
289 - family: Root
290 metrics: [metric_a]
291 charts:
292 - title: A
293 context: a
294 units: "1"
295 dimensions:
296 - selector: metric_a
297 name_from_label: state
298 `,
299 store: newTestCollectorStore(t, func(m metrix.SnapshotMeter) {
300 g := m.Gauge("metric_a")
301 g.Observe(1, m.LabelSet(metrix.Label{Key: "state", Value: "up"}))
302 g.Observe(1, m.LabelSet(metrix.Label{Key: "state", Value: "down"}))
303 }),
304 want: map[string][]string{
305 "test.a": {"down", "up"},
306 },
307 },
308 "inferred stateset dimensions are asserted from flattened metadata": {
309 template: `
310 version: v1
311 context_namespace: test
312 groups:
313 - family: Root
314 metrics: [system.status]
315 charts:
316 - title: System status
317 context: status
318 units: state
319 dimensions:
320 - selector: system.status
321 `,
322 store: newTestCollectorStore(t, func(m metrix.SnapshotMeter) {
323 ss := m.StateSet(
324 "system.status",
325 metrix.WithStateSetStates("ok", "failed"),
326 metrix.WithStateSetMode(metrix.ModeEnum),
327 )
328 ss.Enable("ok")
329 }),
330 want: map[string][]string{
331 "test.status": {"failed", "ok"},
332 },
333 },
334 }
335
336 for name, tc := range tests {
337 t.Run(name, func(t *testing.T) {
338 coverage, err := buildChartCoverage(
339 tc.template,
340 1,
341 tc.store.Read(metrix.ReadRaw(), metrix.ReadFlatten()),
342 nil,
343 )
344 require.NoError(t, err)
345 require.Equal(t, normalizeCoverageDimsList(tc.want), normalizeCoverageDimsList(coverage.ExpectedByContext))
346 require.Equal(t, normalizeCoverageDimsList(tc.want), normalizeCoverageDims(coverage.ActualByContext))
347 })
348 }
349 }
350
351 func TestCollectScalarSeries(t *testing.T) {
352 tests := map[string]struct {
353 collectFn func(ctx context.Context, store metrix.CollectorStore) error
354 want map[string]metrix.SampleValue
355 wantErr bool
356 }{
357 "nil collector": {
358 wantErr: true,
359 },
360 "collects scalar series from one cycle": {
361 collectFn: func(_ context.Context, store metrix.CollectorStore) error {
362 m := store.Write().SnapshotMeter("")
363 m.Gauge("metric").Observe(7)
364 return nil
365 },
366 want: map[string]metrix.SampleValue{
367 "metric": 7,
368 },
369 },
370 "returns collect error": {
371 collectFn: func(_ context.Context, _ metrix.CollectorStore) error {
372 return errors.New("boom")
373 },
374 wantErr: true,
375 },
376 }
377
378 for name, tc := range tests {
379 t.Run(name, func(t *testing.T) {
380 var collector interface {
381 MetricStore() metrix.CollectorStore
382 Collect(context.Context) error
383 }
384 if tc.collectFn != nil {
385 store := metrix.NewCollectorStore()
386 collector = testScalarCollector{
387 store: store,
388 collectFn: func(ctx context.Context) error {
389 return tc.collectFn(ctx, store)
390 },
391 }
392 }
393
394 got, err := CollectScalarSeries(collector, metrix.ReadRaw())
395 if tc.wantErr {
396 require.Error(t, err)
397 return
398 }
399 require.NoError(t, err)
400 require.Equal(t, tc.want, got)
401 })
402 }
403 }
404
405 type testScalarCollector struct {
406 store metrix.CollectorStore
407 collectFn func(context.Context) error
408 }
409
410 func (c testScalarCollector) MetricStore() metrix.CollectorStore {
411 return c.store
412 }
413
414 func (c testScalarCollector) Collect(ctx context.Context) error {
415 return c.collectFn(ctx)
416 }
417
418 func newTestCollectorStore(t *testing.T, writeFn func(m metrix.SnapshotMeter)) metrix.CollectorStore {
419 t.Helper()
420
421 store := metrix.NewCollectorStore()
422 managed, ok := metrix.AsCycleManagedStore(store)
423 require.True(t, ok)
424
425 cc := managed.CycleController()
426 cc.BeginCycle()
427 writeFn(store.Write().SnapshotMeter(""))
428 cc.CommitCycleSuccess()
429 return store
430 }
431
432 func normalizeCoverageDims(in map[string]map[string]struct{}) map[string][]string {
433 out := make(map[string][]string, len(in))
434 for contextName, dimSet := range in {
435 dims := make([]string, 0, len(dimSet))
436 for dimName := range dimSet {
437 dims = append(dims, dimName)
438 }
439 sort.Strings(dims)
440 out[contextName] = dims
441 }
442 return out
443 }
444
445 func normalizeCoverageDimsList(in map[string][]string) map[string][]string {
446 out := make(map[string][]string, len(in))
447 for contextName, dims := range in {
448 clone := append([]string(nil), dims...)
449 sort.Strings(clone)
450 out[contextName] = clone
451 }
452 return out
453 }