@cryptotaxi247 / netdata-1 / commits / 762744214

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