chore(go.d/prometheus): migrate to framework v2 (#22651)
Ilya Mashchenko committed
Jun 8, 2026 at 10:36 UTC
762744214db37e417aa92f48c7c1f80ae7c20103
18 files changed
+1974
-1133
src/go/pkg/prometheus/metric_family.go
+10
@@ -3,6 +3,9 @@
3
package prometheus
4
5
import (
6
+ "math"
7
+ "slices"
8
+
9
"github.com/prometheus/common/model"
10
"github.com/prometheus/prometheus/model/labels"
11
)
@@ -105,6 +108,13 @@ func (s Summary) Count() float64 { return s.count }
108
func (s Summary) Sum() float64 { return s.sum }
109
func (s Summary) Quantiles() []Quantile { return s.quantiles }
110
111
+// IsNaN reports whether every quantile value is NaN, which a Prometheus summary emits for an
112
+// empty observation window (a summary with no quantiles also reports true). Callers skip such
113
+// a summary so a chart is not created until it carries a real value.
114
+func (s Summary) IsNaN() bool {
115
+ return !slices.ContainsFunc(s.quantiles, func(q Quantile) bool { return !math.IsNaN(q.value) })
116
+}
117
+
118
func (q Quantile) Quantile() float64 { return q.quantile }
119
func (q Quantile) Value() float64 { return q.value }
120
src/go/pkg/prometheus/metric_family_test.go
+31
@@ -1,6 +1,7 @@
1
package prometheus
2
3
import (
4
+ "math"
5
"testing"
6
7
"github.com/prometheus/common/model"
@@ -324,6 +325,36 @@ func TestSummary_Quantiles(t *testing.T) {
325
)
326
}
327
328
+func TestSummary_IsNaN(t *testing.T) {
329
+ tests := map[string]struct {
330
+ summary Summary
331
+ want bool
332
+ }{
333
+ "all quantiles NaN": {
334
+ summary: Summary{quantiles: []Quantile{{quantile: 0.5, value: math.NaN()}, {quantile: 0.9, value: math.NaN()}}},
335
+ want: true,
336
+ },
337
+ "no quantiles": {
338
+ summary: Summary{},
339
+ want: true,
340
+ },
341
+ "mix of NaN and real quantiles": {
342
+ summary: Summary{quantiles: []Quantile{{quantile: 0.5, value: math.NaN()}, {quantile: 0.9, value: 0.4}}},
343
+ want: false,
344
+ },
345
+ "all quantiles real": {
346
+ summary: Summary{quantiles: []Quantile{{quantile: 0.5, value: 0.1}, {quantile: 0.9, value: 0.4}}},
347
+ want: false,
348
+ },
349
+ }
350
+
351
+ for name, test := range tests {
352
+ t.Run(name, func(t *testing.T) {
353
+ assert.Equal(t, test.want, test.summary.IsNaN())
354
+ })
355
+ }
356
+}
357
+
358
func TestQuantile_Value(t *testing.T) {
359
assert.Equal(t, Quantile{value: 1}.Value(), 1.0)
360
}
src/go/plugin/go.d/collector/prometheus/cache.go
deleted
-41
@@ -1,41 +0,0 @@
1
-// SPDX-License-Identifier: GPL-3.0-or-later
2
-
3
-package prometheus
4
-
5
-import (
6
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
7
-)
8
-
9
-func newCache() *cache {
10
- return &cache{entries: make(map[string]*cacheEntry)}
11
-}
12
-
13
-type (
14
- cache struct {
15
- entries map[string]*cacheEntry
16
- }
17
-
18
- cacheEntry struct {
19
- seen bool
20
- notSeenTimes int
21
- charts []*collectorapi.Chart
22
- }
23
-)
24
-
25
-func (c *cache) hasP(key string) bool {
26
- v, ok := c.entries[key]
27
- if !ok {
28
- v = &cacheEntry{}
29
- c.entries[key] = v
30
- }
31
- v.seen = true
32
- v.notSeenTimes = 0
33
-
34
- return ok
35
-}
36
-
37
-func (c *cache) addChart(key string, chart *collectorapi.Chart) {
38
- if v, ok := c.entries[key]; ok {
39
- v.charts = append(v.charts, chart)
40
- }
41
-}
src/go/plugin/go.d/collector/prometheus/chart_meta.go
new
+125
@@ -0,0 +1,125 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package prometheus
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
10
+ commonmodel "github.com/prometheus/common/model"
11
+)
12
+
13
+const (
14
+ prioDefault = collectorapi.Priority
15
+ prioGORuntime = prioDefault + 10
16
+)
17
+
18
+// application is the "app" segment of a chart context: the configured Application, else
19
+// the job Name. It selects the per-job chart-template namespace ("prometheus" when empty,
20
+// else "prometheus.<app>").
21
+func (c *Collector) application() string {
22
+ if c.Application != "" {
23
+ return c.Application
24
+ }
25
+ return c.Name
26
+}
27
+
28
+// getChartTitle derives a chart title (description) from the metric HELP, falling back to
29
+// the metric name when HELP is absent. It is fed into the metrix instrument meta so
30
+// chartengine autogen reproduces the V1 chart title.
31
+func getChartTitle(name, help string) string {
32
+ if help == "" {
33
+ return fmt.Sprintf("Metric \"%s\"", name)
34
+ }
35
+
36
+ help = strings.ReplaceAll(help, "'", "")
37
+ help = strings.TrimSuffix(help, ".")
38
+
39
+ return help
40
+}
41
+
42
+func getChartFamily(name string) (fam string) {
43
+ if strings.HasPrefix(name, "go_") {
44
+ return "go"
45
+ }
46
+ if strings.HasPrefix(name, "process_") {
47
+ return "process"
48
+ }
49
+ if parts := strings.SplitN(name, "_", 3); len(parts) < 3 {
50
+ fam = name
51
+ } else {
52
+ fam = parts[0] + "_" + parts[1]
53
+ }
54
+
55
+ // remove number suffix if any
56
+ // load1, load5, load15 => load
57
+ i := len(fam) - 1
58
+ for i >= 0 && fam[i] >= '0' && fam[i] <= '9' {
59
+ i--
60
+ }
61
+ if i > 0 {
62
+ return fam[:i+1]
63
+ }
64
+ return fam
65
+}
66
+
67
+func getChartUnits(name string) string {
68
+ // https://prometheus.io/docs/practices/naming/#metric-names
69
+ // ...must have a single unit (i.e. do not mix seconds with milliseconds, or seconds with bytes).
70
+ // ...should have a suffix describing the unit, in plural form.
71
+ // Note that an accumulating count has total as a suffix, in addition to the unit if applicable
72
+
73
+ idx := strings.LastIndexByte(name, '_')
74
+ if idx == -1 {
75
+ // snmp_exporter: e.g. ifOutUcastPkts, ifOutOctets.
76
+ if idx = strings.LastIndexFunc(name, func(r rune) bool { return r >= 'A' && r <= 'Z' }); idx != -1 {
77
+ v := strings.ToLower(name[idx:])
78
+ switch v {
79
+ case "pkts":
80
+ return "packets"
81
+ case "octets":
82
+ return "bytes"
83
+ case "mtu":
84
+ return "octets"
85
+ case "speed":
86
+ return "bits"
87
+ }
88
+ return v
89
+ }
90
+ return "events"
91
+ }
92
+ switch suffix := name[idx:]; suffix {
93
+ case "_total", "_sum", "_count", "_ratio":
94
+ return getChartUnits(name[:idx])
95
+ }
96
+ switch units := name[idx+1:]; units {
97
+ case "hertz":
98
+ return "Hz"
99
+ default:
100
+ return units
101
+ }
102
+}
103
+
104
+// instrumentUnit returns the chart unit metrix should carry for a family. V1 appends "/s" to summary
105
+// quantile units (except seconds/time). chartengine autogen adds "/s" itself for the incremental
106
+// counter/_sum routes but uses the unit as-is for the absolute summary-quantile route, so the writer
107
+// must add it for summaries; gauges/counters/histograms pass the base unit.
108
+func instrumentUnit(name string, typ commonmodel.MetricType) string {
109
+ unit := getChartUnits(name)
110
+ if typ == commonmodel.MetricTypeSummary {
111
+ switch unit {
112
+ case "seconds", "time":
113
+ default:
114
+ unit += "/s"
115
+ }
116
+ }
117
+ return unit
118
+}
119
+
120
+func getChartPriority(name string) int {
121
+ if strings.HasPrefix(name, "go_") || strings.HasPrefix(name, "process_") {
122
+ return prioGORuntime
123
+ }
124
+ return prioDefault
125
+}
src/go/plugin/go.d/collector/prometheus/chart_template.go
new
+50
@@ -0,0 +1,50 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package prometheus
4
+
5
+import (
6
+ "fmt"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
9
+ "gopkg.in/yaml.v2"
10
+)
11
+
12
+// chartExpireAfterCycles mirrors V1's stale-chart removal (a chart was dropped after 10 missed
13
+// cycles): chartengine autogen removes a chart/dimension after this many successful cycles in
14
+// which its series is not seen.
15
+const chartExpireAfterCycles = 10
16
+
17
+// buildChartTemplate returns the per-job chart template (charttpl YAML) for the prometheus collector.
18
+// It is a pure-autogen template: a stub group satisfies the schema, and chartengine autogen builds
19
+// one chart per scraped metric, prefixing the context with context_namespace. The namespace is
20
+// "prometheus" or "prometheus.<app>" so contexts match V1 (prometheus.<metric> /
21
+// prometheus.<app>.<metric>); autogen joins namespace + "." + metric, so the app's separating dot is
22
+// part of the namespace itself.
23
+func buildChartTemplate(app string) (string, error) {
24
+ namespace := "prometheus"
25
+ if app != "" {
26
+ namespace = "prometheus." + app
27
+ }
28
+
29
+ spec := charttpl.Spec{
30
+ Version: charttpl.VersionV1,
31
+ ContextNamespace: namespace,
32
+ Engine: &charttpl.Engine{
33
+ Autogen: &charttpl.EngineAutogen{
34
+ Enabled: true,
35
+ ExpireAfterSuccessCycles: chartExpireAfterCycles,
36
+ },
37
+ },
38
+ Groups: []charttpl.Group{{Family: "prometheus"}},
39
+ }
40
+
41
+ if err := spec.Validate(); err != nil {
42
+ return "", fmt.Errorf("build prometheus chart template: %w", err)
43
+ }
44
+
45
+ raw, err := yaml.Marshal(spec)
46
+ if err != nil {
47
+ return "", fmt.Errorf("marshal prometheus chart template: %w", err)
48
+ }
49
+ return string(raw), nil
50
+}
src/go/plugin/go.d/collector/prometheus/chart_template_test.go
new
+56
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package prometheus
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
9
+ "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
10
+
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
15
+func TestBuildChartTemplate(t *testing.T) {
16
+ tests := map[string]struct {
17
+ app string
18
+ wantNamespace string
19
+ }{
20
+ "no app uses the prometheus namespace": {
21
+ app: "",
22
+ wantNamespace: "prometheus",
23
+ },
24
+ "app is folded into the namespace with the separating dot": {
25
+ app: "myapp",
26
+ wantNamespace: "prometheus.myapp",
27
+ },
28
+ }
29
+
30
+ for name, tc := range tests {
31
+ t.Run(name, func(t *testing.T) {
32
+ out, err := buildChartTemplate(tc.app)
33
+ require.NoError(t, err)
34
+
35
+ // Parse back through charttpl's own canonical decoder (yaml.v2 UnmarshalStrict, the path
36
+ // chartengine uses) so the round-trip is validated against the real template contract.
37
+ spec, err := charttpl.DecodeYAML([]byte(out))
38
+ require.NoError(t, err)
39
+
40
+ assert.Equal(t, charttpl.VersionV1, spec.Version)
41
+ assert.Equal(t, tc.wantNamespace, spec.ContextNamespace, "context_namespace drives the V1-parity chart context")
42
+ require.NotNil(t, spec.Engine)
43
+ require.NotNil(t, spec.Engine.Autogen)
44
+ assert.True(t, spec.Engine.Autogen.Enabled, "autogen must be enabled (no static charts)")
45
+ assert.Equal(t, uint64(chartExpireAfterCycles), spec.Engine.Autogen.ExpireAfterSuccessCycles,
46
+ "autogen chart expiry must mirror V1's 10-cycle stale removal")
47
+ require.Len(t, spec.Groups, 1, "a stub group satisfies the non-empty groups requirement")
48
+ assert.Equal(t, "prometheus", spec.Groups[0].Family)
49
+
50
+ // chartengine must accept the generated template (compiles + publishes a revision).
51
+ eng, err := chartengine.New()
52
+ require.NoError(t, err)
53
+ require.NoError(t, eng.LoadYAML([]byte(out), 1))
54
+ })
55
+ }
56
+}
src/go/plugin/go.d/collector/prometheus/charts.go
deleted
-336
@@ -1,336 +0,0 @@
1
-// SPDX-License-Identifier: GPL-3.0-or-later
2
-
3
-package prometheus
4
-
5
-import (
6
- "fmt"
7
- "strings"
8
-
9
- "github.com/prometheus/prometheus/model/labels"
10
-
11
- "github.com/netdata/netdata/go/plugins/pkg/prometheus"
12
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
13
-)
14
-
15
-const (
16
- prioDefault = collectorapi.Priority
17
- prioGORuntime = prioDefault + 10
18
-)
19
-
20
-func (c *Collector) addGaugeChart(id, name, help string, labels labels.Labels) {
21
- units := getChartUnits(name)
22
-
23
- cType := collectorapi.Line
24
- if strings.HasSuffix(units, "bytes") {
25
- cType = collectorapi.Area
26
- }
27
-
28
- chart := &collectorapi.Chart{
29
- ID: id,
30
- Title: getChartTitle(name, help),
31
- Units: units,
32
- Fam: getChartFamily(name),
33
- Ctx: getChartContext(c.application(), name),
34
- Type: cType,
35
- Priority: getChartPriority(name),
36
- Dims: collectorapi.Dims{
37
- {ID: id, Name: name, Div: precision},
38
- },
39
- }
40
-
41
- for _, lbl := range labels {
42
- chart.Labels = append(chart.Labels,
43
- collectorapi.Label{
44
- Key: c.labelName(lbl.Name),
45
- Value: apostropheReplacer.Replace(lbl.Value),
46
- },
47
- )
48
- }
49
-
50
- if err := c.Charts().Add(chart); err != nil {
51
- c.Warning(err)
52
- return
53
- }
54
-
55
- c.cache.addChart(id, chart)
56
-}
57
-
58
-func (c *Collector) addCounterChart(id, name, help string, labels labels.Labels) {
59
- units := getChartUnits(name)
60
-
61
- switch units {
62
- case "seconds", "time":
63
- default:
64
- units += "/s"
65
- }
66
-
67
- cType := collectorapi.Line
68
- if strings.HasSuffix(units, "bytes/s") {
69
- cType = collectorapi.Area
70
- }
71
-
72
- chart := &collectorapi.Chart{
73
- ID: id,
74
- Title: getChartTitle(name, help),
75
- Units: units,
76
- Fam: getChartFamily(name),
77
- Ctx: getChartContext(c.application(), name),
78
- Type: cType,
79
- Priority: getChartPriority(name),
80
- Dims: collectorapi.Dims{
81
- {ID: id, Name: name, Algo: collectorapi.Incremental, Div: precision},
82
- },
83
- }
84
- for _, lbl := range labels {
85
- chart.Labels = append(chart.Labels,
86
- collectorapi.Label{
87
- Key: c.labelName(lbl.Name),
88
- Value: apostropheReplacer.Replace(lbl.Value),
89
- },
90
- )
91
- }
92
-
93
- if err := c.Charts().Add(chart); err != nil {
94
- c.Warning(err)
95
- return
96
- }
97
-
98
- c.cache.addChart(id, chart)
99
-}
100
-
101
-func (c *Collector) addSummaryCharts(id, name, help string, labels labels.Labels, quantiles []prometheus.Quantile) {
102
- units := getChartUnits(name)
103
-
104
- switch units {
105
- case "seconds", "time":
106
- default:
107
- units += "/s"
108
- }
109
-
110
- charts := collectorapi.Charts{
111
- {
112
- ID: id,
113
- Title: getChartTitle(name, help),
114
- Units: units,
115
- Fam: getChartFamily(name),
116
- Ctx: getChartContext(c.application(), name),
117
- Priority: getChartPriority(name),
118
- Dims: func() (dims collectorapi.Dims) {
119
- for _, v := range quantiles {
120
- s := formatFloat(v.Quantile())
121
- dims = append(dims, &collectorapi.Dim{
122
- ID: fmt.Sprintf("%s_quantile=%s", id, s),
123
- Name: fmt.Sprintf("quantile_%s", s),
124
- Div: precision * precision,
125
- })
126
- }
127
- return dims
128
- }(),
129
- },
130
- {
131
- ID: id + "_sum",
132
- Title: getChartTitle(name, help),
133
- Units: units,
134
- Fam: getChartFamily(name),
135
- Ctx: getChartContext(c.application(), name) + "_sum",
136
- Priority: getChartPriority(name),
137
- Dims: collectorapi.Dims{
138
- {ID: id + "_sum", Name: name + "_sum", Algo: collectorapi.Incremental, Div: precision},
139
- },
140
- },
141
- {
142
- ID: id + "_count",
143
- Title: getChartTitle(name, help),
144
- Units: "events/s",
145
- Fam: getChartFamily(name),
146
- Ctx: getChartContext(c.application(), name) + "_count",
147
- Priority: getChartPriority(name),
148
- Dims: collectorapi.Dims{
149
- {ID: id + "_count", Name: name + "_count", Algo: collectorapi.Incremental},
150
- },
151
- },
152
- }
153
-
154
- for _, chart := range charts {
155
- for _, lbl := range labels {
156
- chart.Labels = append(chart.Labels, collectorapi.Label{
157
- Key: c.labelName(lbl.Name),
158
- Value: apostropheReplacer.Replace(lbl.Value),
159
- })
160
- }
161
- if err := c.Charts().Add(chart); err != nil {
162
- c.Warning(err)
163
- continue
164
- }
165
- c.cache.addChart(id, chart)
166
- }
167
-}
168
-
169
-func (c *Collector) addHistogramCharts(id, name, help string, labels labels.Labels, buckets []prometheus.Bucket) {
170
- units := getChartUnits(name)
171
-
172
- switch units {
173
- case "seconds", "time":
174
- default:
175
- units += "/s"
176
- }
177
-
178
- charts := collectorapi.Charts{
179
- {
180
- ID: id,
181
- Title: getChartTitle(name, help),
182
- Units: "observations/s",
183
- Fam: getChartFamily(name),
184
- Ctx: getChartContext(c.application(), name),
185
- Priority: getChartPriority(name),
186
- Dims: func() (dims collectorapi.Dims) {
187
- for _, v := range buckets {
188
- s := formatFloat(v.UpperBound())
189
- dims = append(dims, &collectorapi.Dim{
190
- ID: fmt.Sprintf("%s_bucket=%s", id, s),
191
- Name: fmt.Sprintf("bucket_%s", s),
192
- Algo: collectorapi.Incremental,
193
- })
194
- }
195
- return dims
196
- }(),
197
- },
198
- {
199
- ID: id + "_sum",
200
- Title: getChartTitle(name, help),
201
- Units: units,
202
- Fam: getChartFamily(name),
203
- Ctx: getChartContext(c.application(), name) + "_sum",
204
- Priority: getChartPriority(name),
205
- Dims: collectorapi.Dims{
206
- {ID: id + "_sum", Name: name + "_sum", Algo: collectorapi.Incremental, Div: precision},
207
- },
208
- },
209
- {
210
- ID: id + "_count",
211
- Title: getChartTitle(name, help),
212
- Units: "events/s",
213
- Fam: getChartFamily(name),
214
- Ctx: getChartContext(c.application(), name) + "_count",
215
- Priority: getChartPriority(name),
216
- Dims: collectorapi.Dims{
217
- {ID: id + "_count", Name: name + "_count", Algo: collectorapi.Incremental},
218
- },
219
- },
220
- }
221
-
222
- for _, chart := range charts {
223
- for _, lbl := range labels {
224
- chart.Labels = append(chart.Labels, collectorapi.Label{
225
- Key: c.labelName(lbl.Name),
226
- Value: apostropheReplacer.Replace(lbl.Value),
227
- })
228
- }
229
- if err := c.Charts().Add(chart); err != nil {
230
- c.Warning(err)
231
- continue
232
- }
233
- c.cache.addChart(id, chart)
234
- }
235
-}
236
-
237
-func (c *Collector) application() string {
238
- if c.Application != "" {
239
- return c.Application
240
- }
241
- return c.Name
242
-}
243
-
244
-func (c *Collector) labelName(lblName string) string {
245
- if c.LabelPrefix == "" {
246
- return lblName
247
- }
248
- return c.LabelPrefix + "_" + lblName
249
-}
250
-
251
-func getChartTitle(name, help string) string {
252
- if help == "" {
253
- return fmt.Sprintf("Metric \"%s\"", name)
254
- }
255
-
256
- help = strings.ReplaceAll(help, "'", "")
257
- help = strings.TrimSuffix(help, ".")
258
-
259
- return help
260
-}
261
-
262
-func getChartContext(app, name string) string {
263
- if app == "" {
264
- return fmt.Sprintf("prometheus.%s", name)
265
- }
266
- return fmt.Sprintf("prometheus.%s.%s", app, name)
267
-}
268
-
269
-func getChartFamily(metric string) (fam string) {
270
- if strings.HasPrefix(metric, "go_") {
271
- return "go"
272
- }
273
- if strings.HasPrefix(metric, "process_") {
274
- return "process"
275
- }
276
- if parts := strings.SplitN(metric, "_", 3); len(parts) < 3 {
277
- fam = metric
278
- } else {
279
- fam = parts[0] + "_" + parts[1]
280
- }
281
-
282
- // remove number suffix if any
283
- // load1, load5, load15 => load
284
- i := len(fam) - 1
285
- for i >= 0 && fam[i] >= '0' && fam[i] <= '9' {
286
- i--
287
- }
288
- if i > 0 {
289
- return fam[:i+1]
290
- }
291
- return fam
292
-}
293
-
294
-func getChartUnits(metric string) string {
295
- // https://prometheus.io/docs/practices/naming/#metric-names
296
- // ...must have a single unit (i.e. do not mix seconds with milliseconds, or seconds with bytes).
297
- // ...should have a suffix describing the unit, in plural form.
298
- // Note that an accumulating count has total as a suffix, in addition to the unit if applicable
299
-
300
- idx := strings.LastIndexByte(metric, '_')
301
- if idx == -1 {
302
- // snmp_exporter: e.g. ifOutUcastPkts, ifOutOctets.
303
- if idx = strings.LastIndexFunc(metric, func(r rune) bool { return r >= 'A' && r <= 'Z' }); idx != -1 {
304
- v := strings.ToLower(metric[idx:])
305
- switch v {
306
- case "pkts":
307
- return "packets"
308
- case "octets":
309
- return "bytes"
310
- case "mtu":
311
- return "octets"
312
- case "speed":
313
- return "bits"
314
- }
315
- return v
316
- }
317
- return "events"
318
- }
319
- switch suffix := metric[idx:]; suffix {
320
- case "_total", "_sum", "_count", "_ratio":
321
- return getChartUnits(metric[:idx])
322
- }
323
- switch units := metric[idx+1:]; units {
324
- case "hertz":
325
- return "Hz"
326
- default:
327
- return units
328
- }
329
-}
330
-
331
-func getChartPriority(name string) int {
332
- if strings.HasPrefix(name, "go_") || strings.HasPrefix(name, "process_") {
333
- return prioGORuntime
334
- }
335
- return prioDefault
336
-}
src/go/plugin/go.d/collector/prometheus/collect.go
+33
-236
@@ -4,259 +4,60 @@ package prometheus
4
5
import (
6
"fmt"
7
- "math"
8
- "strconv"
7
"strings"
8
11
- "github.com/prometheus/common/model"
12
- "github.com/prometheus/prometheus/model/labels"
13
-
9
"github.com/netdata/netdata/go/plugins/pkg/prometheus"
10
)
11
17
-const (
18
- precision = 1000
19
-)
20
-
21
-func (c *Collector) collect() (map[string]int64, error) {
22
- mfs, err := c.prom.Scrape()
12
+// collect scrapes the endpoint and writes the metric families to the metrix store. The
13
+// store cycle (begin/commit) is driven by the framework around Collect, so this only
14
+// writes observations and returns an error to abort the cycle.
15
+func (c *Collector) collect() error {
16
+ mfs, err := c.scrape()
17
if err != nil {
24
- return nil, err
18
+ return err
19
}
20
+ c.writer.writeMetricFamilies(mfs)
21
+ return nil
22
+}
23
27
- if mfs.Len() == 0 {
28
- c.Warningf("endpoint '%s' returned 0 metric families", c.URL)
29
- return nil, nil
24
+// check probes the endpoint and enforces the startup gates the V1 collector applied once:
25
+// the expected-prefix guard and the total time-series limit. Unlike V1 these are read-only
26
+// (V1 mutated Config to make them one-shot); they run only at Check, i.e. autodetection.
27
+func (c *Collector) check() error {
28
+ mfs, err := c.scrape()
29
+ if err != nil {
30
+ return err
31
}
31
-
32
- // TODO: shouldn't modify the value from Config
33
- if c.ExpectedPrefix != "" {
34
- if !hasPrefix(mfs, c.ExpectedPrefix) {
35
- return nil, fmt.Errorf("'%s' metrics have no expected prefix (%s)", c.URL, c.ExpectedPrefix)
36
- }
37
- c.ExpectedPrefix = ""
32
+ if c.ExpectedPrefix != "" && !hasPrefix(mfs, c.ExpectedPrefix) {
33
+ return fmt.Errorf("'%s' metrics have no expected prefix (%s)", c.URL, c.ExpectedPrefix)
34
}
39
-
40
- // TODO: shouldn't modify the value from Config
35
if c.MaxTS > 0 {
36
if n := calcMetrics(mfs); n > c.MaxTS {
43
- return nil, fmt.Errorf("'%s' num of time series (%d) > limit (%d)", c.URL, n, c.MaxTS)
44
- }
45
- c.MaxTS = 0
46
- }
47
-
48
- mx := make(map[string]int64)
49
-
50
- c.resetCache()
51
- defer c.removeStaleCharts()
52
-
53
- for _, mf := range mfs {
54
- if strings.HasSuffix(mf.Name(), "_info") {
55
- continue
56
- }
57
- if c.MaxTSPerMetric > 0 && len(mf.Metrics()) > c.MaxTSPerMetric {
58
- c.Debugf("metric '%s' num of time series (%d) > limit (%d), skipping it",
59
- mf.Name(), len(mf.Metrics()), c.MaxTSPerMetric)
60
- continue
61
- }
62
-
63
- switch mf.Type() {
64
- case model.MetricTypeGauge:
65
- c.collectGauge(mx, mf)
66
- case model.MetricTypeCounter:
67
- c.collectCounter(mx, mf)
68
- case model.MetricTypeSummary:
69
- c.collectSummary(mx, mf)
70
- case model.MetricTypeHistogram:
71
- c.collectHistogram(mx, mf)
72
- case model.MetricTypeUnknown:
73
- c.collectUntyped(mx, mf)
74
- }
75
- }
76
-
77
- return mx, nil
78
-}
79
-
80
-func (c *Collector) collectGauge(mx map[string]int64, mf *prometheus.MetricFamily) {
81
- for _, m := range mf.Metrics() {
82
- if m.Gauge() == nil || math.IsNaN(m.Gauge().Value()) {
83
- continue
84
- }
85
-
86
- id := mf.Name() + c.joinLabels(m.Labels())
87
-
88
- if !c.cache.hasP(id) {
89
- c.addGaugeChart(id, mf.Name(), mf.Help(), m.Labels())
90
- }
91
-
92
- mx[id] = int64(m.Gauge().Value() * precision)
93
- }
94
-}
95
-
96
-func (c *Collector) collectCounter(mx map[string]int64, mf *prometheus.MetricFamily) {
97
- for _, m := range mf.Metrics() {
98
- if m.Counter() == nil || math.IsNaN(m.Counter().Value()) {
99
- continue
100
- }
101
-
102
- id := mf.Name() + c.joinLabels(m.Labels())
103
-
104
- if !c.cache.hasP(id) {
105
- c.addCounterChart(id, mf.Name(), mf.Help(), m.Labels())
106
- }
107
-
108
- mx[id] = int64(m.Counter().Value() * precision)
109
- }
110
-}
111
-
112
-func (c *Collector) collectSummary(mx map[string]int64, mf *prometheus.MetricFamily) {
113
- for _, m := range mf.Metrics() {
114
- if m.Summary() == nil || len(m.Summary().Quantiles()) == 0 {
115
- continue
116
- }
117
-
118
- id := mf.Name() + c.joinLabels(m.Labels())
119
-
120
- if !c.cache.hasP(id) {
121
- c.addSummaryCharts(id, mf.Name(), mf.Help(), m.Labels(), m.Summary().Quantiles())
122
- }
123
-
124
- for _, v := range m.Summary().Quantiles() {
125
- if !math.IsNaN(v.Value()) {
126
- dimID := fmt.Sprintf("%s_quantile=%s", id, formatFloat(v.Quantile()))
127
- mx[dimID] = int64(v.Value() * precision * precision)
128
- }
129
- }
130
-
131
- mx[id+"_sum"] = int64(m.Summary().Sum() * precision)
132
- mx[id+"_count"] = int64(m.Summary().Count())
133
- }
134
-}
135
-
136
-func (c *Collector) collectHistogram(mx map[string]int64, mf *prometheus.MetricFamily) {
137
- for _, m := range mf.Metrics() {
138
- if m.Histogram() == nil || len(m.Histogram().Buckets()) == 0 {
139
- continue
140
- }
141
-
142
- id := mf.Name() + c.joinLabels(m.Labels())
143
-
144
- if !c.cache.hasP(id) {
145
- c.addHistogramCharts(id, mf.Name(), mf.Help(), m.Labels(), m.Histogram().Buckets())
146
- }
147
-
148
- for _, v := range m.Histogram().Buckets() {
149
- if !math.IsNaN(v.CumulativeCount()) {
150
- dimID := fmt.Sprintf("%s_bucket=%s", id, formatFloat(v.UpperBound()))
151
- mx[dimID] = int64(v.CumulativeCount())
152
- }
37
+ return fmt.Errorf("'%s' num of time series (%d) > limit (%d)", c.URL, n, c.MaxTS)
38
}
154
-
155
- mx[id+"_sum"] = int64(m.Histogram().Sum() * precision)
156
- mx[id+"_count"] = int64(m.Histogram().Count())
39
}
158
-}
159
-
160
-func (c *Collector) collectUntyped(mx map[string]int64, mf *prometheus.MetricFamily) {
161
- for _, m := range mf.Metrics() {
162
- if m.Untyped() == nil || math.IsNaN(m.Untyped().Value()) {
163
- continue
164
- }
165
-
166
- if c.isFallbackTypeGauge(mf.Name()) {
167
- id := mf.Name() + c.joinLabels(m.Labels())
168
-
169
- if !c.cache.hasP(id) {
170
- c.addGaugeChart(id, mf.Name(), mf.Help(), m.Labels())
171
- }
172
-
173
- mx[id] = int64(m.Untyped().Value() * precision)
174
- }
175
-
176
- if c.isFallbackTypeCounter(mf.Name()) || strings.HasSuffix(mf.Name(), "_total") {
177
- id := mf.Name() + c.joinLabels(m.Labels())
178
-
179
- if !c.cache.hasP(id) {
180
- c.addCounterChart(id, mf.Name(), mf.Help(), m.Labels())
181
- }
182
-
183
- mx[id] = int64(m.Untyped().Value() * precision)
184
- }
40
+ if c.writer.countWritable(mfs) == 0 {
41
+ return fmt.Errorf("endpoint '%s' exposes no usable metrics", c.URL)
42
}
43
+ return nil
44
}
45
188
-func (c *Collector) isFallbackTypeGauge(name string) bool {
189
- return c.fallbackType.gauge != nil && c.fallbackType.gauge.MatchString(name)
190
-}
191
-
192
-func (c *Collector) isFallbackTypeCounter(name string) bool {
193
- return c.fallbackType.counter != nil && c.fallbackType.counter.MatchString(name)
194
-}
195
-
196
-func (c *Collector) joinLabels(labels labels.Labels) string {
197
- var sb strings.Builder
198
- for _, lbl := range labels {
199
- name, val := lbl.Name, lbl.Value
200
- if name == "" || val == "" {
201
- continue
202
- }
203
-
204
- if strings.IndexByte(val, ' ') != -1 {
205
- val = spaceReplacer.Replace(val)
206
- }
207
- if strings.IndexByte(val, '\\') != -1 {
208
- if val = decodeLabelValue(val); strings.IndexByte(val, '\\') != -1 {
209
- val = backslashReplacer.Replace(val)
210
- }
211
- }
212
- if strings.IndexByte(val, '\'') != -1 {
213
- val = apostropheReplacer.Replace(val)
214
- }
215
-
216
- sb.WriteString("-" + name + "=" + val)
217
- }
218
- return sb.String()
219
-}
220
-
221
-func (c *Collector) resetCache() {
222
- for _, v := range c.cache.entries {
223
- v.seen = false
224
- }
225
-}
226
-
227
-const maxNotSeenTimes = 10
228
-
229
-func (c *Collector) removeStaleCharts() {
230
- for k, v := range c.cache.entries {
231
- if v.seen {
232
- continue
233
- }
234
- if v.notSeenTimes++; v.notSeenTimes >= maxNotSeenTimes {
235
- for _, chart := range v.charts {
236
- chart.MarkRemove()
237
- chart.MarkNotCreated()
238
- }
239
- delete(c.cache.entries, k)
240
- }
241
- }
242
-}
243
-
244
-func decodeLabelValue(value string) string {
245
- v, err := strconv.Unquote("\"" + value + "\"")
46
+// scrape fetches the endpoint and enforces the empty-scrape contract: an empty scrape is
47
+// an error (endpoint down or exposing nothing), not silent no-data.
48
+func (c *Collector) scrape() (prometheus.MetricFamilies, error) {
49
+ mfs, err := c.prom.Scrape()
50
if err != nil {
247
- return value
51
+ return nil, err
52
}
249
- return v
53
+ if mfs.Len() == 0 {
54
+ return nil, fmt.Errorf("endpoint '%s' returned 0 metric families", c.URL)
55
+ }
56
+ return mfs, nil
57
}
58
252
-var (
253
- spaceReplacer = strings.NewReplacer(" ", "_")
254
- backslashReplacer = strings.NewReplacer(`\`, "_")
255
- apostropheReplacer = strings.NewReplacer("'", "")
256
-)
257
-
258
-func hasPrefix(mf map[string]*prometheus.MetricFamily, prefix string) bool {
259
- for name := range mf {
59
+func hasPrefix(mfs prometheus.MetricFamilies, prefix string) bool {
60
+ for name := range mfs {
61
if strings.HasPrefix(name, prefix) {
62
return true
63
}
@@ -271,7 +72,3 @@ func calcMetrics(mfs prometheus.MetricFamilies) int {
72
}
73
return n
74
}
274
-
275
-func formatFloat(v float64) string {
276
- return strconv.FormatFloat(v, 'f', -1, 64)
277
-}
src/go/plugin/go.d/collector/prometheus/collector.go
+33
-41
@@ -5,12 +5,11 @@ package prometheus
5
import (
6
"context"
7
_ "embed"
8
- "errors"
8
"fmt"
9
"time"
10
11
"github.com/netdata/netdata/go/plugins/pkg/confopt"
13
- "github.com/netdata/netdata/go/plugins/pkg/matcher"
12
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
13
"github.com/netdata/netdata/go/plugins/pkg/prometheus"
14
"github.com/netdata/netdata/go/plugins/pkg/prometheus/selector"
15
"github.com/netdata/netdata/go/plugins/pkg/web"
@@ -26,8 +25,8 @@ func init() {
25
Defaults: collectorapi.Defaults{
26
UpdateEvery: 10,
27
},
29
- Create: func() collectorapi.CollectorV1 { return New() },
30
- Config: func() any { return &Config{} },
28
+ CreateV2: func() collectorapi.CollectorV2 { return New() },
29
+ Config: func() any { return &Config{} },
30
})
31
}
32
@@ -42,8 +41,7 @@ func New() *Collector {
41
MaxTS: 2000,
42
MaxTSPerMetric: 200,
43
},
45
- charts: &collectorapi.Charts{},
46
- cache: newCache(),
44
+ store: metrix.NewCollectorStore(),
45
}
46
}
47
@@ -69,15 +67,10 @@ type Collector struct {
67
collectorapi.Base
68
Config `yaml:",inline" json:""`
69
72
- charts *collectorapi.Charts
73
-
74
- prom prometheus.Prometheus
75
-
76
- cache *cache
77
- fallbackType struct {
78
- counter matcher.Matcher
79
- gauge matcher.Matcher
80
- }
70
+ prom prometheus.Prometheus
71
+ store metrix.CollectorStore
72
+ writer *metricFamilyWriter
73
+ chartTemplate string
74
}
75
76
func (c *Collector) Configuration() any {
@@ -95,46 +88,37 @@ func (c *Collector) Init(context.Context) error {
88
}
89
c.prom = prom
90
98
- m, err := c.initFallbackTypeMatcher(c.FallbackType.Counter)
91
+ gaugeFallback, err := c.initFallbackTypeMatcher(c.FallbackType.Gauge)
92
if err != nil {
100
- return fmt.Errorf("init counter fallback type matcher: %v", err)
93
+ return fmt.Errorf("init gauge fallback type matcher: %v", err)
94
}
102
- c.fallbackType.counter = m
103
-
104
- m, err = c.initFallbackTypeMatcher(c.FallbackType.Gauge)
95
+ counterFallback, err := c.initFallbackTypeMatcher(c.FallbackType.Counter)
96
if err != nil {
97
return fmt.Errorf("init counter fallback type matcher: %v", err)
98
}
108
- c.fallbackType.gauge = m
99
110
- return nil
111
-}
100
+ c.writer = newMetricFamilyWriter(c.store, metricFamilyWriterPolicy{
101
+ labelPrefix: c.LabelPrefix,
102
+ maxTSPerMetric: c.MaxTSPerMetric,
103
+ isFallbackTypeGauge: gaugeFallback,
104
+ isFallbackTypeCounter: counterFallback,
105
+ }, c.Logger)
106
113
-func (c *Collector) Check(context.Context) error {
114
- mx, err := c.collect()
107
+ tmpl, err := buildChartTemplate(c.application())
108
if err != nil {
116
- return err
117
- }
118
- if len(mx) == 0 {
119
- return errors.New("no metrics collected")
109
+ return fmt.Errorf("build chart template: %v", err)
110
}
111
+ c.chartTemplate = tmpl
112
+
113
return nil
114
}
115
124
-func (c *Collector) Charts() *collectorapi.Charts {
125
- return c.charts
116
+func (c *Collector) Check(context.Context) error {
117
+ return c.check()
118
}
119
128
-func (c *Collector) Collect(context.Context) map[string]int64 {
129
- mx, err := c.collect()
130
- if err != nil {
131
- c.Error(err)
132
- }
133
-
134
- if len(mx) == 0 {
135
- return nil
136
- }
137
- return mx
120
+func (c *Collector) Collect(context.Context) error {
121
+ return c.collect()
122
}
123
124
func (c *Collector) Cleanup(context.Context) {
@@ -142,3 +126,11 @@ func (c *Collector) Cleanup(context.Context) {
126
c.prom.HTTPClient().CloseIdleConnections()
127
}
128
}
129
+
130
+func (c *Collector) MetricStore() metrix.CollectorStore {
131
+ return c.store
132
+}
133
+
134
+func (c *Collector) ChartTemplateYAML() string {
135
+ return c.chartTemplate
136
+}
src/go/plugin/go.d/collector/prometheus/collector_test.go
+191
-366
@@ -4,7 +4,6 @@ package prometheus
4
5
import (
6
"context"
7
- "fmt"
7
"net/http"
8
"net/http/httptest"
9
"os"
@@ -13,9 +12,9 @@ import (
12
"github.com/stretchr/testify/assert"
13
"github.com/stretchr/testify/require"
14
15
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
16
"github.com/netdata/netdata/go/plugins/pkg/prometheus/selector"
17
"github.com/netdata/netdata/go/plugins/pkg/web"
18
- "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
18
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
19
)
20
@@ -161,6 +160,32 @@ test_counter_no_meta_metric_1_total{label1="value2"} 11
160
return collr, srv.Close
161
},
162
},
163
+ "fail if endpoint exposes only non-writable metrics": {
164
+ wantFail: true,
165
+ prepare: func() (collr *Collector, cleanup func()) {
166
+ srv := httptest.NewServer(http.HandlerFunc(
167
+ func(w http.ResponseWriter, r *http.Request) {
168
+ _, _ = w.Write([]byte(`app_x_info{version="1.0"} 1`))
169
+ }))
170
+ collr = New()
171
+ collr.URL = srv.URL
172
+
173
+ return collr, srv.Close
174
+ },
175
+ },
176
+ "fail if endpoint returns an empty body (no metric families)": {
177
+ wantFail: true,
178
+ prepare: func() (collr *Collector, cleanup func()) {
179
+ srv := httptest.NewServer(http.HandlerFunc(
180
+ func(w http.ResponseWriter, r *http.Request) {
181
+ _, _ = w.Write([]byte(""))
182
+ }))
183
+ collr = New()
184
+ collr.URL = srv.URL
185
+
186
+ return collr, srv.Close
187
+ },
188
+ },
189
"fail if connection refused": {
190
wantFail: true,
191
prepare: func() (collr *Collector, cleanup func()) {
@@ -201,413 +226,213 @@ test_counter_no_meta_metric_1_total{label1="value2"} 11
226
}
227
}
228
229
+// TestCollector_Collect drives the real V2 collector (Init, then a framework-style store
230
+// cycle around Collect) and asserts the metrics it wrote into the metrix store, by metric
231
+// name + flattened labels. Per-type correctness is exercised exhaustively in writer_test.go;
232
+// this checks the collector's end-to-end wiring (client/selector/fallback built in Init →
233
+// scrape → writer → store) plus the config-driven behaviors.
234
func TestCollector_Collect(t *testing.T) {
205
- type testCaseStep struct {
206
- desc string
207
- input string
208
- wantCollected map[string]int64
209
- wantCharts int
210
- }
235
tests := map[string]struct {
236
prepare func() *Collector
213
- steps []testCaseStep
237
+ input string
238
+ want func(t *testing.T, fr metrix.Reader)
239
}{
215
- "Gauge": {
240
+ "gauge and counter values": {
241
prepare: New,
217
- steps: []testCaseStep{
218
- {
219
- desc: "Two first seen series, no meta series ignored",
220
- input: `
221
-# HELP test_gauge_metric_1 Test Gauge Metric 1
222
-# TYPE test_gauge_metric_1 gauge
223
-test_gauge_metric_1{label1="value1"} 11
224
-test_gauge_metric_1{label1="value2"} 12
225
-test_gauge_no_meta_metric_1{label1="value1"} 11
226
-test_gauge_no_meta_metric_1{label1="value2"} 12
242
+ input: `
243
+# TYPE test_gauge_metric gauge
244
+test_gauge_metric{label1="value1"} 11
245
+test_gauge_metric{label1="value2"} 12.5
246
+# TYPE test_counter_metric_total counter
247
+test_counter_metric_total{label1="value1"} 11
248
`,
228
- wantCollected: map[string]int64{
229
- "test_gauge_metric_1-label1=value1": 11000,
230
- "test_gauge_metric_1-label1=value2": 12000,
231
- },
232
- wantCharts: 2,
233
- },
234
- {
235
- desc: "One series removed",
236
- input: `
237
-# HELP test_gauge_metric_1 Test Gauge Metric 1
238
-# TYPE test_gauge_metric_1 gauge
239
-test_gauge_metric_1{label1="value1"} 11
240
-`,
241
- wantCollected: map[string]int64{
242
- "test_gauge_metric_1-label1=value1": 11000,
243
- },
244
- wantCharts: 1,
245
- },
246
- {
247
- desc: "One series (re)added",
248
- input: `
249
-# HELP test_gauge_metric_1 Test Gauge Metric 1
250
-# TYPE test_gauge_metric_1 gauge
251
-test_gauge_metric_1{label1="value1"} 11
252
-test_gauge_metric_1{label1="value2"} 12
253
-`,
254
- wantCollected: map[string]int64{
255
- "test_gauge_metric_1-label1=value1": 11000,
256
- "test_gauge_metric_1-label1=value2": 12000,
257
- },
258
- wantCharts: 2,
259
- },
249
+ want: func(t *testing.T, fr metrix.Reader) {
250
+ assert.InDelta(t, 11, value(t, fr, "test_gauge_metric", metrix.Labels{"label1": "value1"}), 1e-9)
251
+ assert.InDelta(t, 12.5, value(t, fr, "test_gauge_metric", metrix.Labels{"label1": "value2"}), 1e-9)
252
+ assert.InDelta(t, 11, value(t, fr, "test_counter_metric_total", metrix.Labels{"label1": "value1"}), 1e-9)
253
},
254
},
262
- "Counter": {
255
+ "summary flattens to quantiles, sum and count": {
256
prepare: New,
264
- steps: []testCaseStep{
265
- {
266
- desc: "Four first seen series, no meta series collected",
267
- input: `
268
-# HELP test_counter_metric_1_total Test Counter Metric 1
269
-# TYPE test_counter_metric_1_total counter
270
-test_counter_metric_1_total{label1="value1"} 11
271
-test_counter_metric_1_total{label1="value2"} 12
272
-test_counter_no_meta_metric_1_total{label1="value1"} 11
273
-test_counter_no_meta_metric_1_total{label1="value2"} 12
257
+ input: `
258
+# TYPE test_latency summary
259
+test_latency{quantile="0.5"} 0.25
260
+test_latency{quantile="0.99"} 0.5
261
+test_latency_sum 12.5
262
+test_latency_count 42
263
`,
275
- wantCollected: map[string]int64{
276
- "test_counter_metric_1_total-label1=value1": 11000,
277
- "test_counter_metric_1_total-label1=value2": 12000,
278
- "test_counter_no_meta_metric_1_total-label1=value1": 11000,
279
- "test_counter_no_meta_metric_1_total-label1=value2": 12000,
280
- },
281
- wantCharts: 4,
282
- },
283
- {
284
- desc: "Two series removed",
285
- input: `
286
-# HELP test_counter_metric_1_total Test Counter Metric 1
287
-# TYPE test_counter_metric_1_total counter
288
-test_counter_metric_1_total{label1="value1"} 11
289
-test_counter_no_meta_metric_1_total{label1="value1"} 11
290
-`,
291
- wantCollected: map[string]int64{
292
- "test_counter_metric_1_total-label1=value1": 11000,
293
- "test_counter_no_meta_metric_1_total-label1=value1": 11000,
294
- },
295
- wantCharts: 2,
296
- },
297
- {
298
- desc: "Two series (re)added",
299
- input: `
300
-# HELP test_counter_metric_1_total Test Counter Metric 1
301
-# TYPE test_counter_metric_1_total counter
302
-test_counter_metric_1_total{label1="value1"} 11
303
-test_counter_metric_1_total{label1="value2"} 12
304
-test_counter_no_meta_metric_1_total{label1="value1"} 11
305
-test_counter_no_meta_metric_1_total{label1="value2"} 12
306
-`,
307
- wantCollected: map[string]int64{
308
- "test_counter_metric_1_total-label1=value1": 11000,
309
- "test_counter_metric_1_total-label1=value2": 12000,
310
- "test_counter_no_meta_metric_1_total-label1=value1": 11000,
311
- "test_counter_no_meta_metric_1_total-label1=value2": 12000,
312
- },
313
- wantCharts: 4,
314
- },
264
+ want: func(t *testing.T, fr metrix.Reader) {
265
+ assert.InDelta(t, 0.25, value(t, fr, "test_latency", metrix.Labels{"quantile": "0.5"}), 1e-9)
266
+ assert.InDelta(t, 0.5, value(t, fr, "test_latency", metrix.Labels{"quantile": "0.99"}), 1e-9)
267
+ assert.InDelta(t, 12.5, value(t, fr, "test_latency_sum", nil), 1e-9)
268
+ assert.InDelta(t, 42, value(t, fr, "test_latency_count", nil), 1e-9)
269
},
270
},
317
- "Summary": {
271
+ "histogram flattens to buckets, sum and count": {
272
prepare: New,
319
- steps: []testCaseStep{
320
- {
321
- desc: "Two first seen series, no meta series collected",
322
- input: `
323
-# HELP test_summary_1_duration_microseconds Test Summary Metric 1
324
-# TYPE test_summary_1_duration_microseconds summary
325
-test_summary_1_duration_microseconds{label1="value1",quantile="0.5"} 4931.921
326
-test_summary_1_duration_microseconds{label1="value1",quantile="0.9"} 4932.921
327
-test_summary_1_duration_microseconds{label1="value1",quantile="0.99"} 4933.921
328
-test_summary_1_duration_microseconds_sum{label1="value1"} 283201.29
329
-test_summary_1_duration_microseconds_count{label1="value1"} 31
330
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.5"} 4931.921
331
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.9"} 4932.921
332
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.99"} 4933.921
333
-test_summary_no_meta_1_duration_microseconds_sum{label1="value1"} 283201.29
334
-test_summary_no_meta_1_duration_microseconds_count{label1="value1"} 31
335
-`,
336
- wantCollected: map[string]int64{
337
- "test_summary_1_duration_microseconds-label1=value1_count": 31,
338
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.5": 4931921000,
339
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.9": 4932921000,
340
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.99": 4933921000,
341
- "test_summary_1_duration_microseconds-label1=value1_sum": 283201290,
342
- "test_summary_no_meta_1_duration_microseconds-label1=value1_count": 31,
343
- "test_summary_no_meta_1_duration_microseconds-label1=value1_quantile=0.5": 4931921000,
344
- "test_summary_no_meta_1_duration_microseconds-label1=value1_quantile=0.9": 4932921000,
345
- "test_summary_no_meta_1_duration_microseconds-label1=value1_quantile=0.99": 4933921000,
346
- "test_summary_no_meta_1_duration_microseconds-label1=value1_sum": 283201290,
347
- },
348
- wantCharts: 6,
349
- },
350
- {
351
- desc: "One series removed",
352
- input: `
353
-# HELP test_summary_1_duration_microseconds Test Summary Metric 1
354
-# TYPE test_summary_1_duration_microseconds summary
355
-test_summary_1_duration_microseconds{label1="value1",quantile="0.5"} 4931.921
356
-test_summary_1_duration_microseconds{label1="value1",quantile="0.9"} 4932.921
357
-test_summary_1_duration_microseconds{label1="value1",quantile="0.99"} 4933.921
358
-test_summary_1_duration_microseconds_sum{label1="value1"} 283201.29
359
-test_summary_1_duration_microseconds_count{label1="value1"} 31
273
+ input: `
274
+# TYPE test_dur histogram
275
+test_dur_bucket{le="0.1"} 4
276
+test_dur_bucket{le="+Inf"} 6
277
+test_dur_sum 2.5
278
+test_dur_count 6
279
`,
361
- wantCollected: map[string]int64{
362
- "test_summary_1_duration_microseconds-label1=value1_count": 31,
363
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.5": 4931921000,
364
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.9": 4932921000,
365
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.99": 4933921000,
366
- "test_summary_1_duration_microseconds-label1=value1_sum": 283201290,
367
- },
368
- wantCharts: 3,
369
- },
370
- {
371
- desc: "One series (re)added",
372
- input: `
373
-# HELP test_summary_1_duration_microseconds Test Summary Metric 1
374
-# TYPE test_summary_1_duration_microseconds summary
375
-test_summary_1_duration_microseconds{label1="value1",quantile="0.5"} 4931.921
376
-test_summary_1_duration_microseconds{label1="value1",quantile="0.9"} 4932.921
377
-test_summary_1_duration_microseconds{label1="value1",quantile="0.99"} 4933.921
378
-test_summary_1_duration_microseconds_sum{label1="value1"} 283201.29
379
-test_summary_1_duration_microseconds_count{label1="value1"} 31
380
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.5"} 4931.921
381
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.9"} 4932.921
382
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.99"} 4933.921
383
-test_summary_no_meta_1_duration_microseconds_sum{label1="value1"} 283201.29
384
-test_summary_no_meta_1_duration_microseconds_count{label1="value1"} 31
385
-`,
386
- wantCollected: map[string]int64{
387
- "test_summary_1_duration_microseconds-label1=value1_count": 31,
388
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.5": 4931921000,
389
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.9": 4932921000,
390
- "test_summary_1_duration_microseconds-label1=value1_quantile=0.99": 4933921000,
391
- "test_summary_1_duration_microseconds-label1=value1_sum": 283201290,
392
- "test_summary_no_meta_1_duration_microseconds-label1=value1_count": 31,
393
- "test_summary_no_meta_1_duration_microseconds-label1=value1_quantile=0.5": 4931921000,
394
- "test_summary_no_meta_1_duration_microseconds-label1=value1_quantile=0.9": 4932921000,
395
- "test_summary_no_meta_1_duration_microseconds-label1=value1_quantile=0.99": 4933921000,
396
- "test_summary_no_meta_1_duration_microseconds-label1=value1_sum": 283201290,
397
- },
398
- wantCharts: 6,
399
- },
280
+ want: func(t *testing.T, fr metrix.Reader) {
281
+ assert.InDelta(t, 4, value(t, fr, "test_dur_bucket", metrix.Labels{"le": "0.1"}), 1e-9)
282
+ assert.InDelta(t, 6, value(t, fr, "test_dur_bucket", metrix.Labels{"le": "+Inf"}), 1e-9)
283
+ assert.InDelta(t, 2.5, value(t, fr, "test_dur_sum", nil), 1e-9)
284
+ assert.InDelta(t, 6, value(t, fr, "test_dur_count", nil), 1e-9)
285
},
286
},
402
- "Summary with NaN": {
403
- prepare: New,
404
- steps: []testCaseStep{
405
- {
406
- desc: "Two first seen series, no meta series collected",
407
- input: `
408
-# HELP test_summary_1_duration_microseconds Test Summary Metric 1
409
-# TYPE test_summary_1_duration_microseconds summary
410
-test_summary_1_duration_microseconds{label1="value1",quantile="0.5"} NaN
411
-test_summary_1_duration_microseconds{label1="value1",quantile="0.9"} NaN
412
-test_summary_1_duration_microseconds{label1="value1",quantile="0.99"} NaN
413
-test_summary_1_duration_microseconds_sum{label1="value1"} 283201.29
414
-test_summary_1_duration_microseconds_count{label1="value1"} 31
415
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.5"} NaN
416
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.9"} NaN
417
-test_summary_no_meta_1_duration_microseconds{label1="value1",quantile="0.99"} NaN
418
-test_summary_no_meta_1_duration_microseconds_sum{label1="value1"} 283201.29
419
-test_summary_no_meta_1_duration_microseconds_count{label1="value1"} 31
420
-`,
421
- wantCollected: map[string]int64{
422
- "test_summary_1_duration_microseconds-label1=value1_count": 31,
423
- "test_summary_1_duration_microseconds-label1=value1_sum": 283201290,
424
- "test_summary_no_meta_1_duration_microseconds-label1=value1_count": 31,
425
- "test_summary_no_meta_1_duration_microseconds-label1=value1_sum": 283201290,
426
- },
427
- wantCharts: 6,
428
- },
287
+ "untyped falls back to gauge and counter": {
288
+ prepare: func() *Collector {
289
+ c := New()
290
+ c.FallbackType.Gauge = []string{"test_fallback_gauge"}
291
+ return c
292
},
430
- },
431
- "Histogram": {
432
- prepare: New,
433
- steps: []testCaseStep{
434
- {
435
- desc: "Two first seen series, no meta series collected",
436
- input: `
437
-# HELP test_histogram_1_duration_seconds Test Histogram Metric 1
438
-# TYPE test_histogram_1_duration_seconds histogram
439
-test_histogram_1_duration_seconds_bucket{label1="value1",le="0.1"} 4
440
-test_histogram_1_duration_seconds_bucket{label1="value1",le="0.5"} 5
441
-test_histogram_1_duration_seconds_bucket{label1="value1",le="+Inf"} 6
442
-test_histogram_1_duration_seconds_sum{label1="value1"} 0.00147889
443
-test_histogram_1_duration_seconds_count{label1="value1"} 6
444
-test_histogram_no_meta_1_duration_seconds_bucket{label1="value1",le="0.1"} 4
445
-test_histogram_no_meta_1_duration_seconds_bucket{label1="value1",le="0.5"} 5
446
-test_histogram_no_meta_1_duration_seconds_bucket{label1="value1",le="+Inf"} 6
447
-test_histogram_no_meta_1_duration_seconds_sum{label1="value1"} 0.00147889
448
-test_histogram_no_meta_1_duration_seconds_count{label1="value1"} 6
449
-`,
450
- wantCollected: map[string]int64{
451
- "test_histogram_1_duration_seconds-label1=value1_bucket=+Inf": 6,
452
- "test_histogram_1_duration_seconds-label1=value1_bucket=0.1": 4,
453
- "test_histogram_1_duration_seconds-label1=value1_bucket=0.5": 5,
454
- "test_histogram_1_duration_seconds-label1=value1_count": 6,
455
- "test_histogram_1_duration_seconds-label1=value1_sum": 1,
456
- "test_histogram_no_meta_1_duration_seconds-label1=value1_bucket=+Inf": 6,
457
- "test_histogram_no_meta_1_duration_seconds-label1=value1_bucket=0.1": 4,
458
- "test_histogram_no_meta_1_duration_seconds-label1=value1_bucket=0.5": 5,
459
- "test_histogram_no_meta_1_duration_seconds-label1=value1_count": 6,
460
- "test_histogram_no_meta_1_duration_seconds-label1=value1_sum": 1,
461
- },
462
- wantCharts: 6,
463
- },
464
- {
465
- desc: "One series removed",
466
- input: `
467
-# HELP test_histogram_1_duration_seconds Test Histogram Metric 1
468
-# TYPE test_histogram_1_duration_seconds histogram
469
-test_histogram_1_duration_seconds_bucket{label1="value1",le="0.1"} 4
470
-test_histogram_1_duration_seconds_bucket{label1="value1",le="0.5"} 5
471
-test_histogram_1_duration_seconds_bucket{label1="value1",le="+Inf"} 6
293
+ input: `
294
+test_fallback_gauge{label1="value1"} 7
295
+test_things_total{label1="value1"} 5
296
+test_untyped_dropped{label1="value1"} 9
297
`,
473
- wantCollected: map[string]int64{
474
- "test_histogram_1_duration_seconds-label1=value1_bucket=+Inf": 6,
475
- "test_histogram_1_duration_seconds-label1=value1_bucket=0.1": 4,
476
- "test_histogram_1_duration_seconds-label1=value1_bucket=0.5": 5,
477
- "test_histogram_1_duration_seconds-label1=value1_count": 0,
478
- "test_histogram_1_duration_seconds-label1=value1_sum": 0,
479
- },
480
- wantCharts: 3,
481
- },
482
- {
483
- desc: "One series (re)added",
484
- input: `
485
-# HELP test_histogram_1_duration_seconds Test Histogram Metric 1
486
-# TYPE test_histogram_1_duration_seconds histogram
487
-test_histogram_1_duration_seconds_bucket{label1="value1",le="0.1"} 4
488
-test_histogram_1_duration_seconds_bucket{label1="value1",le="0.5"} 5
489
-test_histogram_1_duration_seconds_bucket{label1="value1",le="+Inf"} 6
490
-test_histogram_1_duration_seconds_sum{label1="value1"} 0.00147889
491
-test_histogram_1_duration_seconds_count{label1="value1"} 6
492
-test_histogram_no_meta_1_duration_seconds_bucket{label1="value1",le="0.1"} 4
493
-test_histogram_no_meta_1_duration_seconds_bucket{label1="value1",le="0.5"} 5
494
-test_histogram_no_meta_1_duration_seconds_bucket{label1="value1",le="+Inf"} 6
495
-test_histogram_no_meta_1_duration_seconds_sum{label1="value1"} 0.00147889
496
-test_histogram_no_meta_1_duration_seconds_count{label1="value1"} 6
497
-`,
498
- wantCollected: map[string]int64{
499
- "test_histogram_1_duration_seconds-label1=value1_bucket=+Inf": 6,
500
- "test_histogram_1_duration_seconds-label1=value1_bucket=0.1": 4,
501
- "test_histogram_1_duration_seconds-label1=value1_bucket=0.5": 5,
502
- "test_histogram_1_duration_seconds-label1=value1_count": 6,
503
- "test_histogram_1_duration_seconds-label1=value1_sum": 1,
504
- "test_histogram_no_meta_1_duration_seconds-label1=value1_bucket=+Inf": 6,
505
- "test_histogram_no_meta_1_duration_seconds-label1=value1_bucket=0.1": 4,
506
- "test_histogram_no_meta_1_duration_seconds-label1=value1_bucket=0.5": 5,
507
- "test_histogram_no_meta_1_duration_seconds-label1=value1_count": 6,
508
- "test_histogram_no_meta_1_duration_seconds-label1=value1_sum": 1,
509
- },
510
- wantCharts: 6,
511
- },
298
+ want: func(t *testing.T, fr metrix.Reader) {
299
+ assert.InDelta(t, 7, value(t, fr, "test_fallback_gauge", metrix.Labels{"label1": "value1"}), 1e-9)
300
+ assert.InDelta(t, 5, value(t, fr, "test_things_total", metrix.Labels{"label1": "value1"}), 1e-9)
301
+ _, ok := fr.Value("test_untyped_dropped", metrix.Labels{"label1": "value1"})
302
+ assert.False(t, ok, "an untyped metric with no fallback and no _total suffix must be dropped")
303
},
304
},
514
- "match Untyped as Gauge": {
305
+ "selector drops non-matching metrics": {
306
prepare: func() *Collector {
516
- collr := New()
517
- collr.FallbackType.Gauge = []string{"test_gauge_no_meta*"}
518
- return collr
307
+ c := New()
308
+ c.Selector = selector.Expr{Allow: []string{"test_keep"}}
309
+ return c
310
},
520
- steps: []testCaseStep{
521
- {
522
- desc: "Two first seen series, meta series processed as Gauge",
523
- input: `
524
-# HELP test_gauge_metric_1 Test Untyped Metric 1
525
-# TYPE test_gauge_metric_1 gauge
526
-test_gauge_metric_1{label1="value1"} 11
527
-test_gauge_metric_1{label1="value2"} 12
528
-test_gauge_no_meta_metric_1{label1="value1"} 11
529
-test_gauge_no_meta_metric_1{label1="value2"} 12
311
+ input: `
312
+# TYPE test_keep gauge
313
+test_keep{label1="value1"} 11
314
+# TYPE test_drop gauge
315
+test_drop{label1="value1"} 22
316
`,
531
- wantCollected: map[string]int64{
532
- "test_gauge_metric_1-label1=value1": 11000,
533
- "test_gauge_metric_1-label1=value2": 12000,
534
- "test_gauge_no_meta_metric_1-label1=value1": 11000,
535
- "test_gauge_no_meta_metric_1-label1=value2": 12000,
536
- },
537
- wantCharts: 4,
538
- },
317
+ want: func(t *testing.T, fr metrix.Reader) {
318
+ assert.InDelta(t, 11, value(t, fr, "test_keep", metrix.Labels{"label1": "value1"}), 1e-9)
319
+ _, ok := fr.Value("test_drop", metrix.Labels{"label1": "value1"})
320
+ assert.False(t, ok, "a metric not matched by the selector must be dropped")
321
},
322
},
541
- "match Untyped as Counter": {
323
+ "_info family is skipped": {
324
+ prepare: New,
325
+ input: `
326
+# TYPE test_metric gauge
327
+test_metric{label1="value1"} 11
328
+# TYPE test_metric_info gauge
329
+test_metric_info{version="1.2.3"} 1
330
+`,
331
+ want: func(t *testing.T, fr metrix.Reader) {
332
+ assert.InDelta(t, 11, value(t, fr, "test_metric", metrix.Labels{"label1": "value1"}), 1e-9)
333
+ _, ok := fr.Value("test_metric_info", metrix.Labels{"version": "1.2.3"})
334
+ assert.False(t, ok, "an _info family must be skipped")
335
+ },
336
+ },
337
+ "per-metric series limit skips the family": {
338
prepare: func() *Collector {
543
- collr := New()
544
- collr.FallbackType.Counter = []string{"test_gauge_no_meta*"}
545
- return collr
339
+ c := New()
340
+ c.MaxTSPerMetric = 1
341
+ return c
342
},
547
- steps: []testCaseStep{
548
- {
549
- desc: "Two first seen series, meta series processed as Counter",
550
- input: `
551
-# HELP test_gauge_metric_1 Test Untyped Metric 1
552
-# TYPE test_gauge_metric_1 gauge
553
-test_gauge_metric_1{label1="value1"} 11
554
-test_gauge_metric_1{label1="value2"} 12
555
-test_gauge_no_meta_metric_1{label1="value1"} 11
556
-test_gauge_no_meta_metric_1{label1="value2"} 12
343
+ input: `
344
+# TYPE test_gauge_metric gauge
345
+test_gauge_metric{label1="value1"} 11
346
+test_gauge_metric{label1="value2"} 12
347
`,
558
- wantCollected: map[string]int64{
559
- "test_gauge_metric_1-label1=value1": 11000,
560
- "test_gauge_metric_1-label1=value2": 12000,
561
- "test_gauge_no_meta_metric_1-label1=value1": 11000,
562
- "test_gauge_no_meta_metric_1-label1=value2": 12000,
563
- },
564
- wantCharts: 4,
565
- },
348
+ want: func(t *testing.T, fr metrix.Reader) {
349
+ _, ok := fr.Value("test_gauge_metric", metrix.Labels{"label1": "value1"})
350
+ assert.False(t, ok, "a family over the per-metric series limit must be skipped entirely")
351
},
352
},
353
}
354
570
- for name, test := range tests {
355
+ for name, tc := range tests {
356
t.Run(name, func(t *testing.T) {
572
- collr := test.prepare()
573
-
574
- var metrics []byte
357
srv := httptest.NewServer(http.HandlerFunc(
576
- func(w http.ResponseWriter, r *http.Request) {
577
- _, _ = w.Write(metrics)
578
- }))
358
+ func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(tc.input)) }))
359
defer srv.Close()
360
361
+ collr := tc.prepare()
362
collr.URL = srv.URL
363
require.NoError(t, collr.Init(context.Background()))
364
584
- for num, step := range test.steps {
585
- t.Run(fmt.Sprintf("step num %d ('%s')", num+1, step.desc), func(t *testing.T) {
586
-
587
- metrics = []byte(step.input)
588
-
589
- var mx map[string]int64
365
+ // Drive Collect exactly as the framework does: one store cycle around it.
366
+ cc := cycle(t, collr.MetricStore())
367
+ cc.BeginCycle()
368
+ require.NoError(t, collr.Collect(context.Background()))
369
+ require.NoError(t, cc.CommitCycleSuccess())
370
591
- for range maxNotSeenTimes + 1 {
592
- mx = collr.Collect(context.Background())
593
- }
594
-
595
- assert.Equal(t, step.wantCollected, mx)
596
- removeObsoleteCharts(collr.Charts())
597
- assert.Len(t, *collr.Charts(), step.wantCharts)
598
- })
599
- }
371
+ tc.want(t, collr.MetricStore().Read(metrix.ReadRaw(), metrix.ReadFlatten()))
372
})
373
}
374
}
375
604
-func removeObsoleteCharts(charts *collectorapi.Charts) {
605
- var i int
606
- for _, chart := range *charts {
607
- if !chart.Obsolete {
608
- (*charts)[i] = chart
609
- i++
610
- }
376
+// TestCollector_ChartCoverage verifies the collector's own ChartTemplateYAML() (the per-job
377
+// autogen template built in Init from the configured app) plus the collected store materialize
378
+// the expected chart contexts and dimensions. Unlike the manifest parity test (which builds the
379
+// template directly), this exercises the real CollectorV2.ChartTemplateYAML() method and the
380
+// "prometheus" / "prometheus.<app>" context namespace end-to-end via chartengine autogen.
381
+func TestCollector_ChartCoverage(t *testing.T) {
382
+ tests := map[string]struct {
383
+ prepare func() *Collector
384
+ input string
385
+ want map[string][]string
386
+ }{
387
+ "default namespace, scalars and a summary split": {
388
+ prepare: New,
389
+ input: `
390
+# TYPE test_gauge_metric gauge
391
+test_gauge_metric{label1="value1"} 11
392
+# TYPE test_counter_metric_total counter
393
+test_counter_metric_total{label1="value1"} 11
394
+# TYPE test_summary_duration_seconds summary
395
+test_summary_duration_seconds{label1="value1",quantile="0.5"} 0.25
396
+test_summary_duration_seconds{label1="value1",quantile="0.99"} 0.5
397
+test_summary_duration_seconds_sum{label1="value1"} 12.5
398
+test_summary_duration_seconds_count{label1="value1"} 42
399
+`,
400
+ want: map[string][]string{
401
+ "prometheus.test_gauge_metric": {"test_gauge_metric"},
402
+ "prometheus.test_counter_metric_total": {"test_counter_metric_total"},
403
+ "prometheus.test_summary_duration_seconds": {"quantile_0.5", "quantile_0.99"},
404
+ "prometheus.test_summary_duration_seconds_sum": {"test_summary_duration_seconds_sum"},
405
+ "prometheus.test_summary_duration_seconds_count": {"test_summary_duration_seconds_count"},
406
+ },
407
+ },
408
+ "app namespace prefixes the context": {
409
+ prepare: func() *Collector { c := New(); c.Application = "myapp"; return c },
410
+ input: `
411
+# TYPE test_gauge_metric gauge
412
+test_gauge_metric{label1="value1"} 11
413
+`,
414
+ want: map[string][]string{
415
+ "prometheus.myapp.test_gauge_metric": {"test_gauge_metric"},
416
+ },
417
+ },
418
+ }
419
+
420
+ for name, tc := range tests {
421
+ t.Run(name, func(t *testing.T) {
422
+ srv := httptest.NewServer(http.HandlerFunc(
423
+ func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(tc.input)) }))
424
+ defer srv.Close()
425
+
426
+ collr := tc.prepare()
427
+ collr.URL = srv.URL
428
+ require.NoError(t, collr.Init(context.Background()))
429
+
430
+ cc := cycle(t, collr.MetricStore())
431
+ cc.BeginCycle()
432
+ require.NoError(t, collr.Collect(context.Background()))
433
+ require.NoError(t, cc.CommitCycleSuccess())
434
+
435
+ collecttest.AssertChartCoverage(t, collr, collecttest.ChartCoverageExpectation{RequiredContexts: tc.want})
436
+ })
437
}
612
- *charts = (*charts)[:i]
438
}
src/go/plugin/go.d/collector/prometheus/config_schema.json
+1
-1
@@ -127,7 +127,7 @@
127
},
128
"uniqueItems": true
129
},
130
- "Counter": {
130
+ "counter": {
131
"title": "As Counter",
132
"description": "Untyped metrics matching any [pattern](https://golang.org/pkg/path/filepath/#Match) will be processed as Counter.",
133
"type": [
src/go/plugin/go.d/collector/prometheus/init.go
+1
-1
@@ -39,7 +39,7 @@ func (c *Collector) initPrometheusClient() (prometheus.Prometheus, error) {
39
40
func (c *Collector) initFallbackTypeMatcher(expr []string) (matcher.Matcher, error) {
41
if len(expr) == 0 {
42
- return nil, nil
42
+ return matcher.FALSE(), nil
43
}
44
45
m := matcher.FALSE()
src/go/plugin/go.d/collector/prometheus/manifest_test.go
+255
-110
@@ -5,7 +5,8 @@ package prometheus
5
import (
6
"context"
7
"encoding/json"
8
- "flag"
8
+ "fmt"
9
+ "maps"
10
"net/http"
11
"net/http/httptest"
12
"os"
@@ -17,35 +18,39 @@ import (
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
24
-// updateGolden, when set, makes the manifest test (re)write the golden files and
25
-// skip the comparison. The goldens are a frozen baseline of the V1 collector's
26
-// observable contract; the V2 migration (PR5) checks parity by diffing its render
27
-// against them. Regenerate ONLY when intentionally adding a fixture or accepting a
28
-// contract change, then review the git diff — never to silently absorb V2 drift,
29
-// which would defeat the baseline. Hand-editing the JSON is error-prone, so this is
30
-// the supported way to maintain them:
31
-//
32
-// go test ./plugin/go.d/collector/prometheus/ -run TestCollector_compatManifest -update-golden
33
-var updateGolden = flag.Bool("update-golden", false, "regenerate the prometheus compat-manifest golden files")
34
-
35
-// The compat manifest captures the V1 collector's observable CONTRACT, so the V2
36
-// migration (PR5) can be verified to preserve it.
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
40
-// semantic name with algo + the real (de-scaled) value. `soft` holds best-effort
41
-// fields autogen may derive differently (units/family/type). The V1 chart-ID
42
-// strings and the ×1000 / ×1e6 precision divisor are INTENTIONALLY excluded — both
43
-// change by design in V2 (autogen chart-IDs; float dimensions). Values are
44
-// de-scaled (mx ÷ Div) to the real number V2's float dims emit directly.
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
//
46
-// Note: V1 scales values into int64 (×1000 / ×1e6), so sub-precision values are
47
-// truncated (0.00147889 → 1 → 0.001). The manifest records V1's truncated value;
48
-// the PR5 verification must diff values float-tolerantly (V2 is more precise).
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"`
@@ -65,54 +70,6 @@ type manifestSoft struct {
70
Type string `json:"type"`
71
}
72
68
-func renderManifest(charts *collectorapi.Charts, mx map[string]int64) []manifestChart {
69
- out := make([]manifestChart, 0, len(*charts))
70
-
71
- for _, ch := range *charts {
72
- if ch.Obsolete {
73
- continue
74
- }
75
-
76
- mc := manifestChart{
77
- Context: ch.Ctx,
78
- Soft: manifestSoft{Units: ch.Units, Family: ch.Fam, Type: string(ch.Type)},
79
- }
80
- if len(ch.Labels) > 0 {
81
- mc.Labels = make(map[string]string, len(ch.Labels))
82
- for _, l := range ch.Labels {
83
- mc.Labels[l.Key] = l.Value
84
- }
85
- }
86
- for _, d := range ch.Dims {
87
- div := d.Div
88
- if div == 0 {
89
- div = 1
90
- }
91
- algo := "absolute"
92
- if d.Algo != "" {
93
- algo = string(d.Algo)
94
- }
95
- mc.Dims = append(mc.Dims, manifestDim{
96
- Name: d.Name,
97
- Algo: algo,
98
- Value: float64(mx[d.ID]) / float64(div),
99
- })
100
- }
101
- sort.Slice(mc.Dims, func(i, j int) bool { return mc.Dims[i].Name < mc.Dims[j].Name })
102
-
103
- out = append(out, mc)
104
- }
105
-
106
- sort.Slice(out, func(i, j int) bool {
107
- if out[i].Context != out[j].Context {
108
- return out[i].Context < out[j].Context
109
- }
110
- return manifestLabelsKey(out[i].Labels) < manifestLabelsKey(out[j].Labels)
111
- })
112
-
113
- return out
114
-}
115
-
73
func manifestLabelsKey(m map[string]string) string {
74
keys := make([]string, 0, len(m))
75
for k := range m {
@@ -122,16 +79,25 @@ func manifestLabelsKey(m map[string]string) string {
79
80
var sb strings.Builder
81
for _, k := range keys {
125
- sb.WriteString(k + "=" + m[k] + ";")
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
130
-func TestCollector_compatManifest(t *testing.T) {
131
- tests := map[string]struct {
132
- prepare func() *Collector
133
- input string
134
- }{
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: `
@@ -182,7 +148,7 @@ test_gauge_metric{label1="value1"} 11
148
`,
149
},
150
"app_job_name": {
185
- // Application empty -> the app segment falls back to the job Name (charts.go:238-241).
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
@@ -197,9 +163,9 @@ test_gauge_metric{label1="value1"} 11
163
`,
164
},
165
"snmp_units": {
200
- // Special unit mappings (charts.go getChartUnits): uppercase snmp-exporter
201
- // names octets->bytes, pkts->packets, mtu->octets, speed->bits; underscore
202
- // suffix hertz->Hz.
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
@@ -247,8 +213,9 @@ test_untyped_metric{label1="value1"} 11
213
`,
214
},
215
"fallback_counter": {
250
- // Untyped metric forced to counter by regex — a distinct path from the
251
- // _total auto-counter (independent `if` at collect.go:176); algo incremental.
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"}
@@ -259,8 +226,132 @@ 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
263
- for name, tc := range tests {
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)) }))
@@ -270,41 +361,95 @@ test_untyped_metric{label1="value1"} 11
361
collr.URL = srv.URL
362
require.NoError(t, collr.Init(context.Background()))
363
273
- mx := collr.Collect(context.Background())
274
- require.NotNil(t, mx)
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
276
- got := renderManifest(collr.Charts(), mx)
277
- data, err := json.MarshalIndent(got, "", " ")
278
- require.NoError(t, err)
279
- data = append(data, '\n')
370
+ got := renderManifestV2(t, collr.MetricStore(), collr.ChartTemplateYAML())
371
281
- path := filepath.Join("testdata", "golden", goldenName(name)+".json")
282
- if *updateGolden {
283
- require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
284
- require.NoError(t, os.WriteFile(path, data, 0o644))
285
- return
286
- }
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
288
- want, err := os.ReadFile(path)
289
- require.NoErrorf(t, err, "missing golden %q — run: go test -run TestCollector_compatManifest -update-golden ./...", path)
290
- assert.Equal(t, string(want), string(data))
377
+ assertManifestParity(t, want, got)
378
})
379
}
380
}
381
295
-// Config defaults the V2 migration (PR5) must preserve: update_every is the
296
-// registered Creator default (collectorapi.Defaults); max_time_series[_per_metric]
297
-// are New() defaults. A V2 re-registration can silently drop them.
298
-func TestCollector_compatConfigDefaults(t *testing.T) {
299
- creator, ok := collectorapi.DefaultRegistry.Lookup("prometheus")
300
- require.True(t, ok, "prometheus collector must be registered")
301
- assert.Equal(t, 10, creator.Defaults.UpdateEvery, "update_every default")
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
303
- c := New()
304
- assert.Equal(t, 2000, c.MaxTS, "max_time_series default")
305
- assert.Equal(t, 200, c.MaxTSPerMetric, "max_time_series_per_metric default")
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
308
-func goldenName(name string) string {
309
- return strings.NewReplacer(" ", "_", "(", "", ")", "", ">", "", "-", "_", ".", "_", "/", "_", "<", "").Replace(name)
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
}
src/go/plugin/go.d/collector/prometheus/writer.go
new
+360
@@ -0,0 +1,360 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package prometheus
4
+
5
+import (
6
+ "slices"
7
+ "strconv"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/logger"
11
+ "github.com/netdata/netdata/go/plugins/pkg/matcher"
12
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
13
+ prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
14
+ commonmodel "github.com/prometheus/common/model"
15
+)
16
+
17
+// seriesCacheRetentionCycles bounds the per-series instrument cache: a cached handle not observed for
18
+// this many successful cycles is evicted. This value mirrors two other retention windows that are NOT
19
+// compiler-linked and MUST be kept in agreement: metrix's default store retention (so a cached handle
20
+// lives as long as the series it writes) and the chart template's expiry (chartExpireAfterCycles, so a
21
+// chart is not removed before the series feeding it). The cache also stays bounded under label churn.
22
+const seriesCacheRetentionCycles = 10
23
+
24
+type metricFamilyWriterPolicy struct {
25
+ labelPrefix string
26
+ maxTSPerMetric int
27
+ isFallbackTypeGauge matcher.Matcher
28
+ isFallbackTypeCounter matcher.Matcher
29
+}
30
+
31
+type metricFamilyWriter struct {
32
+ store metrix.CollectorStore
33
+ policy metricFamilyWriterPolicy
34
+ handles map[string]*metricFamilyHandle
35
+ cycle uint64
36
+ *logger.Logger
37
+}
38
+
39
+// cachedInstrument is a per-series instrument handle plus the last cycle it was observed, so handles
40
+// for series that stop appearing in scrapes can be evicted.
41
+type cachedInstrument[T any] struct {
42
+ inst T
43
+ lastSeen uint64
44
+}
45
+
46
+// metricFamilyHandle caches, per metric name, the canonical distribution schema, instrument options,
47
+// and the per-series instrument handles. The family handle is created once per name and KEPT for the
48
+// job's lifetime on purpose: metrix registers an instrument descriptor per name permanently (no
49
+// unregister API), so if a name reappeared with a changed contract (kind, summary quantiles, or
50
+// histogram bounds) and the writer re-registered it, metrix would panic on the mismatch. Keeping the
51
+// handle lets ensureHandle detect that drift and skip it instead of re-registering.
52
+//
53
+// The per-series instrument handles inside ARE evicted: they are reused across cycles (skipping
54
+// per-series instrument re-resolution) and dropped once a series goes unobserved for
55
+// seriesCacheRetentionCycles, so the cache stays bounded under label-value churn — unlike a metrix
56
+// vec, whose internal handle cache is unbounded. A family may mix label-key sets; each series is
57
+// cached and written by its own full label tuple. Per-name state (this handle plus the metrix
58
+// descriptor) is bounded by metric-name cardinality; metric-NAME churn (a Prometheus anti-pattern)
59
+// grows it — an accepted metrix-store limit.
60
+type metricFamilyHandle struct {
61
+ name string
62
+ typ commonmodel.MetricType
63
+ summaryQuantiles []float64
64
+ histogramBounds []float64
65
+ opts []metrix.InstrumentOption
66
+
67
+ gauges map[string]*cachedInstrument[metrix.SnapshotGauge]
68
+ counters map[string]*cachedInstrument[metrix.SnapshotCounter]
69
+ summaries map[string]*cachedInstrument[metrix.SnapshotSummary]
70
+ histograms map[string]*cachedInstrument[metrix.SnapshotHistogram]
71
+}
72
+
73
+func newMetricFamilyWriter(store metrix.CollectorStore, policy metricFamilyWriterPolicy, log *logger.Logger) *metricFamilyWriter {
74
+ // A family with no configured fallback matcher uses a never-matching matcher, so
75
+ // resolveFamilyType can call MatchString unconditionally (no per-cycle nil check).
76
+ if policy.isFallbackTypeGauge == nil {
77
+ policy.isFallbackTypeGauge = matcher.FALSE()
78
+ }
79
+ if policy.isFallbackTypeCounter == nil {
80
+ policy.isFallbackTypeCounter = matcher.FALSE()
81
+ }
82
+ return &metricFamilyWriter{
83
+ store: store,
84
+ policy: policy,
85
+ handles: make(map[string]*metricFamilyHandle),
86
+ Logger: log,
87
+ }
88
+}
89
+
90
+// countWritable reports how many series across all families could be written. Used at Check to
91
+// confirm the endpoint exposes usable metrics, before any cycle has run.
92
+func (w *metricFamilyWriter) countWritable(mfs prompkg.MetricFamilies) int {
93
+ count := 0
94
+ for _, mf := range mfs {
95
+ if w.skipMetricFamily(mf) {
96
+ continue
97
+ }
98
+
99
+ typ, ok := w.resolveFamilyType(mf)
100
+ if !ok {
101
+ continue
102
+ }
103
+
104
+ schema, ok := deriveMetricFamilySchema(mf, typ)
105
+ if !ok {
106
+ continue
107
+ }
108
+
109
+ for _, metric := range mf.Metrics() {
110
+ if metricIsWritable(metric, typ, schema) {
111
+ count++
112
+ }
113
+ }
114
+ }
115
+ return count
116
+}
117
+
118
+func (w *metricFamilyWriter) writeMetricFamilies(mfs prompkg.MetricFamilies) int {
119
+ w.cycle++
120
+
121
+ written := 0
122
+ for _, mf := range mfs {
123
+ if w.skipMetricFamily(mf) {
124
+ continue
125
+ }
126
+
127
+ typ, ok := w.resolveFamilyType(mf)
128
+ if !ok {
129
+ continue
130
+ }
131
+
132
+ handle, ok := w.ensureHandle(mf, typ)
133
+ if !ok {
134
+ continue
135
+ }
136
+
137
+ for _, metric := range mf.Metrics() {
138
+ if w.observeMetric(handle, metric) {
139
+ written++
140
+ }
141
+ }
142
+ }
143
+
144
+ w.evictStaleSeries()
145
+ return written
146
+}
147
+
148
+func (w *metricFamilyWriter) skipMetricFamily(mf *prompkg.MetricFamily) bool {
149
+ if strings.HasSuffix(mf.Name(), "_info") {
150
+ return true
151
+ }
152
+ if w.policy.maxTSPerMetric > 0 && len(mf.Metrics()) > w.policy.maxTSPerMetric {
153
+ w.Debugf("metric '%s' num of time series (%d) > limit (%d), skipping it",
154
+ mf.Name(), len(mf.Metrics()), w.policy.maxTSPerMetric)
155
+ return true
156
+ }
157
+ return false
158
+}
159
+
160
+func (w *metricFamilyWriter) resolveFamilyType(mf *prompkg.MetricFamily) (commonmodel.MetricType, bool) {
161
+ switch mf.Type() {
162
+ case commonmodel.MetricTypeGauge,
163
+ commonmodel.MetricTypeCounter,
164
+ commonmodel.MetricTypeSummary,
165
+ commonmodel.MetricTypeHistogram:
166
+ return mf.Type(), true
167
+ case commonmodel.MetricTypeUnknown:
168
+ if w.policy.isFallbackTypeGauge.MatchString(mf.Name()) {
169
+ return commonmodel.MetricTypeGauge, true
170
+ }
171
+ if w.policy.isFallbackTypeCounter.MatchString(mf.Name()) || strings.HasSuffix(mf.Name(), "_total") {
172
+ return commonmodel.MetricTypeCounter, true
173
+ }
174
+ return "", false
175
+ default:
176
+ return "", false
177
+ }
178
+}
179
+
180
+func (w *metricFamilyWriter) ensureHandle(mf *prompkg.MetricFamily, typ commonmodel.MetricType) (*metricFamilyHandle, bool) {
181
+ if handle, ok := w.handles[mf.Name()]; ok {
182
+ if handle.typ != typ {
183
+ w.Debugf("skip metric family '%s': metric type drift (%s -> %s)", mf.Name(), handle.typ, typ)
184
+ return nil, false
185
+ }
186
+ return handle, true
187
+ }
188
+
189
+ schema, ok := deriveMetricFamilySchema(mf, typ)
190
+ if !ok {
191
+ return nil, false
192
+ }
193
+
194
+ opts := []metrix.InstrumentOption{
195
+ metrix.WithChartFamily(getChartFamily(mf.Name())),
196
+ metrix.WithChartPriority(getChartPriority(mf.Name())),
197
+ metrix.WithUnit(instrumentUnit(mf.Name(), typ)),
198
+ metrix.WithFloat(true),
199
+ metrix.WithDescription(getChartTitle(mf.Name(), mf.Help())),
200
+ }
201
+
202
+ handle := &metricFamilyHandle{
203
+ name: mf.Name(),
204
+ typ: typ,
205
+ summaryQuantiles: slices.Clone(schema.summaryQuantiles),
206
+ histogramBounds: slices.Clone(schema.histogramBounds),
207
+ opts: opts,
208
+ }
209
+
210
+ switch typ {
211
+ case commonmodel.MetricTypeGauge:
212
+ handle.gauges = make(map[string]*cachedInstrument[metrix.SnapshotGauge])
213
+ case commonmodel.MetricTypeCounter:
214
+ handle.counters = make(map[string]*cachedInstrument[metrix.SnapshotCounter])
215
+ case commonmodel.MetricTypeSummary:
216
+ handle.opts = append(handle.opts, metrix.WithSummaryQuantiles(schema.summaryQuantiles...))
217
+ handle.summaries = make(map[string]*cachedInstrument[metrix.SnapshotSummary])
218
+ case commonmodel.MetricTypeHistogram:
219
+ handle.opts = append(handle.opts, metrix.WithHistogramBounds(schema.histogramBounds...))
220
+ handle.histograms = make(map[string]*cachedInstrument[metrix.SnapshotHistogram])
221
+ }
222
+
223
+ w.handles[mf.Name()] = handle
224
+ return handle, true
225
+}
226
+
227
+func (w *metricFamilyWriter) observeMetric(handle *metricFamilyHandle, metric prompkg.Metric) bool {
228
+ schema, ok := deriveMetricSchema(metric, handle.typ)
229
+ if !ok {
230
+ return false
231
+ }
232
+ if !slices.Equal(handle.summaryQuantiles, schema.summaryQuantiles) || !slices.Equal(handle.histogramBounds, schema.histogramBounds) {
233
+ w.Debugf("skip a series of metric '%s': distribution schema drift", handle.name)
234
+ return false
235
+ }
236
+
237
+ sig := w.seriesSig(metric)
238
+
239
+ switch handle.typ {
240
+ case commonmodel.MetricTypeGauge:
241
+ value, ok := metricScalarValue(metric, commonmodel.MetricTypeGauge)
242
+ if !ok {
243
+ return false
244
+ }
245
+ inst := getOrCreateInstrument(handle.gauges, sig, w.cycle, func() metrix.SnapshotGauge {
246
+ return w.store.Write().SnapshotMeter("").WithLabels(w.seriesLabels(metric)...).Gauge(handle.name, handle.opts...)
247
+ })
248
+ inst.Observe(value)
249
+ return true
250
+ case commonmodel.MetricTypeCounter:
251
+ value, ok := metricScalarValue(metric, commonmodel.MetricTypeCounter)
252
+ if !ok {
253
+ return false
254
+ }
255
+ inst := getOrCreateInstrument(handle.counters, sig, w.cycle, func() metrix.SnapshotCounter {
256
+ return w.store.Write().SnapshotMeter("").WithLabels(w.seriesLabels(metric)...).Counter(handle.name, handle.opts...)
257
+ })
258
+ inst.ObserveTotal(value)
259
+ return true
260
+ case commonmodel.MetricTypeSummary:
261
+ point, ok := toSummaryPoint(metric.Summary())
262
+ if !ok {
263
+ return false
264
+ }
265
+ inst := getOrCreateInstrument(handle.summaries, sig, w.cycle, func() metrix.SnapshotSummary {
266
+ return w.store.Write().SnapshotMeter("").WithLabels(w.seriesLabels(metric)...).Summary(handle.name, handle.opts...)
267
+ })
268
+ inst.ObservePoint(point)
269
+ return true
270
+ case commonmodel.MetricTypeHistogram:
271
+ point, ok := toHistogramPoint(metric.Histogram())
272
+ if !ok {
273
+ return false
274
+ }
275
+ inst := getOrCreateInstrument(handle.histograms, sig, w.cycle, func() metrix.SnapshotHistogram {
276
+ return w.store.Write().SnapshotMeter("").WithLabels(w.seriesLabels(metric)...).Histogram(handle.name, handle.opts...)
277
+ })
278
+ inst.ObservePoint(point)
279
+ return true
280
+ default:
281
+ return false
282
+ }
283
+}
284
+
285
+// getOrCreateInstrument returns the cached instrument handle for a series signature, creating and
286
+// caching it on first use, and stamps it as observed in the current cycle.
287
+func getOrCreateInstrument[T any](m map[string]*cachedInstrument[T], sig string, cycle uint64, create func() T) T {
288
+ e, ok := m[sig]
289
+ if !ok {
290
+ e = &cachedInstrument[T]{inst: create()}
291
+ m[sig] = e
292
+ }
293
+ e.lastSeen = cycle
294
+ return e.inst
295
+}
296
+
297
+// evictStaleSeries drops cached instrument handles for series not observed within the retention
298
+// window, keeping the cache bounded under label-value churn.
299
+func (w *metricFamilyWriter) evictStaleSeries() {
300
+ for _, h := range w.handles {
301
+ switch h.typ {
302
+ case commonmodel.MetricTypeGauge:
303
+ evictStaleInstruments(h.gauges, w.cycle)
304
+ case commonmodel.MetricTypeCounter:
305
+ evictStaleInstruments(h.counters, w.cycle)
306
+ case commonmodel.MetricTypeSummary:
307
+ evictStaleInstruments(h.summaries, w.cycle)
308
+ case commonmodel.MetricTypeHistogram:
309
+ evictStaleInstruments(h.histograms, w.cycle)
310
+ }
311
+ }
312
+}
313
+
314
+func evictStaleInstruments[T any](m map[string]*cachedInstrument[T], cycle uint64) {
315
+ for sig, e := range m {
316
+ if e.lastSeen+seriesCacheRetentionCycles <= cycle {
317
+ delete(m, sig)
318
+ }
319
+ }
320
+}
321
+
322
+// seriesSig builds a collision-safe key identifying a scraped series by its (prefixed) label tuple.
323
+// Prometheus labels are sorted by name, so the key is stable for a given series.
324
+func (w *metricFamilyWriter) seriesSig(metric prompkg.Metric) string {
325
+ lbs := metric.Labels()
326
+ if len(lbs) == 0 {
327
+ return ""
328
+ }
329
+ var b strings.Builder
330
+ for _, l := range lbs {
331
+ key := l.Name
332
+ if w.policy.labelPrefix != "" {
333
+ key = w.policy.labelPrefix + "_" + l.Name
334
+ }
335
+ b.WriteString(strconv.Itoa(len(key)))
336
+ b.WriteByte(':')
337
+ b.WriteString(key)
338
+ b.WriteByte('=')
339
+ b.WriteString(strconv.Itoa(len(l.Value)))
340
+ b.WriteByte(':')
341
+ b.WriteString(l.Value)
342
+ b.WriteByte('\xff')
343
+ }
344
+ return b.String()
345
+}
346
+
347
+// seriesLabels converts a scraped series' labels into metrix labels, applying the configured
348
+// label_prefix to each label key (V1 prepended "<prefix>_" to label keys).
349
+func (w *metricFamilyWriter) seriesLabels(metric prompkg.Metric) []metrix.Label {
350
+ lbs := metric.Labels()
351
+ out := make([]metrix.Label, 0, len(lbs))
352
+ for _, l := range lbs {
353
+ key := l.Name
354
+ if w.policy.labelPrefix != "" {
355
+ key = w.policy.labelPrefix + "_" + l.Name
356
+ }
357
+ out = append(out, metrix.Label{Key: key, Value: l.Value})
358
+ }
359
+ return out
360
+}
src/go/plugin/go.d/collector/prometheus/writer_bench_test.go
new
+93
@@ -0,0 +1,93 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package prometheus
4
+
5
+import (
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/netdata/netdata/go/plugins/logger"
13
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
14
+ prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
15
+ "github.com/netdata/netdata/go/plugins/pkg/web"
16
+)
17
+
18
+// BenchmarkMetricFamilyWriter measures the steady-state cost of writing a high-cardinality scrape
19
+// (seriesPerType series of each of the four metric types) to metrix each cycle. The scrape is parsed
20
+// once; the loop exercises only writeMetricFamilies so the per-series instrument-resolution cost is
21
+// isolated from parsing/HTTP.
22
+//
23
+// Indicative results on a developer laptop (macOS, 14 logical CPUs; `-benchmem -count=3`),
24
+// 2000 series/cycle — relative figures, not an absolute or CI baseline:
25
+//
26
+// per-series resolve, no cache: ~1.60 ms/op 3.46 MB/op 38088 allocs/op
27
+// bounded per-series handle cache: ~0.96 ms/op 1.92 MB/op 18608 allocs/op
28
+//
29
+// The writer caches the per-series instrument handle and evicts it after seriesCacheRetentionCycles
30
+// unobserved cycles. A metrix vec is deliberately NOT used: its vecCache is unbounded for the vec's
31
+// lifetime and is not pruned by store retention (pkg/metrix/vec.go), so it would leak under
32
+// label-value churn; the bounded cache keeps the speedup without that risk.
33
+func BenchmarkMetricFamilyWriter(b *testing.B) {
34
+ const seriesPerType = 500
35
+
36
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
37
+ _, _ = w.Write([]byte(buildBenchExposition(seriesPerType)))
38
+ }))
39
+ defer srv.Close()
40
+
41
+ mfs, err := prompkg.New(srv.Client(), web.RequestConfig{URL: srv.URL}).Scrape()
42
+ if err != nil {
43
+ b.Fatal(err)
44
+ }
45
+
46
+ store := metrix.NewCollectorStore()
47
+ w := newMetricFamilyWriter(store, metricFamilyWriterPolicy{}, logger.New())
48
+ managed, ok := metrix.AsCycleManagedStore(store)
49
+ if !ok {
50
+ b.Fatal("store is not cycle-managed")
51
+ }
52
+ cc := managed.CycleController()
53
+
54
+ b.ReportAllocs()
55
+ b.ResetTimer()
56
+ for i := 0; i < b.N; i++ {
57
+ cc.BeginCycle()
58
+ w.writeMetricFamilies(mfs)
59
+ if err := cc.CommitCycleSuccess(); err != nil {
60
+ b.Fatal(err)
61
+ }
62
+ }
63
+}
64
+
65
+func buildBenchExposition(n int) string {
66
+ var b strings.Builder
67
+
68
+ b.WriteString("# TYPE bench_gauge_bytes gauge\n")
69
+ for i := range n {
70
+ fmt.Fprintf(&b, "bench_gauge_bytes{id=\"%d\",az=\"a\"} %d\n", i, i)
71
+ }
72
+ b.WriteString("# TYPE bench_ops_total counter\n")
73
+ for i := range n {
74
+ fmt.Fprintf(&b, "bench_ops_total{id=\"%d\",az=\"a\"} %d\n", i, i)
75
+ }
76
+ b.WriteString("# TYPE bench_latency_seconds summary\n")
77
+ for i := range n {
78
+ fmt.Fprintf(&b, "bench_latency_seconds{id=\"%d\",quantile=\"0.5\"} 0.1\n", i)
79
+ fmt.Fprintf(&b, "bench_latency_seconds{id=\"%d\",quantile=\"0.9\"} 0.2\n", i)
80
+ fmt.Fprintf(&b, "bench_latency_seconds_sum{id=\"%d\"} 1.0\n", i)
81
+ fmt.Fprintf(&b, "bench_latency_seconds_count{id=\"%d\"} 10\n", i)
82
+ }
83
+ b.WriteString("# TYPE bench_dur_seconds histogram\n")
84
+ for i := range n {
85
+ fmt.Fprintf(&b, "bench_dur_seconds_bucket{id=\"%d\",le=\"0.1\"} 1\n", i)
86
+ fmt.Fprintf(&b, "bench_dur_seconds_bucket{id=\"%d\",le=\"0.5\"} 2\n", i)
87
+ fmt.Fprintf(&b, "bench_dur_seconds_bucket{id=\"%d\",le=\"+Inf\"} 3\n", i)
88
+ fmt.Fprintf(&b, "bench_dur_seconds_sum{id=\"%d\"} 0.5\n", i)
89
+ fmt.Fprintf(&b, "bench_dur_seconds_count{id=\"%d\"} 3\n", i)
90
+ }
91
+
92
+ return b.String()
93
+}
src/go/plugin/go.d/collector/prometheus/writer_schema.go
new
+249
@@ -0,0 +1,249 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package prometheus
4
+
5
+import (
6
+ "cmp"
7
+ "math"
8
+ "slices"
9
+
10
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
11
+ prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
12
+ commonmodel "github.com/prometheus/common/model"
13
+)
14
+
15
+// metricFamilySchema captures the per-name distribution schema that metrix requires to stay stable
16
+// across a family's series: the quantile set for summaries, the bucket bounds for histograms.
17
+// Label keys are intentionally NOT part of the schema — each series carries its own labels, so a
18
+// family may legitimately mix label-key sets (V1 rendered every series independently).
19
+type metricFamilySchema struct {
20
+ summaryQuantiles []float64
21
+ histogramBounds []float64
22
+}
23
+
24
+func deriveMetricFamilySchema(mf *prompkg.MetricFamily, typ commonmodel.MetricType) (metricFamilySchema, bool) {
25
+ for _, metric := range mf.Metrics() {
26
+ schema, ok := deriveMetricSchema(metric, typ)
27
+ if ok {
28
+ return schema, true
29
+ }
30
+ }
31
+
32
+ return metricFamilySchema{}, false
33
+}
34
+
35
+func deriveMetricSchema(metric prompkg.Metric, typ commonmodel.MetricType) (metricFamilySchema, bool) {
36
+ var schema metricFamilySchema
37
+
38
+ switch typ {
39
+ case commonmodel.MetricTypeGauge:
40
+ if _, ok := metricScalarValue(metric, commonmodel.MetricTypeGauge); !ok {
41
+ return metricFamilySchema{}, false
42
+ }
43
+ case commonmodel.MetricTypeCounter:
44
+ if _, ok := metricScalarValue(metric, commonmodel.MetricTypeCounter); !ok {
45
+ return metricFamilySchema{}, false
46
+ }
47
+ case commonmodel.MetricTypeSummary:
48
+ summary := metric.Summary()
49
+ if summary == nil {
50
+ return metricFamilySchema{}, false
51
+ }
52
+ qs, ok := summaryQuantiles(summary)
53
+ if !ok {
54
+ return metricFamilySchema{}, false
55
+ }
56
+ if _, ok := toSummaryPoint(summary); !ok {
57
+ return metricFamilySchema{}, false
58
+ }
59
+ schema.summaryQuantiles = qs
60
+ case commonmodel.MetricTypeHistogram:
61
+ histogram := metric.Histogram()
62
+ if histogram == nil {
63
+ return metricFamilySchema{}, false
64
+ }
65
+ bounds, ok := histogramBounds(histogram)
66
+ if !ok {
67
+ return metricFamilySchema{}, false
68
+ }
69
+ if _, ok := toHistogramPoint(histogram); !ok {
70
+ return metricFamilySchema{}, false
71
+ }
72
+ schema.histogramBounds = bounds
73
+ default:
74
+ return metricFamilySchema{}, false
75
+ }
76
+
77
+ return schema, true
78
+}
79
+
80
+// metricIsWritable reports whether a series can be written under the family's canonical schema.
81
+// Only the distribution schema must match (metrix keys hist/summary schema by metric name); label
82
+// keys may differ between series and are written per-series.
83
+func metricIsWritable(metric prompkg.Metric, typ commonmodel.MetricType, schema metricFamilySchema) bool {
84
+ metricSchema, ok := deriveMetricSchema(metric, typ)
85
+ if !ok {
86
+ return false
87
+ }
88
+ return slices.Equal(schema.summaryQuantiles, metricSchema.summaryQuantiles) &&
89
+ slices.Equal(schema.histogramBounds, metricSchema.histogramBounds)
90
+}
91
+
92
+func metricScalarValue(metric prompkg.Metric, typ commonmodel.MetricType) (float64, bool) {
93
+ switch typ {
94
+ case commonmodel.MetricTypeGauge:
95
+ if gauge := metric.Gauge(); gauge != nil && isFinite(gauge.Value()) {
96
+ return gauge.Value(), true
97
+ }
98
+ case commonmodel.MetricTypeCounter:
99
+ if counter := metric.Counter(); counter != nil && isFinite(counter.Value()) {
100
+ return counter.Value(), true
101
+ }
102
+ }
103
+
104
+ // Untyped fallthrough: a family resolved to gauge/counter via fallback_type carries its value in
105
+ // Untyped() (a real typed gauge/counter already returned from the switch). This is the only way a
106
+ // gauge/counter-typed family reaches here.
107
+ if untyped := metric.Untyped(); untyped != nil && isFinite(untyped.Value()) {
108
+ return untyped.Value(), true
109
+ }
110
+
111
+ return 0, false
112
+}
113
+
114
+func toSummaryPoint(summary *prompkg.Summary) (metrix.SummaryPoint, bool) {
115
+ if summary == nil || len(summary.Quantiles()) == 0 {
116
+ return metrix.SummaryPoint{}, false
117
+ }
118
+ // A Prometheus summary leaves every quantile NaN for an empty observation window. Skip the
119
+ // whole summary so a chart is not created until it has a real value (consistent with the
120
+ // scalar NaN-skip); writing resumes once any quantile is observed.
121
+ if summary.IsNaN() {
122
+ return metrix.SummaryPoint{}, false
123
+ }
124
+ if !isFinite(summary.Count()) || !isFinite(summary.Sum()) || summary.Count() < 0 {
125
+ return metrix.SummaryPoint{}, false
126
+ }
127
+
128
+ quantiles := make([]metrix.QuantilePoint, 0, len(summary.Quantiles()))
129
+ for _, q := range summary.Quantiles() {
130
+ // A partially-observed summary can still carry a NaN quantile (an all-NaN summary is
131
+ // skipped above). Keep the NaN: metrix stores it and chartengine renders that dimension
132
+ // as a gap. Only an infinite quantile value is rejected.
133
+ if !isFinite(q.Quantile()) || q.Quantile() < 0 || q.Quantile() > 1 || math.IsInf(q.Value(), 0) {
134
+ return metrix.SummaryPoint{}, false
135
+ }
136
+ quantiles = append(quantiles, metrix.QuantilePoint{
137
+ Quantile: q.Quantile(),
138
+ Value: q.Value(),
139
+ })
140
+ }
141
+
142
+ return metrix.SummaryPoint{
143
+ Count: summary.Count(),
144
+ Sum: summary.Sum(),
145
+ Quantiles: quantiles,
146
+ }, true
147
+}
148
+
149
+// toHistogramPoint validates and converts a scraped histogram into a metrix point. The le="+Inf"
150
+// bucket is intentionally dropped: metrix synthesizes the le="+Inf" flattened series from Count, so a
151
+// malformed +Inf count is superseded by Count rather than causing the whole histogram to be rejected.
152
+// Validation (finiteness, strictly-increasing bounds, monotonic cumulative counts, last bucket <=
153
+// Count) therefore runs over the finite buckets only.
154
+func toHistogramPoint(histogram *prompkg.Histogram) (metrix.HistogramPoint, bool) {
155
+ if histogram == nil || len(histogram.Buckets()) == 0 {
156
+ return metrix.HistogramPoint{}, false
157
+ }
158
+ if !isFinite(histogram.Count()) || !isFinite(histogram.Sum()) || histogram.Count() < 0 {
159
+ return metrix.HistogramPoint{}, false
160
+ }
161
+
162
+ buckets := make([]metrix.BucketPoint, 0, len(histogram.Buckets()))
163
+ for _, b := range histogram.Buckets() {
164
+ if math.IsNaN(b.UpperBound()) || math.IsInf(b.UpperBound(), -1) {
165
+ return metrix.HistogramPoint{}, false
166
+ }
167
+ if math.IsInf(b.UpperBound(), +1) {
168
+ continue
169
+ }
170
+ if !isFinite(b.CumulativeCount()) || b.CumulativeCount() < 0 {
171
+ return metrix.HistogramPoint{}, false
172
+ }
173
+ buckets = append(buckets, metrix.BucketPoint{
174
+ UpperBound: b.UpperBound(),
175
+ CumulativeCount: b.CumulativeCount(),
176
+ })
177
+ }
178
+
179
+ slices.SortFunc(buckets, func(a, b metrix.BucketPoint) int { return cmp.Compare(a.UpperBound, b.UpperBound) })
180
+ for i := 1; i < len(buckets); i++ {
181
+ if buckets[i].UpperBound <= buckets[i-1].UpperBound {
182
+ return metrix.HistogramPoint{}, false
183
+ }
184
+ if buckets[i].CumulativeCount < buckets[i-1].CumulativeCount {
185
+ return metrix.HistogramPoint{}, false
186
+ }
187
+ }
188
+ if n := len(buckets); n > 0 && buckets[n-1].CumulativeCount > histogram.Count() {
189
+ return metrix.HistogramPoint{}, false
190
+ }
191
+
192
+ return metrix.HistogramPoint{
193
+ Count: histogram.Count(),
194
+ Sum: histogram.Sum(),
195
+ Buckets: buckets,
196
+ }, true
197
+}
198
+
199
+func summaryQuantiles(summary *prompkg.Summary) ([]float64, bool) {
200
+ if summary == nil || len(summary.Quantiles()) == 0 {
201
+ return nil, false
202
+ }
203
+
204
+ qs := make([]float64, 0, len(summary.Quantiles()))
205
+ for _, q := range summary.Quantiles() {
206
+ if !isFinite(q.Quantile()) || q.Quantile() < 0 || q.Quantile() > 1 {
207
+ return nil, false
208
+ }
209
+ qs = append(qs, q.Quantile())
210
+ }
211
+ slices.Sort(qs)
212
+ for i := 1; i < len(qs); i++ {
213
+ if qs[i] <= qs[i-1] {
214
+ return nil, false
215
+ }
216
+ }
217
+ return qs, true
218
+}
219
+
220
+func histogramBounds(histogram *prompkg.Histogram) ([]float64, bool) {
221
+ if histogram == nil || len(histogram.Buckets()) == 0 {
222
+ return nil, false
223
+ }
224
+
225
+ bounds := make([]float64, 0, len(histogram.Buckets()))
226
+ for _, b := range histogram.Buckets() {
227
+ if math.IsNaN(b.UpperBound()) || math.IsInf(b.UpperBound(), -1) {
228
+ return nil, false
229
+ }
230
+ if math.IsInf(b.UpperBound(), +1) {
231
+ continue
232
+ }
233
+ bounds = append(bounds, b.UpperBound())
234
+ }
235
+ if len(bounds) == 0 {
236
+ return []float64{}, true
237
+ }
238
+ slices.Sort(bounds)
239
+ for i := 1; i < len(bounds); i++ {
240
+ if bounds[i] <= bounds[i-1] {
241
+ return nil, false
242
+ }
243
+ }
244
+ return bounds, true
245
+}
246
+
247
+func isFinite(v float64) bool {
248
+ return !math.IsNaN(v) && !math.IsInf(v, 0)
249
+}
src/go/plugin/go.d/collector/prometheus/writer_test.go
new
+485
@@ -0,0 +1,485 @@
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
+}
src/health/REFERENCE.md
+1
-1
@@ -1028,7 +1028,7 @@ Several stock health configurations use host variables to reference dimensions f
1028
1029
##### Prometheus Collector Variables
1030
1031
-For metrics collected by the go.d `prometheus` collector, each unique Prometheus label set usually produces a separate chart. The chart ID is built from the metric name followed by `-label=value` pairs for every label (e.g. `kubelet_volume_stats_used_bytes-persistentvolumeclaim=my-pvc`). In the Netdata chart registry, the prefix comes from the go.d job `FullName`: it is `prometheus.<metric_name>-<label_set>` only when the job name is literally `prometheus`; otherwise it is `prometheus_<job_name>.<metric_name>-<label_set>` (for example, `prometheus_local.<metric_name>-<label_set>` or `prometheus_kubelet.<metric_name>-<label_set>`). For summary and histogram metric families, the collector may also emit related chart IDs such as `<id>`, `<id>_sum`, and `<id>_count`, so verify the exact chart ID you want to reference.
1031
+For metrics collected by the go.d `prometheus` collector, each unique Prometheus label set usually produces a separate chart. The chart ID is built from the metric name followed by `-label=value` pairs for every label (e.g. `kubelet_volume_stats_used_bytes-persistentvolumeclaim=my-pvc`); characters in a label value that are not chart-ID-safe, such as `.`, are replaced with `_` in the chart ID, while the chart's label keeps the original value (so `addr="10.0.0.1"` yields `…-addr=10_0_0_1`). In the Netdata chart registry, the prefix comes from the go.d job `FullName`: it is `prometheus.<metric_name>-<label_set>` only when the job name is literally `prometheus`; otherwise it is `prometheus_<job_name>.<metric_name>-<label_set>` (for example, `prometheus_local.<metric_name>-<label_set>` or `prometheus_kubelet.<metric_name>-<label_set>`). Summary and histogram families also emit separate `_sum` and `_count` charts; the suffix is part of the metric name, so the IDs are `<metric_name>_sum-<label_set>` and `<metric_name>_count-<label_set>` (just `<metric_name>_sum` / `<metric_name>_count` when the series has no labels), while histogram buckets are dimensions of the base `<metric_name>` chart. Verify the exact chart ID you want to reference.
1032
1033
Because Prometheus chart IDs typically contain hyphens and `=` characters, use the `${...}` brace form to reference them in `calc`/`warn`/`crit` expressions — the unbraced `$var` form stops parsing at `-`. Apply the same rule for both the common `prometheus_<job_name>` prefix and the special-case plain `prometheus` prefix, including any `_sum` or `_count` chart variants.
1034