@cryptotaxi247 / netdata / commits / fbde7e017

feat(go.d/prometheus): add relabeling engine (#22660)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Ilya Mashchenko committed Jun 8, 2026 at 22:25 UTC fbde7e01709ee069ee017933af81e841bb944ab3
16 files changed +1868 -415
src/go/go.mod
+1 -1
@@ -78,6 +78,7 @@ require (
78 github.com/catonetworks/cato-go-sdk v0.2.6
79 github.com/cespare/xxhash/v2 v2.3.0
80 github.com/docker/go-units v0.5.0
81 + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc
82 github.com/ibm-messaging/mq-golang/v5 v5.7.1
83 github.com/maxmind/mmdbwriter v1.2.0
84 github.com/microsoft/go-mssqldb v1.10.0
@@ -126,7 +127,6 @@ require (
127 github.com/google/certificate-transparency-go v1.1.7 // indirect
128 github.com/google/gnostic-models v0.7.0 // indirect
129 github.com/google/go-cmp v0.7.0 // indirect
129 - github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
130 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
131 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
132 github.com/hashicorp/go-hclog v1.6.3 // indirect
src/go/pkg/prometheus/client.go
+21 -14
@@ -6,6 +6,7 @@ import (
6 "bufio"
7 "bytes"
8 "compress/gzip"
9 + "context"
10 "fmt"
11 "io"
12 "net/http"
@@ -23,17 +24,18 @@ type (
24 // ScrapeSeries and parse prometheus format metrics
25 ScrapeSeries() (Series, error)
26 Scrape() (MetricFamilies, error)
26 - // ScrapeStream scrapes and invokes onSample for every sample — a flat
27 - // [Sample] stream before typed-family assembly — with the selector (if
28 - // any) already applied; onHelp, if non-nil, receives per-family HELP. It
29 - // stops and returns the first error from a callback.
27 + // ScrapeWithTransform scrapes, runs transform on every sample (a flat
28 + // [Sample] stream before typed-family assembly, with the selector already
29 + // applied), then assembles the kept, transformed samples into MetricFamilies.
30 + // A nil transform behaves exactly like Scrape. The result aliases reused
31 + // buffers, valid until the next scrape on this instance (same as Scrape).
32 //
31 - // Order is exposition order, with one exception: a _sum/_count sample
32 - // whose family type is not yet known (its # TYPE, first bucket, or first
33 - // quantile has not appeared) is deferred and emitted once the type
34 - // resolves, or at end of stream — so it may arrive after a later,
35 - // unrelated sample. Each sample's Kind and FamilyType are always correct.
36 - ScrapeStream(onHelp func(name, help string), onSample func(Sample) error) error
33 + // Samples reach transform in exposition order, with one exception: a
34 + // _sum/_count sample whose family type is not yet known (its # TYPE, first
35 + // bucket, or first quantile has not appeared) is deferred and delivered once
36 + // the type resolves, or at end of scrape — so it may arrive after a later,
37 + // unrelated sample. A per-sample (stateless) transform is unaffected.
38 + ScrapeWithTransform(ctx context.Context, transform SampleTransform) (MetricFamilies, error)
39 HTTPClient() *http.Client
40 }
41
@@ -86,7 +88,7 @@ func (p *prometheus) HTTPClient() *http.Client {
88 func (p *prometheus) ScrapeSeries() (Series, error) {
89 p.buf.Reset()
90
89 - if err := p.fetch(p.buf); err != nil {
91 + if err := p.fetch(context.Background(), p.buf); err != nil {
92 return nil, err
93 }
94
@@ -94,16 +96,20 @@ func (p *prometheus) ScrapeSeries() (Series, error) {
96 }
97
98 func (p *prometheus) Scrape() (MetricFamilies, error) {
99 + return p.ScrapeWithTransform(context.Background(), nil)
100 +}
101 +
102 +func (p *prometheus) ScrapeWithTransform(ctx context.Context, transform SampleTransform) (MetricFamilies, error) {
103 p.buf.Reset()
104
99 - if err := p.fetch(p.buf); err != nil {
105 + if err := p.fetch(ctx, p.buf); err != nil {
106 return nil, err
107 }
108
103 - return p.parser.parseToMetricFamilies(p.buf.Bytes())
109 + return p.parser.parseToMetricFamilies(p.buf.Bytes(), transform)
110 }
111
106 -func (p *prometheus) fetch(w io.Writer) error {
112 +func (p *prometheus) fetch(ctx context.Context, w io.Writer) error {
113 // TODO: should be a separate text file prom client
114 if p.filepath != "" {
115 f, err := os.Open(p.filepath)
@@ -121,6 +127,7 @@ func (p *prometheus) fetch(w io.Writer) error {
127 if err != nil {
128 return err
129 }
130 + req = req.WithContext(ctx)
131
132 req.Header.Add("Accept", acceptHeader)
133 req.Header.Add("Accept-Encoding", "gzip")
src/go/pkg/prometheus/client_test.go
+39 -16
@@ -5,6 +5,7 @@ package prometheus
5 import (
6 "bytes"
7 "compress/gzip"
8 + "context"
9 "errors"
10 "net/http"
11 "net/http/httptest"
@@ -132,38 +133,60 @@ func TestPrometheusReadFromFile(t *testing.T) {
133 }
134 }
135
135 -func TestPrometheusScrapeStream(t *testing.T) {
136 +func TestPrometheusScrapeWithTransform(t *testing.T) {
137 errBoom := errors.New("boom")
138
139 tests := map[string]struct {
139 - onSampleErr error // returned by onSample (nil = stream everything)
140 - wantErr error
140 + transform func(seen *int) SampleTransform
141 + wantErr error
142 + check func(t *testing.T, mfs MetricFamilies, seen int)
143 }{
142 - "streams all samples and help": {},
143 - "onSample error propagates": {onSampleErr: errBoom, wantErr: errBoom},
144 + "nil transform assembles like Scrape": {
145 + transform: func(*int) SampleTransform { return nil },
146 + check: func(t *testing.T, mfs MetricFamilies, seen int) {
147 + assert.Positive(t, mfs.Len())
148 + assert.Zero(t, seen, "nil transform must not be invoked")
149 + },
150 + },
151 + "transform sees every sample and keeps them": {
152 + transform: func(seen *int) SampleTransform {
153 + return func(s Sample) (Sample, bool, error) { *seen++; return s, true, nil }
154 + },
155 + check: func(t *testing.T, mfs MetricFamilies, seen int) {
156 + assert.Positive(t, seen)
157 + assert.Positive(t, mfs.Len())
158 + },
159 + },
160 + "dropping every sample yields no families": {
161 + transform: func(seen *int) SampleTransform {
162 + return func(s Sample) (Sample, bool, error) { *seen++; return s, false, nil }
163 + },
164 + check: func(t *testing.T, mfs MetricFamilies, seen int) {
165 + assert.Positive(t, seen)
166 + assert.Zero(t, mfs.Len())
167 + },
168 + },
169 + "transform error aborts the scrape": {
170 + transform: func(*int) SampleTransform {
171 + return func(s Sample) (Sample, bool, error) { return s, false, errBoom }
172 + },
173 + wantErr: errBoom,
174 + },
175 }
176
177 for name, tc := range tests {
178 t.Run(name, func(t *testing.T) {
179 prom := New(http.DefaultClient, web.RequestConfig{URL: "file://testdata/testdata.txt"})
180
150 - var samples int
151 - var help []string
152 - err := prom.ScrapeStream(
153 - func(name, _ string) { help = append(help, name) },
154 - func(Sample) error {
155 - samples++
156 - return tc.onSampleErr
157 - },
158 - )
181 + var seen int
182 + mfs, err := prom.ScrapeWithTransform(context.Background(), tc.transform(&seen))
183
184 if tc.wantErr != nil {
185 assert.ErrorIs(t, err, tc.wantErr)
186 return
187 }
188 require.NoError(t, err)
165 - assert.Positive(t, samples)
166 - assert.Contains(t, help, "go_gc_duration_seconds")
189 + tc.check(t, mfs, seen)
190 })
191 }
192 }
src/go/pkg/prometheus/doc.go
+4 -2
@@ -10,8 +10,10 @@
10 // into their families.
11 // - [Prometheus.ScrapeSeries] returns the raw [Series]: one [SeriesSample] per
12 // scraped series, with labels in textparse-sorted order.
13 -// - [Prometheus.ScrapeStream] exposes a flat [Sample] stream before typed
14 -// assembly — the form a Prometheus metric-relabeling step operates on.
13 +// - [Prometheus.ScrapeWithTransform] runs a per-sample transform — the form a
14 +// Prometheus metric-relabeling step operates on — over the flat [Sample]
15 +// stream before assembling the kept samples into typed [MetricFamilies]; a
16 +// nil transform behaves like Scrape.
17 //
18 // Results are valid only until the next scrape on the same instance; buffers are
19 // reused across scrapes. An optional selector (see the selector subpackage)
src/go/pkg/prometheus/model.go
+11
@@ -42,3 +42,14 @@ type Sample struct {
42 Kind SampleKind
43 FamilyType model.MetricType
44 }
45 +
46 +// SampleTransform transforms or drops a single scraped Sample before typed-family
47 +// assembly. Return (sample, true, nil) to keep it (optionally mutated — rewrite Name
48 +// or mutate Labels in place), (_, false, nil) to drop it, or a non-nil error to abort
49 +// the scrape. Each Sample owns its Labels, so in-place mutation is safe and does not
50 +// affect other samples. It is the hook a Prometheus metric-relabeling step plugs into.
51 +//
52 +// Kind and FamilyType reflect the classification BEFORE the transform runs; rewriting
53 +// Name, le, or quantile does NOT reclassify the sample (matching Prometheus, where
54 +// relabeling cannot retype a series).
55 +type SampleTransform func(Sample) (Sample, bool, error)
src/go/pkg/prometheus/parse.go
+23 -16
@@ -39,13 +39,31 @@ type promTextParser struct {
39 series Series
40 }
41
42 -func (p *promTextParser) parseToMetricFamilies(text []byte) (MetricFamilies, error) {
42 +func (p *promTextParser) parseToMetricFamilies(text []byte, transform SampleTransform) (MetricFamilies, error) {
43 p.driver.sr = p.sr
44 p.asm.reset()
45
46 - // ownLabels=false: the assembler copies labels into its own buffers, so the
47 - // driver may lend the scratch label set (the no-allocation fast path).
48 - if err := p.driver.parseSamples(text, false, p.asm.applyHelp, p.asm.applySample); err != nil {
46 + // With no transform, ownLabels=false: the assembler copies labels into its own
47 + // buffers, so the driver may lend the scratch label set (the no-allocation fast
48 + // path). A transform may mutate or retain a sample's labels, so it needs the
49 + // sample to own them (ownLabels=true).
50 + onSample := p.asm.applySample
51 + ownLabels := false
52 + if transform != nil {
53 + ownLabels = true
54 + onSample = func(s Sample) error {
55 + s, keep, err := transform(s)
56 + if err != nil {
57 + return err
58 + }
59 + if !keep {
60 + return nil
61 + }
62 + return p.asm.applySample(s)
63 + }
64 + }
65 +
66 + if err := p.driver.parseSamples(text, ownLabels, p.asm.applyHelp, onSample); err != nil {
67 return nil, err
68 }
69
@@ -73,17 +91,6 @@ func (p *promTextParser) parseToSeries(text []byte) (Series, error) {
91 return p.series, nil
92 }
93
76 -func (p *promTextParser) parseToStream(text []byte, onHelp func(name, help string), onSample func(Sample) error) error {
77 - if onSample == nil {
78 - return nil
79 - }
80 - p.driver.sr = p.sr
81 -
82 - // ownLabels=true: each Sample must own its labels because the consumer may
83 - // retain or mutate them (e.g. relabeling) past the next sample.
84 - return p.driver.parseSamples(text, true, onHelp, onSample)
85 -}
86 -
94 // parseDriver runs the single exposition parse pass (iterate). On top of it,
95 // parseSamples emits a flat, classified sample stream, deferring a _sum/_count
96 // whose family type is not yet known and back-resolving it once the type appears
@@ -174,7 +181,7 @@ func (d *parseDriver) iterate(
181 // turns each series into a Sample (Kind + FamilyType) and defers a _sum/_count
182 // whose family type is not yet known, back-resolving it once the type appears (a
183 // later # TYPE, _bucket, or quantile series) or flushing it at EOF. Deferral can
177 -// emit a _sum/_count after a later, unrelated series — see ScrapeStream's doc.
184 +// emit a _sum/_count after a later, unrelated series — see ScrapeWithTransform's doc.
185 func (d *parseDriver) parseSamples(text []byte, ownLabels bool, onHelp func(name, help string), onSample func(Sample) error) error {
186 d.reset()
187
src/go/pkg/prometheus/parse_bench_test.go
+15 -13
@@ -28,20 +28,21 @@ import (
28 // parseToMetricFamilies 127868 ns/op 73404 B/op 1314 allocs/op
29 // parseToSeries 111382 ns/op 100444 B/op 1584 allocs/op
30 //
31 -// Unified driver+assembler stream parser (this rewrite — captured 2026-06-05):
31 +// Unified driver+assembler parser (this rewrite — captured 2026-06-05):
32 //
33 // parseToMetricFamilies 133911 ns/op 73408 B/op 1314 allocs/op
34 // parseToSeries 112803 ns/op 100448 B/op 1584 allocs/op
35 -// parseToStream 113248 ns/op 102807 B/op 1644 allocs/op
35 //
37 -// No allocation win and no allocation regression: the legacy parser already
38 -// parsed once per call and reused buffers, and both Scrape and ScrapeSeries match
39 -// it exactly (flat allocs). ScrapeSeries uses the raw-label path (identical to
40 -// legacy), so it is essentially free. Scrape/stream are a few percent slower on
41 -// CPU — the per-sample Sample struct + the shared iterate indirection + assembler
42 -// dispatch on the families/stream path (profiled: makeSample + applySample,
43 -// inherent to the single-driver model, not a fixable hotspot). The win is the
44 -// sample stream that metric relabeling consumes, not raw speed.
36 +// Scrape (parseToMetricFamilies) and ScrapeSeries keep the legacy allocation profile
37 +// (flat allocs); ScrapeSeries is the raw-label path (identical to legacy, essentially
38 +// free), Scrape a few percent slower on CPU (per-sample Sample + iterate indirection +
39 +// assembler dispatch — makeSample/applySample, inherent to the single-driver model).
40 +//
41 +// A transform runs with ownLabels=true, so each kept sample owns a copy of its labels
42 +// — the cost metric relabeling pays to mutate safely (captured 2026-06-08):
43 +//
44 +// parseToMetricFamilies 142135 ns/op 73412 B/op 1314 allocs/op
45 +// parseToMetricFamiliesWithTransform 147460 ns/op 93940 B/op 1709 allocs/op
46
47 func readBenchData(tb testing.TB) []byte {
48 tb.Helper()
@@ -64,7 +65,7 @@ func BenchmarkPromTextParser_parseToMetricFamilies(b *testing.B) {
65 b.SetBytes(int64(len(data)))
66 b.ResetTimer()
67 for range b.N {
67 - if _, err := p.parseToMetricFamilies(data); err != nil {
68 + if _, err := p.parseToMetricFamilies(data, nil); err != nil {
69 b.Fatal(err)
70 }
71 }
@@ -83,14 +84,15 @@ func BenchmarkPromTextParser_parseToSeries(b *testing.B) {
84 }
85 }
86
86 -func BenchmarkPromTextParser_parseToStream(b *testing.B) {
87 +func BenchmarkPromTextParser_parseToMetricFamiliesWithTransform(b *testing.B) {
88 data := readBenchData(b)
89 var p promTextParser
90 + keep := func(s Sample) (Sample, bool, error) { return s, true, nil }
91 b.ReportAllocs()
92 b.SetBytes(int64(len(data)))
93 b.ResetTimer()
94 for range b.N {
93 - if err := p.parseToStream(data, nil, func(Sample) error { return nil }); err != nil {
95 + if _, err := p.parseToMetricFamilies(data, keep); err != nil {
96 b.Fatal(err)
97 }
98 }
src/go/pkg/prometheus/parse_test.go
+296 -28
@@ -54,6 +54,105 @@ func TestPromTextParser_parseToMetricFamilies(t *testing.T) {
54 input []byte
55 want MetricFamilies
56 }{
57 + "summary _sum/_count before # TYPE fold into one summary": {
58 + input: []byte(`my_summary_sum{label1="value1"} 10
59 +my_summary_count{label1="value1"} 2
60 +# TYPE my_summary summary
61 +my_summary{label1="value1",quantile="0.5"} 0.5
62 +`),
63 + want: MetricFamilies{
64 + "my_summary": {
65 + name: "my_summary",
66 + typ: model.MetricTypeSummary,
67 + metrics: []Metric{
68 + {
69 + labels: labels.Labels{{Name: "label1", Value: "value1"}},
70 + summary: &Summary{
71 + sum: 10,
72 + count: 2,
73 + quantiles: []Quantile{{quantile: 0.5, value: 0.5}},
74 + },
75 + },
76 + },
77 + },
78 + },
79 + },
80 + "histogram _sum/_count before _bucket (no # TYPE) fold into one histogram": {
81 + input: []byte(`my_hist_sum{label1="value1"} 5
82 +my_hist_count{label1="value1"} 3
83 +my_hist_bucket{label1="value1",le="0.1"} 1
84 +my_hist_bucket{label1="value1",le="+Inf"} 3
85 +`),
86 + want: MetricFamilies{
87 + "my_hist": {
88 + name: "my_hist",
89 + typ: model.MetricTypeHistogram,
90 + metrics: []Metric{
91 + {
92 + labels: labels.Labels{{Name: "label1", Value: "value1"}},
93 + histogram: &Histogram{
94 + sum: 5,
95 + count: 3,
96 + buckets: []Bucket{
97 + {upperBound: 0.1, cumulativeCount: 1},
98 + {upperBound: math.Inf(1), cumulativeCount: 3},
99 + },
100 + },
101 + },
102 + },
103 + },
104 + },
105 + },
106 + "metric name found by lookup, not first label": {
107 + input: []byte(`
108 +# HELP DCGM_FI_DEV_GPU_UTIL GPU utilization
109 +# TYPE DCGM_FI_DEV_GPU_UTIL gauge
110 +DCGM_FI_DEV_GPU_UTIL{UUID="GPU-aaa",gpu="0"} 80
111 +`),
112 + want: MetricFamilies{
113 + "DCGM_FI_DEV_GPU_UTIL": {
114 + name: "DCGM_FI_DEV_GPU_UTIL",
115 + help: "GPU utilization",
116 + typ: model.MetricTypeGauge,
117 + metrics: []Metric{
118 + {
119 + labels: labels.Labels{{Name: "UUID", Value: "GPU-aaa"}, {Name: "gpu", Value: "0"}},
120 + gauge: &Gauge{value: 80},
121 + },
122 + },
123 + },
124 + },
125 + },
126 + "valid _bucket (has le) folds into the histogram family": {
127 + input: []byte("# TYPE h histogram\nh_bucket{le=\"1\",label=\"x\"} 1\n"),
128 + want: MetricFamilies{
129 + "h": {
130 + name: "h",
131 + typ: model.MetricTypeHistogram,
132 + metrics: []Metric{
133 + {
134 + labels: labels.Labels{{Name: "label", Value: "x"}},
135 + histogram: &Histogram{buckets: []Bucket{{upperBound: 1, cumulativeCount: 1}}},
136 + },
137 + },
138 + },
139 + },
140 + },
141 + "_bucket without le is a plain metric, not a histogram bucket": {
142 + input: []byte("# TYPE h histogram\nh_bucket{label=\"x\"} 1\n"),
143 + want: MetricFamilies{
144 + "h_bucket": {
145 + name: "h_bucket",
146 + typ: model.MetricTypeUnknown,
147 + metrics: []Metric{
148 + {
149 + labels: labels.Labels{{Name: "label", Value: "x"}},
150 + untyped: &Untyped{value: 1},
151 + },
152 + },
153 + },
154 + },
155 + },
156 "Gauge with multiline HELP": {
157 input: dataMultilineHelp,
158 want: MetricFamilies{
@@ -1347,7 +1446,7 @@ func TestPromTextParser_parseToMetricFamilies(t *testing.T) {
1446
1447 for i := range 10 {
1448 t.Run(fmt.Sprintf("parse num %d", i+1), func(t *testing.T) {
1350 - mfs, err := p.parseToMetricFamilies(test.input)
1449 + mfs, err := p.parseToMetricFamilies(test.input, nil)
1450 if len(test.want) > 0 {
1451 assert.Equal(t, test.want, mfs)
1452 } else {
@@ -1382,7 +1481,7 @@ test_gauge_metric_2{label1="value2"} 1
1481 },
1482 }
1483
1385 - mfs, err := p.parseToMetricFamilies(txt)
1484 + mfs, err := p.parseToMetricFamilies(txt, nil)
1485
1486 require.NoError(t, err)
1487 assert.Equal(t, want, mfs)
@@ -1393,6 +1492,17 @@ func TestPromTextParser_parseToSeries(t *testing.T) {
1492 input []byte
1493 want Series
1494 }{
1495 + "label order matches textparse (__name__ not forced first)": {
1496 + input: []byte("m{UUID=\"x\",gpu=\"0\"} 5\n"),
1497 + want: Series{SeriesSample{
1498 + Labels: labels.Labels{
1499 + {Name: "UUID", Value: "x"},
1500 + {Name: "__name__", Value: "m"},
1501 + {Name: "gpu", Value: "0"},
1502 + },
1503 + Value: 5,
1504 + }},
1505 + },
1506 "All types": {
1507 input: []byte(`
1508 # HELP test_gauge_metric_1 Test Gauge Metric 1
@@ -1665,31 +1775,6 @@ test_gauge_metric_2{label1="value2"} 1
1775 assert.Equal(t, want, series)
1776 }
1777
1668 -func TestPromTextParser_parseToMetricFamilies_metricNameNotFirstLabel(t *testing.T) {
1669 - var p promTextParser
1670 -
1671 - txt := []byte(`
1672 -# HELP DCGM_FI_DEV_GPU_UTIL GPU utilization
1673 -# TYPE DCGM_FI_DEV_GPU_UTIL gauge
1674 -DCGM_FI_DEV_GPU_UTIL{UUID="GPU-aaa",gpu="0"} 80
1675 -`)
1676 -
1677 - mfs, err := p.parseToMetricFamilies(txt)
1678 - require.NoError(t, err)
1679 -
1680 - require.Contains(t, mfs, "DCGM_FI_DEV_GPU_UTIL")
1681 - require.NotContains(t, mfs, "GPU-aaa")
1682 -
1683 - mf := mfs["DCGM_FI_DEV_GPU_UTIL"]
1684 - require.Len(t, mf.metrics, 1)
1685 - assert.Equal(t, model.MetricTypeGauge, mf.typ)
1686 - assert.Equal(t, 80.0, mf.metrics[0].gauge.value)
1687 - assert.EqualValues(t, labels.Labels{
1688 - {Name: "UUID", Value: "GPU-aaa"},
1689 - {Name: "gpu", Value: "0"},
1690 - }, mf.metrics[0].labels)
1691 -}
1692 -
1778 func TestPromTextParser_parseToMetricFamilies_failsOnInvalidSeriesValue(t *testing.T) {
1779 var p promTextParser
1780
@@ -1702,7 +1787,7 @@ DCGM_FI_DEV_GPU_UTIL{UUID="GPU-aaa",gpu="0"} 80
1787 DCGM_FI_DEV_REQUESTED_POWER_PROFILE_MASK{UUID="GPU-aaa",gpu="0"} ERROR - FAILED TO CONVERT TO STRING
1788 `)
1789
1705 - _, err := p.parseToMetricFamilies(txt)
1790 + _, err := p.parseToMetricFamilies(txt, nil)
1791 require.Error(t, err)
1792 assert.Contains(t, err.Error(), "failed to parse prometheus metrics")
1793 }
@@ -1728,3 +1813,186 @@ func joinData(data ...[]byte) []byte {
1813 }
1814 return buf.Bytes()
1815 }
1816 +
1817 +func TestPromTextParser_parseSamples(t *testing.T) {
1818 + type wantSample struct {
1819 + name string
1820 + labels string
1821 + value float64
1822 + kind SampleKind
1823 + familyType model.MetricType
1824 + }
1825 +
1826 + tests := map[string]struct {
1827 + input []byte
1828 + wantHelp []string
1829 + want []wantSample
1830 + }{
1831 + "deferred _sum emits after a later unrelated metric (cross-metric reorder)": {
1832 + input: []byte("a_sum 1\nb 2\n# TYPE a summary\na{quantile=\"0.5\"} 3\n"),
1833 + want: []wantSample{
1834 + {"b", `{}`, 2, SampleKindScalar, model.MetricTypeUnknown},
1835 + {"a_sum", `{}`, 1, SampleKindSummarySum, model.MetricTypeSummary},
1836 + {"a", `{quantile="0.5"}`, 3, SampleKindSummaryQuantile, model.MetricTypeSummary},
1837 + },
1838 + },
1839 + "all metric types are classified in exposition order": {
1840 + input: []byte(`# HELP test_gauge A gauge metric.
1841 +# TYPE test_gauge gauge
1842 +test_gauge{label="a"} 1
1843 +# TYPE test_counter_total counter
1844 +test_counter_total 5
1845 +# TYPE test_hist histogram
1846 +test_hist_bucket{le="0.1"} 1
1847 +test_hist_bucket{le="+Inf"} 2
1848 +test_hist_sum 3
1849 +test_hist_count 2
1850 +# TYPE test_summary summary
1851 +test_summary{quantile="0.5"} 0.2
1852 +test_summary_sum 1
1853 +test_summary_count 10
1854 +`),
1855 + wantHelp: []string{"test_gauge=A gauge metric."},
1856 + want: []wantSample{
1857 + {"test_gauge", `{label="a"}`, 1, SampleKindScalar, model.MetricTypeGauge},
1858 + {"test_counter_total", `{}`, 5, SampleKindScalar, model.MetricTypeCounter},
1859 + {"test_hist_bucket", `{le="0.1"}`, 1, SampleKindHistogramBucket, model.MetricTypeHistogram},
1860 + {"test_hist_bucket", `{le="+Inf"}`, 2, SampleKindHistogramBucket, model.MetricTypeHistogram},
1861 + {"test_hist_sum", `{}`, 3, SampleKindHistogramSum, model.MetricTypeHistogram},
1862 + {"test_hist_count", `{}`, 2, SampleKindHistogramCount, model.MetricTypeHistogram},
1863 + {"test_summary", `{quantile="0.5"}`, 0.2, SampleKindSummaryQuantile, model.MetricTypeSummary},
1864 + {"test_summary_sum", `{}`, 1, SampleKindSummarySum, model.MetricTypeSummary},
1865 + {"test_summary_count", `{}`, 10, SampleKindSummaryCount, model.MetricTypeSummary},
1866 + },
1867 + },
1868 + "__name__ is delivered via Name and excluded from Labels": {
1869 + input: []byte("# TYPE m gauge\nm{a=\"1\",b=\"2\"} 7\n"),
1870 + want: []wantSample{
1871 + {"m", `{a="1", b="2"}`, 7, SampleKindScalar, model.MetricTypeGauge},
1872 + },
1873 + },
1874 + "deferred sum/count before # TYPE are buffered and back-resolved": {
1875 + input: []byte(`my_summary_sum 10
1876 +my_summary_count 2
1877 +# TYPE my_summary summary
1878 +my_summary{quantile="0.5"} 0.5
1879 +`),
1880 + want: []wantSample{
1881 + {"my_summary_sum", `{}`, 10, SampleKindSummarySum, model.MetricTypeSummary},
1882 + {"my_summary_count", `{}`, 2, SampleKindSummaryCount, model.MetricTypeSummary},
1883 + {"my_summary", `{quantile="0.5"}`, 0.5, SampleKindSummaryQuantile, model.MetricTypeSummary},
1884 + },
1885 + },
1886 + }
1887 +
1888 + for name, test := range tests {
1889 + t.Run(name, func(t *testing.T) {
1890 + var p promTextParser
1891 +
1892 + for i := range 10 {
1893 + t.Run(fmt.Sprintf("parse num %d", i+1), func(t *testing.T) {
1894 + var got []wantSample
1895 + var help []string
1896 +
1897 + err := p.driver.parseSamples(test.input, true,
1898 + func(name, h string) { help = append(help, name+"="+h) },
1899 + func(s Sample) error {
1900 + assert.Falsef(t, s.Labels.Has(labels.MetricName),
1901 + "sample %q must not carry __name__ in Labels", s.Name)
1902 + got = append(got, wantSample{s.Name, s.Labels.String(), s.Value, s.Kind, s.FamilyType})
1903 + return nil
1904 + },
1905 + )
1906 + require.NoError(t, err)
1907 + assert.Equal(t, test.want, got)
1908 + for _, h := range test.wantHelp {
1909 + assert.Contains(t, help, h)
1910 + }
1911 + })
1912 + }
1913 + })
1914 + }
1915 +}
1916 +
1917 +// The stream supports Prometheus-style relabeling on __name__/le/quantile.
1918 +// ownLabels=true isolates each sample's labels, so a transform can rename via
1919 +// Name and mutate Labels in place without affecting later samples.
1920 +func TestPromTextParser_parseSamples_relabelStyle(t *testing.T) {
1921 + data := []byte(`# TYPE req_seconds histogram
1922 +req_seconds_bucket{le="0.1",path="/a"} 1
1923 +req_seconds_bucket{le="+Inf",path="/a"} 3
1924 +# TYPE rpc summary
1925 +rpc{quantile="0.99",path="/a"} 0.5
1926 +`)
1927 +
1928 + type out struct {
1929 + name string
1930 + le string
1931 + quantile string
1932 + labels string
1933 + }
1934 +
1935 + var p promTextParser
1936 + var got []out
1937 + err := p.driver.parseSamples(data, true, nil, func(s Sample) error {
1938 + o := out{
1939 + name: s.Name + ":relabeled", // __name__ is mutable via Name
1940 + le: s.Labels.Get(bucketLabel),
1941 + quantile: s.Labels.Get(quantileLabel),
1942 + }
1943 + // Drop the "path" target label in place (this sample owns its labels).
1944 + kept := s.Labels[:0]
1945 + for _, l := range s.Labels {
1946 + if l.Name == "path" {
1947 + continue
1948 + }
1949 + kept = append(kept, l)
1950 + }
1951 + o.labels = labels.Labels(kept).String()
1952 + got = append(got, o)
1953 + return nil
1954 + })
1955 + require.NoError(t, err)
1956 +
1957 + assert.Equal(t, []out{
1958 + {name: "req_seconds_bucket:relabeled", le: "0.1", quantile: "", labels: `{le="0.1"}`},
1959 + {name: "req_seconds_bucket:relabeled", le: "+Inf", quantile: "", labels: `{le="+Inf"}`},
1960 + {name: "rpc:relabeled", le: "", quantile: "0.99", labels: `{quantile="0.99"}`},
1961 + }, got)
1962 +}
1963 +
1964 +// A transform's mutations — a renamed Name and changed Labels — are what the
1965 +// assembler folds: parseToMetricFamilies must assemble the TRANSFORMED sample, not
1966 +// the original. Mutating Labels in place also exercises ownLabels=true.
1967 +func TestPromTextParser_parseToMetricFamilies_transformMutatesAssembledOutput(t *testing.T) {
1968 + input := []byte("# TYPE old_name gauge\nold_name{keep=\"yes\",drop=\"me\"} 42\n")
1969 +
1970 + // Rename the metric and drop the "drop" label in place (the sample owns its labels).
1971 + transform := func(s Sample) (Sample, bool, error) {
1972 + s.Name = "new_name"
1973 + kept := s.Labels[:0]
1974 + for _, l := range s.Labels {
1975 + if l.Name == "drop" {
1976 + continue
1977 + }
1978 + kept = append(kept, l)
1979 + }
1980 + s.Labels = kept
1981 + return s, true, nil
1982 + }
1983 +
1984 + want := MetricFamilies{
1985 + "new_name": {
1986 + name: "new_name",
1987 + typ: model.MetricTypeGauge,
1988 + metrics: []Metric{
1989 + {labels: labels.Labels{{Name: "keep", Value: "yes"}}, gauge: &Gauge{value: 42}},
1990 + },
1991 + },
1992 + }
1993 +
1994 + var p promTextParser
1995 + mfs, err := p.parseToMetricFamilies(input, transform)
1996 + require.NoError(t, err)
1997 + assert.Equal(t, want, mfs)
1998 +}
src/go/pkg/prometheus/stream.go deleted
-15
@@ -1,15 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package prometheus
4 -
5 -// ScrapeStream implements [Prometheus]. See the interface for the ordering and
6 -// callback contract. onHelp may be nil when per-family HELP is not needed.
7 -func (p *prometheus) ScrapeStream(onHelp func(name, help string), onSample func(Sample) error) error {
8 - p.buf.Reset()
9 -
10 - if err := p.fetch(p.buf); err != nil {
11 - return err
12 - }
13 -
14 - return p.parser.parseToStream(p.buf.Bytes(), onHelp, onSample)
15 -}
src/go/pkg/prometheus/stream_test.go deleted
-296
@@ -1,296 +0,0 @@
1 -// SPDX-License-Identifier: GPL-3.0-or-later
2 -
3 -package prometheus
4 -
5 -import (
6 - "fmt"
7 - "math"
8 - "testing"
9 -
10 - "github.com/prometheus/common/model"
11 - "github.com/prometheus/prometheus/model/labels"
12 - "github.com/stretchr/testify/assert"
13 - "github.com/stretchr/testify/require"
14 -)
15 -
16 -func TestPromTextParser_parseToStream(t *testing.T) {
17 - type wantSample struct {
18 - name string
19 - labels string
20 - value float64
21 - kind SampleKind
22 - familyType model.MetricType
23 - }
24 -
25 - tests := map[string]struct {
26 - input []byte
27 - wantHelp []string
28 - want []wantSample
29 - }{
30 - "all metric types are classified in exposition order": {
31 - input: []byte(`# HELP test_gauge A gauge metric.
32 -# TYPE test_gauge gauge
33 -test_gauge{label="a"} 1
34 -# TYPE test_counter_total counter
35 -test_counter_total 5
36 -# TYPE test_hist histogram
37 -test_hist_bucket{le="0.1"} 1
38 -test_hist_bucket{le="+Inf"} 2
39 -test_hist_sum 3
40 -test_hist_count 2
41 -# TYPE test_summary summary
42 -test_summary{quantile="0.5"} 0.2
43 -test_summary_sum 1
44 -test_summary_count 10
45 -`),
46 - wantHelp: []string{"test_gauge=A gauge metric."},
47 - want: []wantSample{
48 - {"test_gauge", `{label="a"}`, 1, SampleKindScalar, model.MetricTypeGauge},
49 - {"test_counter_total", `{}`, 5, SampleKindScalar, model.MetricTypeCounter},
50 - {"test_hist_bucket", `{le="0.1"}`, 1, SampleKindHistogramBucket, model.MetricTypeHistogram},
51 - {"test_hist_bucket", `{le="+Inf"}`, 2, SampleKindHistogramBucket, model.MetricTypeHistogram},
52 - {"test_hist_sum", `{}`, 3, SampleKindHistogramSum, model.MetricTypeHistogram},
53 - {"test_hist_count", `{}`, 2, SampleKindHistogramCount, model.MetricTypeHistogram},
54 - {"test_summary", `{quantile="0.5"}`, 0.2, SampleKindSummaryQuantile, model.MetricTypeSummary},
55 - {"test_summary_sum", `{}`, 1, SampleKindSummarySum, model.MetricTypeSummary},
56 - {"test_summary_count", `{}`, 10, SampleKindSummaryCount, model.MetricTypeSummary},
57 - },
58 - },
59 - "__name__ is delivered via Name and excluded from Labels": {
60 - input: []byte("# TYPE m gauge\nm{a=\"1\",b=\"2\"} 7\n"),
61 - want: []wantSample{
62 - {"m", `{a="1", b="2"}`, 7, SampleKindScalar, model.MetricTypeGauge},
63 - },
64 - },
65 - "deferred sum/count before # TYPE are buffered and back-resolved": {
66 - input: []byte(`my_summary_sum 10
67 -my_summary_count 2
68 -# TYPE my_summary summary
69 -my_summary{quantile="0.5"} 0.5
70 -`),
71 - want: []wantSample{
72 - {"my_summary_sum", `{}`, 10, SampleKindSummarySum, model.MetricTypeSummary},
73 - {"my_summary_count", `{}`, 2, SampleKindSummaryCount, model.MetricTypeSummary},
74 - {"my_summary", `{quantile="0.5"}`, 0.5, SampleKindSummaryQuantile, model.MetricTypeSummary},
75 - },
76 - },
77 - }
78 -
79 - for name, test := range tests {
80 - t.Run(name, func(t *testing.T) {
81 - var p promTextParser
82 -
83 - for i := range 10 {
84 - t.Run(fmt.Sprintf("parse num %d", i+1), func(t *testing.T) {
85 - var got []wantSample
86 - var help []string
87 -
88 - err := p.parseToStream(test.input,
89 - func(name, h string) { help = append(help, name+"="+h) },
90 - func(s Sample) error {
91 - assert.Falsef(t, s.Labels.Has(labels.MetricName),
92 - "sample %q must not carry __name__ in Labels", s.Name)
93 - got = append(got, wantSample{s.Name, s.Labels.String(), s.Value, s.Kind, s.FamilyType})
94 - return nil
95 - },
96 - )
97 - require.NoError(t, err)
98 - assert.Equal(t, test.want, got)
99 - for _, h := range test.wantHelp {
100 - assert.Contains(t, help, h)
101 - }
102 - })
103 - }
104 - })
105 - }
106 -}
107 -
108 -// Deferred _sum/_count (emitted before the family type is known) fold into the
109 -// typed family once it is resolved — criterion #5 at the assembled level.
110 -func TestPromTextParser_parseToMetricFamilies_deferredClassification(t *testing.T) {
111 - tests := map[string]struct {
112 - input []byte
113 - want MetricFamilies
114 - }{
115 - "summary _sum/_count before # TYPE fold into one summary": {
116 - input: []byte(`my_summary_sum{label1="value1"} 10
117 -my_summary_count{label1="value1"} 2
118 -# TYPE my_summary summary
119 -my_summary{label1="value1",quantile="0.5"} 0.5
120 -`),
121 - want: MetricFamilies{
122 - "my_summary": {
123 - name: "my_summary",
124 - typ: model.MetricTypeSummary,
125 - metrics: []Metric{
126 - {
127 - labels: labels.Labels{{Name: "label1", Value: "value1"}},
128 - summary: &Summary{
129 - sum: 10,
130 - count: 2,
131 - quantiles: []Quantile{{quantile: 0.5, value: 0.5}},
132 - },
133 - },
134 - },
135 - },
136 - },
137 - },
138 - "histogram _sum/_count before _bucket (no # TYPE) fold into one histogram": {
139 - input: []byte(`my_hist_sum{label1="value1"} 5
140 -my_hist_count{label1="value1"} 3
141 -my_hist_bucket{label1="value1",le="0.1"} 1
142 -my_hist_bucket{label1="value1",le="+Inf"} 3
143 -`),
144 - want: MetricFamilies{
145 - "my_hist": {
146 - name: "my_hist",
147 - typ: model.MetricTypeHistogram,
148 - metrics: []Metric{
149 - {
150 - labels: labels.Labels{{Name: "label1", Value: "value1"}},
151 - histogram: &Histogram{
152 - sum: 5,
153 - count: 3,
154 - buckets: []Bucket{
155 - {upperBound: 0.1, cumulativeCount: 1},
156 - {upperBound: math.Inf(1), cumulativeCount: 3},
157 - },
158 - },
159 - },
160 - },
161 - },
162 - },
163 - },
164 - }
165 -
166 - for name, test := range tests {
167 - t.Run(name, func(t *testing.T) {
168 - var p promTextParser
169 -
170 - for i := range 10 {
171 - t.Run(fmt.Sprintf("parse num %d", i+1), func(t *testing.T) {
172 - mfs, err := p.parseToMetricFamilies(test.input)
173 - require.NoError(t, err)
174 - assert.Equal(t, test.want, mfs)
175 - })
176 - }
177 - })
178 - }
179 -}
180 -
181 -// The stream supports Prometheus-style relabeling on __name__/le/quantile.
182 -// ownLabels=true isolates each sample's labels, so a transform can rename via
183 -// Name and mutate Labels in place without affecting later samples.
184 -func TestPromTextParser_parseToStream_relabelStyle(t *testing.T) {
185 - data := []byte(`# TYPE req_seconds histogram
186 -req_seconds_bucket{le="0.1",path="/a"} 1
187 -req_seconds_bucket{le="+Inf",path="/a"} 3
188 -# TYPE rpc summary
189 -rpc{quantile="0.99",path="/a"} 0.5
190 -`)
191 -
192 - type out struct {
193 - name string
194 - le string
195 - quantile string
196 - labels string
197 - }
198 -
199 - var p promTextParser
200 - var got []out
201 - err := p.parseToStream(data, nil, func(s Sample) error {
202 - o := out{
203 - name: s.Name + ":relabeled", // __name__ is mutable via Name
204 - le: s.Labels.Get(bucketLabel),
205 - quantile: s.Labels.Get(quantileLabel),
206 - }
207 - // Drop the "path" target label in place (this sample owns its labels).
208 - kept := s.Labels[:0]
209 - for _, l := range s.Labels {
210 - if l.Name == "path" {
211 - continue
212 - }
213 - kept = append(kept, l)
214 - }
215 - o.labels = labels.Labels(kept).String()
216 - got = append(got, o)
217 - return nil
218 - })
219 - require.NoError(t, err)
220 -
221 - assert.Equal(t, []out{
222 - {name: "req_seconds_bucket:relabeled", le: "0.1", quantile: "", labels: `{le="0.1"}`},
223 - {name: "req_seconds_bucket:relabeled", le: "+Inf", quantile: "", labels: `{le="+Inf"}`},
224 - {name: "rpc:relabeled", le: "", quantile: "0.99", labels: `{quantile="0.99"}`},
225 - }, got)
226 -}
227 -
228 -func TestPromTextParser_parseToStream_nilCallbackIsNoop(t *testing.T) {
229 - var p promTextParser
230 - require.NoError(t, p.parseToStream([]byte("metric 1\n"), nil, nil))
231 -}
232 -
233 -// Byte-identical series ordering: textparse sorts labels, so __name__ is NOT
234 -// always first — a label like "UUID" (0x55) sorts before "__name__" (0x5f).
235 -// ScrapeSeries must preserve that raw sorted order (same as the legacy parser),
236 -// not force __name__ first, which would also break the labels.Labels sorted
237 -// invariant.
238 -func TestPromTextParser_parseToSeries_labelOrderMatchesTextparse(t *testing.T) {
239 - var p promTextParser
240 - series, err := p.parseToSeries([]byte("m{UUID=\"x\",gpu=\"0\"} 5\n"))
241 - require.NoError(t, err)
242 - require.Len(t, series, 1)
243 - assert.Equal(t, `{UUID="x", __name__="m", gpu="0"}`, series[0].Labels.String())
244 -}
245 -
246 -// The deferred _sum/_count buffer (criterion #5) can emit a _sum after a later,
247 -// unrelated sample: here a_sum is buffered (type unknown), b is emitted, then
248 -// a_sum resolves on "# TYPE a summary". Documents the ScrapeStream order caveat.
249 -func TestPromTextParser_parseToStream_deferralReordersAcrossMetrics(t *testing.T) {
250 - data := []byte("a_sum 1\nb 2\n# TYPE a summary\na{quantile=\"0.5\"} 3\n")
251 -
252 - var p promTextParser
253 - var names []string
254 - err := p.parseToStream(data, nil, func(s Sample) error {
255 - names = append(names, s.Name)
256 - return nil
257 - })
258 - require.NoError(t, err)
259 - assert.Equal(t, []string{"b", "a_sum", "a"}, names)
260 -}
261 -
262 -// A _bucket-named series is a histogram bucket only if it carries an "le" label.
263 -// Without le it is malformed and is kept as a plain metric (value preserved) — not
264 -// folded into the histogram family. (Legacy folded it and dropped the value;
265 -// valid buckets always have le, so real histograms are unaffected.)
266 -func TestPromTextParser_parseToMetricFamilies_bucketRequiresLe(t *testing.T) {
267 - tests := map[string]struct {
268 - input []byte
269 - wantFam string
270 - wantType model.MetricType
271 - }{
272 - "valid _bucket (has le) folds into the histogram family": {
273 - input: []byte("# TYPE h histogram\nh_bucket{le=\"1\",label=\"x\"} 1\n"),
274 - wantFam: "h",
275 - wantType: model.MetricTypeHistogram,
276 - },
277 - "_bucket without le is a plain metric, not a histogram bucket": {
278 - input: []byte("# TYPE h histogram\nh_bucket{label=\"x\"} 1\n"),
279 - wantFam: "h_bucket",
280 - wantType: model.MetricTypeUnknown,
281 - },
282 - }
283 -
284 - for name, tc := range tests {
285 - t.Run(name, func(t *testing.T) {
286 - var p promTextParser
287 - mfs, err := p.parseToMetricFamilies(tc.input)
288 - require.NoError(t, err)
289 -
290 - mf := mfs.Get(tc.wantFam)
291 - require.NotNilf(t, mf, "expected family %q", tc.wantFam)
292 - assert.Equal(t, tc.wantType, mf.Type())
293 - assert.Len(t, mf.Metrics(), 1)
294 - })
295 - }
296 -}
src/go/plugin/go.d/collector/prometheus/collect.go
+17 -6
@@ -3,17 +3,19 @@
3 package prometheus
4
5 import (
6 + "context"
7 "fmt"
8 "strings"
9
10 "github.com/netdata/netdata/go/plugins/pkg/prometheus"
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/prometheus/relabel"
12 )
13
14 // collect scrapes the endpoint and writes the metric families to the metrix store. The
15 // store cycle (begin/commit) is driven by the framework around Collect, so this only
16 // writes observations and returns an error to abort the cycle.
15 -func (c *Collector) collect() error {
16 - mfs, err := c.scrape()
17 +func (c *Collector) collect(ctx context.Context) error {
18 + mfs, err := c.scrape(ctx)
19 if err != nil {
20 return err
21 }
@@ -24,8 +26,8 @@ func (c *Collector) collect() error {
26 // check probes the endpoint and enforces the startup gates the V1 collector applied once:
27 // the expected-prefix guard and the total time-series limit. Unlike V1 these are read-only
28 // (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 +func (c *Collector) check(ctx context.Context) error {
30 + mfs, err := c.scrape(ctx)
31 if err != nil {
32 return err
33 }
@@ -45,8 +47,8 @@ func (c *Collector) check() error {
47
48 // scrape fetches the endpoint and enforces the empty-scrape contract: an empty scrape is
49 // 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 +func (c *Collector) scrape(ctx context.Context) (prometheus.MetricFamilies, error) {
51 + mfs, err := c.prom.ScrapeWithTransform(ctx, c.relabelTransform)
52 if err != nil {
53 return nil, err
54 }
@@ -72,3 +74,12 @@ func calcMetrics(mfs prometheus.MetricFamilies) int {
74 }
75 return n
76 }
77 +
78 +// onRelabelDrop logs why a relabel rule dropped a sample, at debug level. It logs
79 +// the metric name and the rule outcome, never label values (cardinality/PII).
80 +func (c *Collector) onRelabelDrop(s prometheus.Sample, d relabel.DropInfo) {
81 + c.When(d.RuleIndex >= 0).
82 + Debugf("relabel dropped metric %q: %s (rule %d, action %q)", s.Name, d.Reason, d.RuleIndex, d.Action).
83 + Else().
84 + Debugf("relabel dropped metric %q: %s", s.Name, d.Reason)
85 +}
src/go/plugin/go.d/collector/prometheus/collector.go
+20 -8
@@ -14,6 +14,7 @@ import (
14 "github.com/netdata/netdata/go/plugins/pkg/prometheus/selector"
15 "github.com/netdata/netdata/go/plugins/pkg/web"
16 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/prometheus/relabel"
18 )
19
20 //go:embed "config_schema.json"
@@ -67,10 +68,12 @@ type Collector struct {
68 collectorapi.Base
69 Config `yaml:",inline" json:""`
70
70 - prom prometheus.Prometheus
71 - store metrix.CollectorStore
72 - writer *metricFamilyWriter
73 - chartTemplate string
71 + prom prometheus.Prometheus
72 + relabelConfigs []relabel.Config
73 + relabelTransform prometheus.SampleTransform
74 + store metrix.CollectorStore
75 + writer *metricFamilyWriter
76 + chartTemplate string
77 }
78
79 func (c *Collector) Configuration() any {
@@ -88,6 +91,15 @@ func (c *Collector) Init(context.Context) error {
91 }
92 c.prom = prom
93
94 + // relabelConfigs are empty in this PR (rules are set by tests; profiles populate
95 + // them later), so NewTransform returns a nil transform and Scrape keeps its
96 + // no-transform fast path. A non-empty, invalid rule set fails Init here.
97 + transform, err := relabel.NewTransform(c.relabelConfigs, c.onRelabelDrop)
98 + if err != nil {
99 + return fmt.Errorf("init relabel: %v", err)
100 + }
101 + c.relabelTransform = transform
102 +
103 gaugeFallback, err := c.initFallbackTypeMatcher(c.FallbackType.Gauge)
104 if err != nil {
105 return fmt.Errorf("init gauge fallback type matcher: %v", err)
@@ -113,12 +125,12 @@ func (c *Collector) Init(context.Context) error {
125 return nil
126 }
127
116 -func (c *Collector) Check(context.Context) error {
117 - return c.check()
128 +func (c *Collector) Check(ctx context.Context) error {
129 + return c.check(ctx)
130 }
131
120 -func (c *Collector) Collect(context.Context) error {
121 - return c.collect()
132 +func (c *Collector) Collect(ctx context.Context) error {
133 + return c.collect(ctx)
134 }
135
136 func (c *Collector) Cleanup(context.Context) {
src/go/plugin/go.d/collector/prometheus/collector_test.go
+59
@@ -15,6 +15,7 @@ import (
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/go.d/collector/prometheus/relabel"
19 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
20 )
21
@@ -350,6 +351,64 @@ test_gauge_metric{label1="value2"} 12
351 assert.False(t, ok, "a family over the per-metric series limit must be skipped entirely")
352 },
353 },
354 + "relabel applies before assembly (drop + rename via __name__)": {
355 + prepare: func() *Collector {
356 + c := New()
357 + c.relabelConfigs = []relabel.Config{
358 + {
359 + SourceLabels: []string{"__name__"},
360 + Regex: relabel.MustNewRegexp("test_drop_me"),
361 + Action: relabel.Drop,
362 + },
363 + {
364 + SourceLabels: []string{"__name__"},
365 + Regex: relabel.MustNewRegexp("test_(.+)"),
366 + TargetLabel: "__name__",
367 + Replacement: "renamed_${1}",
368 + Action: relabel.Replace,
369 + },
370 + }
371 + return c
372 + },
373 + input: `
374 +# TYPE test_keep gauge
375 +test_keep{label1="value1"} 11
376 +# TYPE test_drop_me gauge
377 +test_drop_me{label1="value1"} 22
378 +`,
379 + want: func(t *testing.T, fr metrix.Reader) {
380 + assert.InDelta(t, 11, value(t, fr, "renamed_keep", metrix.Labels{"label1": "value1"}), 1e-9)
381 + _, ok := fr.Value("test_keep", metrix.Labels{"label1": "value1"})
382 + assert.False(t, ok, "the renamed metric must not appear under its original name")
383 + _, ok = fr.Value("test_drop_me", metrix.Labels{"label1": "value1"})
384 + assert.False(t, ok, "the drop rule must drop test_drop_me before assembly")
385 + _, ok = fr.Value("renamed_drop_me", metrix.Labels{"label1": "value1"})
386 + assert.False(t, ok, "a dropped sample must not be renamed or assembled")
387 + },
388 + },
389 + "relabel rewrites a regular label (copy via Replace)": {
390 + prepare: func() *Collector {
391 + c := New()
392 + c.relabelConfigs = []relabel.Config{
393 + {
394 + SourceLabels: []string{"method"},
395 + Regex: relabel.MustNewRegexp("(.+)"),
396 + TargetLabel: "verb",
397 + Replacement: "${1}",
398 + Action: relabel.Replace,
399 + },
400 + }
401 + return c
402 + },
403 + input: `
404 +# TYPE test_requests_total counter
405 +test_requests_total{method="get"} 5
406 +`,
407 + want: func(t *testing.T, fr metrix.Reader) {
408 + // Replace copies method -> verb; the series carries both labels.
409 + assert.InDelta(t, 5, value(t, fr, "test_requests_total", metrix.Labels{"method": "get", "verb": "get"}), 1e-9)
410 + },
411 + },
412 }
413
414 for name, tc := range tests {
src/go/plugin/go.d/collector/prometheus/relabel/relabel.go new
+567
@@ -0,0 +1,567 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +// Package relabel applies Prometheus-compatible metric-relabeling rules to
4 +// scraped samples (the metric name plus labels, including le/quantile) before
5 +// typed-family assembly. It is collector-local to the prometheus collector.
6 +package relabel
7 +
8 +import (
9 + "crypto/md5"
10 + "encoding/binary"
11 + "errors"
12 + "fmt"
13 + "strconv"
14 + "strings"
15 +
16 + "github.com/grafana/regexp"
17 + commonmodel "github.com/prometheus/common/model"
18 + "github.com/prometheus/prometheus/model/labels"
19 +
20 + prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
21 +)
22 +
23 +var (
24 + relabelTargetLegacy = regexp.MustCompile(`^(?:(?:[a-zA-Z_]|\$(?:\{\w+\}|\w+))+\w*)+$`)
25 +
26 + defaultConfig = Config{
27 + Action: Replace,
28 + Separator: ";",
29 + Regex: MustNewRegexp("(.*)"),
30 + Replacement: "$1",
31 + }
32 +)
33 +
34 +// defaultNameValidationScheme is the name-validation scheme applied when a rule
35 +// does not set one. It is UTF-8: relabeling may legitimately produce dotted or
36 +// otherwise non-legacy metric and label names, and only an empty name is
37 +// rejected. A rule may still opt into commonmodel.LegacyValidation via
38 +// Config.NameScheme.
39 +const defaultNameValidationScheme = commonmodel.UTF8Validation
40 +
41 +// Action is the operation a relabel rule performs on a sample's labels and metric
42 +// name. It mirrors Prometheus's relabel actions; New canonicalizes the value, so it
43 +// is case-insensitive. "Joined value" below means the SourceLabels values joined by
44 +// Separator.
45 +type Action string
46 +
47 +const (
48 + // Replace sets TargetLabel from the regex match of the joined value (Replacement
49 + // is the template; an empty result deletes the target label).
50 + Replace Action = "replace"
51 + // Keep keeps the sample only when Regex matches the joined value.
52 + Keep Action = "keep"
53 + // Drop drops the sample when Regex matches the joined value.
54 + Drop Action = "drop"
55 + // KeepEqual keeps the sample only when TargetLabel equals the joined value.
56 + KeepEqual Action = "keepequal"
57 + // DropEqual drops the sample when TargetLabel equals the joined value.
58 + DropEqual Action = "dropequal"
59 + // HashMod sets TargetLabel to the MD5 of the joined value modulo Modulus.
60 + HashMod Action = "hashmod"
61 + // LabelMap copies each label whose name matches Regex to a new name from Replacement.
62 + LabelMap Action = "labelmap"
63 + // LabelDrop removes every label whose name matches Regex.
64 + LabelDrop Action = "labeldrop"
65 + // LabelKeep removes every label whose name does not match Regex.
66 + LabelKeep Action = "labelkeep"
67 + // Lowercase sets TargetLabel to the lowercased joined value.
68 + Lowercase Action = "lowercase"
69 + // Uppercase sets TargetLabel to the uppercased joined value.
70 + Uppercase Action = "uppercase"
71 +)
72 +
73 +// DropReason explains why Apply dropped a sample, for the caller to log.
74 +type DropReason string
75 +
76 +const (
77 + DropReasonNone DropReason = ""
78 + DropReasonDropRuleMatched DropReason = "drop rule matched"
79 + DropReasonKeepRuleMismatch DropReason = "keep rule did not match"
80 + DropReasonDropEqualMatched DropReason = "dropequal rule matched"
81 + DropReasonKeepEqualMismatch DropReason = "keepequal rule did not match"
82 + DropReasonInvalidMetricName DropReason = "resulting metric name is empty or invalid"
83 +)
84 +
85 +// DropInfo is the outcome of Apply. Dropped reports whether the sample was
86 +// dropped; when it was, Reason says why and, for a rule-driven drop, RuleIndex
87 +// and Action identify the rule. RuleIndex is -1 when the drop is not tied to a
88 +// single rule (an invalid final metric name).
89 +type DropInfo struct {
90 + Reason DropReason
91 + RuleIndex int
92 + Action Action
93 +}
94 +
95 +// Dropped reports whether the sample was dropped.
96 +func (d DropInfo) Dropped() bool { return d.Reason != DropReasonNone }
97 +
98 +// DropObserver is called by the SampleTransform returned from NewTransform for
99 +// each dropped sample. Implementations SHOULD log the reason/rule/action but
100 +// MUST NOT log label values (cardinality and privacy).
101 +type DropObserver func(sample prompkg.Sample, drop DropInfo)
102 +
103 +// Config is one relabeling rule. Construct it directly using the Action constants
104 +// and exported fields; rules are validated (and the Action canonicalized) by New.
105 +//
106 +// The unexported separatorSet/replacementSet/sourceLabelsSet fields distinguish an
107 +// explicitly-empty field from an unset one (they are read by withDefaults, validate
108 +// and applyReplace). They are settable only within this package; callers outside the
109 +// package cannot express explicit-empty Separator/Replacement/SourceLabels via normal
110 +// struct literals or standard YAML/JSON unmarshaling (unexported fields are ignored),
111 +// so a dedicated config loader must set them when that behavior is needed.
112 +type Config struct {
113 + SourceLabels []string
114 + Separator string
115 + Regex Regexp
116 + Modulus uint64
117 + TargetLabel string
118 + Replacement string
119 + Action Action
120 + NameScheme commonmodel.ValidationScheme
121 +
122 + separatorSet bool
123 + replacementSet bool
124 + sourceLabelsSet bool
125 +}
126 +
127 +// Regexp is a relabel regular expression: a regexp.Regexp compiled fully anchored
128 +// (see NewRegexp). The zero value has no pattern; build one with NewRegexp or
129 +// MustNewRegexp. String returns the original, un-anchored source.
130 +type Regexp struct {
131 + *regexp.Regexp
132 + original string // un-anchored source passed to NewRegexp; returned by String
133 +}
134 +
135 +// Processor applies an ordered list of rules to samples. It reuses internal
136 +// buffers across calls, so a Processor is single-threaded per scrape and is NOT
137 +// goroutine-safe.
138 +type Processor struct {
139 + cfgs []Config
140 +
141 + builder *labels.Builder
142 + join strings.Builder
143 + buf []byte
144 + rangeBuf []labels.Label
145 +
146 + currentName string
147 + currentNameScheme commonmodel.ValidationScheme
148 + labelsChanged bool
149 + nameChanged bool
150 +}
151 +
152 +// New validates and compiles the rules into a Processor.
153 +func New(cfgs []Config) (*Processor, error) {
154 + compiled, err := normalizeAndValidateConfigs(cfgs)
155 + if err != nil {
156 + return nil, err
157 + }
158 +
159 + return &Processor{
160 + cfgs: compiled,
161 + builder: labels.NewBuilder(nil),
162 + }, nil
163 +}
164 +
165 +// NewTransform builds a prompkg.SampleTransform from the rules. It returns a nil
166 +// transform when there are no rules, so the scraper keeps its no-transform fast
167 +// path. onDrop, if non-nil, is called for each dropped sample. The returned
168 +// transform closes over a single reusable Processor, so it is NOT goroutine-safe;
169 +// use one transform per scrape goroutine.
170 +func NewTransform(cfgs []Config, onDrop DropObserver) (prompkg.SampleTransform, error) {
171 + p, err := New(cfgs)
172 + if err != nil {
173 + return nil, err
174 + }
175 + if len(p.cfgs) == 0 {
176 + return nil, nil
177 + }
178 +
179 + return func(s prompkg.Sample) (prompkg.Sample, bool, error) {
180 + out, drop := p.Apply(s)
181 + if drop.Dropped() {
182 + if onDrop != nil {
183 + onDrop(out, drop)
184 + }
185 + return prompkg.Sample{}, false, nil
186 + }
187 + return out, true, nil
188 + }, nil
189 +}
190 +
191 +func normalizeAndValidateConfigs(cfgs []Config) ([]Config, error) {
192 + compiled := make([]Config, 0, len(cfgs))
193 + for i, cfg := range cfgs {
194 + cfg = withDefaults(cfg)
195 + if err := cfg.validate(); err != nil {
196 + return nil, fmt.Errorf("rule %d: %w", i, err)
197 + }
198 + compiled = append(compiled, cfg)
199 + }
200 + return compiled, nil
201 +}
202 +
203 +func (c Config) validate() error {
204 + c = withDefaults(c)
205 +
206 + if _, err := parseAction(string(c.Action)); err != nil {
207 + return err
208 + }
209 +
210 + if err := validateNameScheme(c.NameScheme); err != nil {
211 + return err
212 + }
213 +
214 + scheme := c.NameScheme
215 + if scheme == commonmodel.UnsetValidation {
216 + scheme = defaultNameValidationScheme
217 + }
218 +
219 + if c.Modulus == 0 && c.Action == HashMod {
220 + return errors.New("relabel configuration for hashmod requires non-zero modulus")
221 + }
222 + if needsTargetLabel(c.Action) && c.TargetLabel == "" {
223 + return fmt.Errorf("relabel configuration for %s action requires 'target_label' value", c.Action)
224 + }
225 +
226 + if c.Action == Replace && !varInRegexTemplate(c.TargetLabel) && !scheme.IsValidLabelName(c.TargetLabel) {
227 + return fmt.Errorf("%q is invalid 'target_label' for %s action", c.TargetLabel, c.Action)
228 + }
229 + if c.Action == Replace && varInRegexTemplate(c.TargetLabel) && !isValidLabelNameWithRegexVar(c.TargetLabel, scheme) {
230 + return fmt.Errorf("%q is invalid 'target_label' for %s action", c.TargetLabel, c.Action)
231 + }
232 + if (c.Action == Lowercase || c.Action == Uppercase || c.Action == KeepEqual || c.Action == DropEqual) &&
233 + !scheme.IsValidLabelName(c.TargetLabel) {
234 + return fmt.Errorf("%q is invalid 'target_label' for %s action", c.TargetLabel, c.Action)
235 + }
236 + if (c.Action == Lowercase || c.Action == Uppercase || c.Action == KeepEqual || c.Action == DropEqual) &&
237 + c.Replacement != defaultConfig.Replacement {
238 + return fmt.Errorf("'replacement' can not be set for %s action", c.Action)
239 + }
240 + if c.Action == LabelMap && !isValidLabelNameWithRegexVar(c.Replacement, scheme) {
241 + return fmt.Errorf("%q is invalid 'replacement' for %s action", c.Replacement, c.Action)
242 + }
243 + if c.Action == HashMod && !scheme.IsValidLabelName(c.TargetLabel) {
244 + return fmt.Errorf("%q is invalid 'target_label' for %s action", c.TargetLabel, c.Action)
245 + }
246 + if c.Action == DropEqual || c.Action == KeepEqual {
247 + if c.Regex.String() != defaultConfig.Regex.String() ||
248 + c.Modulus != defaultConfig.Modulus ||
249 + c.Separator != defaultConfig.Separator ||
250 + c.Replacement != defaultConfig.Replacement {
251 + return fmt.Errorf("%s action requires only 'source_labels' and 'target_label', and no other fields", c.Action)
252 + }
253 + }
254 + if c.Action == LabelDrop || c.Action == LabelKeep {
255 + if c.sourceLabelsSet ||
256 + len(c.SourceLabels) > 0 ||
257 + c.TargetLabel != defaultConfig.TargetLabel ||
258 + c.Modulus != defaultConfig.Modulus ||
259 + c.Separator != defaultConfig.Separator ||
260 + c.Replacement != defaultConfig.Replacement {
261 + return fmt.Errorf("%s action requires only 'regex', and no other fields", c.Action)
262 + }
263 + }
264 +
265 + return nil
266 +}
267 +
268 +func NewRegexp(s string) (Regexp, error) {
269 + re, err := regexp.Compile("^(?s:" + s + ")$")
270 + return Regexp{Regexp: re, original: s}, err
271 +}
272 +
273 +func MustNewRegexp(s string) Regexp {
274 + re, err := NewRegexp(s)
275 + if err != nil {
276 + panic(err)
277 + }
278 + return re
279 +}
280 +
281 +// String returns the original, un-anchored pattern passed to NewRegexp. It returns
282 +// "" for the zero value or any Regexp not built via NewRegexp, and never inspects the
283 +// compiled form, so it is safe on a Regexp wrapping an arbitrary *regexp.Regexp.
284 +func (re Regexp) String() string {
285 + return re.original
286 +}
287 +
288 +// Apply runs the rules against one sample. It returns the (possibly mutated)
289 +// sample and a DropInfo. When DropInfo.Dropped() is true the sample must be
290 +// discarded; the returned sample is the original (unmutated) so the caller can
291 +// log its name. Value, Kind and FamilyType are passed through unchanged — a
292 +// relabeled sample is never re-typed.
293 +func (p *Processor) Apply(sample prompkg.Sample) (prompkg.Sample, DropInfo) {
294 + if len(p.cfgs) == 0 {
295 + return sample, DropInfo{}
296 + }
297 +
298 + p.builder.Reset(sample.Labels)
299 + p.currentName = sample.Name
300 + p.currentNameScheme = defaultNameValidationScheme
301 + p.labelsChanged = false
302 + p.nameChanged = false
303 +
304 + for i := range p.cfgs {
305 + if keep, drop := p.applyConfig(&p.cfgs[i], i); !keep {
306 + return sample, drop
307 + }
308 + }
309 +
310 + if !p.currentNameScheme.IsValidMetricName(p.currentName) {
311 + return sample, DropInfo{Reason: DropReasonInvalidMetricName, RuleIndex: -1}
312 + }
313 +
314 + if !p.nameChanged && !p.labelsChanged {
315 + return sample, DropInfo{}
316 + }
317 +
318 + sample.Name = p.currentName
319 + if p.labelsChanged {
320 + sample.Labels = p.builder.Labels()
321 + }
322 + return sample, DropInfo{}
323 +}
324 +
325 +func (p *Processor) applyConfig(cfg *Config, idx int) (bool, DropInfo) {
326 + val := p.joinSourceLabels(cfg.SourceLabels, cfg.Separator)
327 +
328 + switch cfg.Action {
329 + case Drop:
330 + if cfg.Regex.MatchString(val) {
331 + return false, DropInfo{Reason: DropReasonDropRuleMatched, RuleIndex: idx, Action: Drop}
332 + }
333 + case Keep:
334 + if !cfg.Regex.MatchString(val) {
335 + return false, DropInfo{Reason: DropReasonKeepRuleMismatch, RuleIndex: idx, Action: Keep}
336 + }
337 + case DropEqual:
338 + if p.getLabel(cfg.TargetLabel) == val {
339 + return false, DropInfo{Reason: DropReasonDropEqualMatched, RuleIndex: idx, Action: DropEqual}
340 + }
341 + case KeepEqual:
342 + if p.getLabel(cfg.TargetLabel) != val {
343 + return false, DropInfo{Reason: DropReasonKeepEqualMismatch, RuleIndex: idx, Action: KeepEqual}
344 + }
345 + case Replace:
346 + p.applyReplace(cfg, val)
347 + case Lowercase:
348 + p.setLabel(cfg.TargetLabel, strings.ToLower(val), cfg.NameScheme)
349 + case Uppercase:
350 + p.setLabel(cfg.TargetLabel, strings.ToUpper(val), cfg.NameScheme)
351 + case HashMod:
352 + hash := md5.Sum([]byte(val))
353 + mod := binary.BigEndian.Uint64(hash[8:]) % cfg.Modulus
354 + p.setLabel(cfg.TargetLabel, strconv.FormatUint(mod, 10), cfg.NameScheme)
355 + case LabelMap:
356 + p.rangeLabels(func(l labels.Label) {
357 + if cfg.Regex.MatchString(l.Name) {
358 + p.setLabel(cfg.Regex.ReplaceAllString(l.Name, cfg.Replacement), l.Value, cfg.NameScheme)
359 + }
360 + })
361 + case LabelDrop:
362 + p.rangeLabels(func(l labels.Label) {
363 + if cfg.Regex.MatchString(l.Name) {
364 + p.delLabel(l.Name)
365 + }
366 + })
367 + case LabelKeep:
368 + p.rangeLabels(func(l labels.Label) {
369 + if !cfg.Regex.MatchString(l.Name) {
370 + p.delLabel(l.Name)
371 + }
372 + })
373 + default:
374 + panic(fmt.Errorf("unknown relabel action %q", cfg.Action))
375 + }
376 +
377 + return true, DropInfo{}
378 +}
379 +
380 +func (p *Processor) applyReplace(cfg *Config, val string) {
381 + if val == "" &&
382 + cfg.Regex.String() == defaultConfig.Regex.String() &&
383 + !varInRegexTemplate(cfg.TargetLabel) &&
384 + !varInRegexTemplate(cfg.Replacement) {
385 + p.setLabel(cfg.TargetLabel, cfg.Replacement, cfg.NameScheme)
386 + return
387 + }
388 +
389 + indexes := cfg.Regex.FindStringSubmatchIndex(val)
390 + if indexes == nil {
391 + return
392 + }
393 +
394 + p.buf = cfg.Regex.ExpandString(p.buf[:0], cfg.TargetLabel, val, indexes)
395 + target := string(p.buf)
396 + if !cfg.NameScheme.IsValidLabelName(target) {
397 + return
398 + }
399 +
400 + p.buf = cfg.Regex.ExpandString(p.buf[:0], cfg.Replacement, val, indexes)
401 + if len(p.buf) == 0 {
402 + p.delLabel(target)
403 + return
404 + }
405 +
406 + p.setLabel(target, string(p.buf), cfg.NameScheme)
407 +}
408 +
409 +func (p *Processor) joinSourceLabels(sourceLabels []string, separator string) string {
410 + switch len(sourceLabels) {
411 + case 0:
412 + return ""
413 + case 1:
414 + return p.getLabel(sourceLabels[0])
415 + }
416 +
417 + p.join.Reset()
418 + for i, name := range sourceLabels {
419 + if i > 0 {
420 + p.join.WriteString(separator)
421 + }
422 + p.join.WriteString(p.getLabel(name))
423 + }
424 +
425 + return p.join.String()
426 +}
427 +
428 +func (p *Processor) getLabel(name string) string {
429 + if name == commonmodel.MetricNameLabel {
430 + return p.currentName
431 + }
432 + return p.builder.Get(name)
433 +}
434 +
435 +func (p *Processor) setLabel(name, value string, scheme commonmodel.ValidationScheme) {
436 + if name == commonmodel.MetricNameLabel {
437 + p.currentNameScheme = scheme
438 + if p.currentName != value {
439 + p.currentName = value
440 + p.nameChanged = true
441 + }
442 + return
443 + }
444 +
445 + if current, ok := p.lookupLabel(name); ok && current == value {
446 + return
447 + }
448 +
449 + p.builder.Set(name, value)
450 + p.labelsChanged = true
451 +}
452 +
453 +func (p *Processor) delLabel(name string) {
454 + if name == commonmodel.MetricNameLabel {
455 + if p.currentName != "" {
456 + p.currentName = ""
457 + p.nameChanged = true
458 + }
459 + return
460 + }
461 +
462 + if _, ok := p.lookupLabel(name); !ok {
463 + return
464 + }
465 +
466 + p.builder.Del(name)
467 + p.labelsChanged = true
468 +}
469 +
470 +func (p *Processor) rangeLabels(fn func(labels.Label)) {
471 + // Snapshot the current label set (including __name__) before invoking fn, so a
472 + // callback that adds labels (labelmap) does not re-process labels it creates in
473 + // the same rule — matching Prometheus, which ranges one snapshot per rule. The
474 + // scratch buffer is reused across calls.
475 + p.rangeBuf = p.rangeBuf[:0]
476 + if p.currentName != "" {
477 + p.rangeBuf = append(p.rangeBuf, labels.Label{Name: commonmodel.MetricNameLabel, Value: p.currentName})
478 + }
479 + p.builder.Range(func(l labels.Label) {
480 + p.rangeBuf = append(p.rangeBuf, l)
481 + })
482 + for _, l := range p.rangeBuf {
483 + fn(l)
484 + }
485 +}
486 +
487 +func (p *Processor) lookupLabel(name string) (string, bool) {
488 + if name == commonmodel.MetricNameLabel {
489 + if p.currentName == "" {
490 + return "", false
491 + }
492 + return p.currentName, true
493 + }
494 +
495 + var (
496 + value string
497 + ok bool
498 + )
499 + p.builder.Range(func(l labels.Label) {
500 + if l.Name == name {
501 + value = l.Value
502 + ok = true
503 + }
504 + })
505 + return value, ok
506 +}
507 +
508 +func parseAction(s string) (Action, error) {
509 + switch act := Action(strings.ToLower(s)); act {
510 + case Replace, Keep, Drop, KeepEqual, DropEqual, HashMod, LabelMap, LabelDrop, LabelKeep, Lowercase, Uppercase:
511 + return act, nil
512 + default:
513 + return "", fmt.Errorf("unknown relabel action %q", s)
514 + }
515 +}
516 +
517 +func withDefaults(cfg Config) Config {
518 + cfg.NameScheme = withNameScheme(cfg.NameScheme)
519 + if cfg.Action == "" {
520 + cfg.Action = defaultConfig.Action
521 + } else if act, err := parseAction(string(cfg.Action)); err == nil {
522 + // Canonicalize a valid action (e.g. "KEEP" -> "keep") so Apply's switch
523 + // matches; an invalid action is left for validate to reject.
524 + cfg.Action = act
525 + }
526 + if !cfg.separatorSet && cfg.Separator == "" {
527 + cfg.Separator = defaultConfig.Separator
528 + }
529 + if cfg.Regex.Regexp == nil {
530 + cfg.Regex = defaultConfig.Regex
531 + }
532 + if !cfg.replacementSet && cfg.Replacement == "" {
533 + cfg.Replacement = defaultConfig.Replacement
534 + }
535 + return cfg
536 +}
537 +
538 +func withNameScheme(scheme commonmodel.ValidationScheme) commonmodel.ValidationScheme {
539 + if scheme == commonmodel.UnsetValidation {
540 + return defaultNameValidationScheme
541 + }
542 + return scheme
543 +}
544 +
545 +func validateNameScheme(scheme commonmodel.ValidationScheme) error {
546 + switch scheme {
547 + case commonmodel.UnsetValidation, commonmodel.LegacyValidation, commonmodel.UTF8Validation:
548 + return nil
549 + default:
550 + return fmt.Errorf("unknown relabel config name validation method specified, must be either '', 'legacy' or 'utf8', got %s", scheme)
551 + }
552 +}
553 +
554 +func needsTargetLabel(action Action) bool {
555 + return action == Replace || action == HashMod || action == Lowercase || action == Uppercase || action == KeepEqual || action == DropEqual
556 +}
557 +
558 +func isValidLabelNameWithRegexVar(value string, scheme commonmodel.ValidationScheme) bool {
559 + if scheme == commonmodel.UTF8Validation {
560 + return scheme.IsValidLabelName(value)
561 + }
562 + return relabelTargetLegacy.MatchString(value)
563 +}
564 +
565 +func varInRegexTemplate(template string) bool {
566 + return strings.Contains(template, "$")
567 +}
src/go/plugin/go.d/collector/prometheus/relabel/relabel_bench_test.go new
+157
@@ -0,0 +1,157 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package relabel
4 +
5 +import (
6 + "strconv"
7 + "testing"
8 +
9 + commonmodel "github.com/prometheus/common/model"
10 + "github.com/prometheus/prometheus/model/labels"
11 +
12 + prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
13 +)
14 +
15 +var (
16 + benchSampleSink prompkg.Sample
17 + benchKeepSink bool
18 +)
19 +
20 +// relabel runs on every kept sample on the scrape hot path, so keep these
21 +// numbers current before/after changes to the engine. Run with:
22 +//
23 +// go test ./plugin/go.d/collector/prometheus/relabel -run '^$' -bench BenchmarkProcessorApply -benchmem
24 +//
25 +// Numbers are machine-specific; compare relative before/after deltas, and watch
26 +// the allocation columns (the no-rules and passthrough cases should stay at 0).
27 +func BenchmarkProcessorApply(b *testing.B) {
28 + type testCase struct {
29 + name string
30 + cfgs []Config
31 + sample prompkg.Sample
32 + }
33 +
34 + tests := []testCase{
35 + {
36 + name: "no_rules",
37 + cfgs: nil,
38 + sample: benchSample("http_requests_total", benchLabels(4), prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
39 + },
40 + {
41 + name: "keep_passthrough",
42 + cfgs: []Config{
43 + {
44 + SourceLabels: []string{"job"},
45 + Regex: MustNewRegexp("api"),
46 + Action: Keep,
47 + },
48 + },
49 + sample: benchSample("http_requests_total", map[string]string{
50 + "instance": "127.0.0.1:9090",
51 + "job": "api",
52 + "method": "GET",
53 + "status": "200",
54 + }, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
55 + },
56 + {
57 + name: "name_only_rewrite",
58 + cfgs: []Config{
59 + {
60 + SourceLabels: []string{commonmodel.MetricNameLabel},
61 + Regex: MustNewRegexp("(.*)_total"),
62 + TargetLabel: commonmodel.MetricNameLabel,
63 + Replacement: "${1}",
64 + Action: Replace,
65 + },
66 + },
67 + sample: benchSample("http_requests_total", benchLabels(4), prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
68 + },
69 + {
70 + name: "label_replace",
71 + cfgs: []Config{
72 + {
73 + SourceLabels: []string{"method"},
74 + TargetLabel: "http_method",
75 + Replacement: "$1",
76 + Action: Replace,
77 + },
78 + },
79 + sample: benchSample("http_requests_total", map[string]string{
80 + "instance": "127.0.0.1:9090",
81 + "job": "api",
82 + "method": "GET",
83 + "status": "200",
84 + }, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
85 + },
86 + {
87 + name: "multi_source_replace",
88 + cfgs: []Config{
89 + {
90 + SourceLabels: []string{"job", "instance", "method", "status"},
91 + Separator: "/",
92 + TargetLabel: "route_key",
93 + Replacement: "$1",
94 + Action: Replace,
95 + },
96 + },
97 + sample: benchSample("http_requests_total", map[string]string{
98 + "instance": "127.0.0.1:9090",
99 + "job": "api",
100 + "method": "GET",
101 + "status": "200",
102 + }, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
103 + },
104 + {
105 + name: "labeldrop_many_labels",
106 + cfgs: []Config{
107 + {
108 + Regex: MustNewRegexp("label_[02468]"),
109 + Action: LabelDrop,
110 + },
111 + },
112 + sample: benchSample("http_requests_total", benchLabels(12), prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
113 + },
114 + }
115 +
116 + for _, test := range tests {
117 + b.Run(test.name, func(b *testing.B) {
118 + p, err := New(test.cfgs)
119 + if err != nil {
120 + b.Fatalf("New() error = %v", err)
121 + }
122 +
123 + got, drop := p.Apply(test.sample)
124 + if drop.Dropped() {
125 + b.Fatal("unexpected drop in benchmark setup")
126 + }
127 + benchSampleSink = got
128 + benchKeepSink = !drop.Dropped()
129 +
130 + b.ReportAllocs()
131 + b.ResetTimer()
132 + for i := 0; i < b.N; i++ {
133 + got, drop = p.Apply(test.sample)
134 + benchSampleSink = got
135 + benchKeepSink = !drop.Dropped()
136 + }
137 + })
138 + }
139 +}
140 +
141 +func benchSample(name string, lbs map[string]string, kind prompkg.SampleKind, familyType commonmodel.MetricType) prompkg.Sample {
142 + return prompkg.Sample{
143 + Name: name,
144 + Labels: labels.FromMap(lbs),
145 + Value: 1,
146 + Kind: kind,
147 + FamilyType: familyType,
148 + }
149 +}
150 +
151 +func benchLabels(n int) map[string]string {
152 + lbs := make(map[string]string, n)
153 + for i := range n {
154 + lbs["label_"+strconv.Itoa(i)] = "value"
155 + }
156 + return lbs
157 +}
src/go/plugin/go.d/collector/prometheus/relabel/relabel_test.go new
+638
@@ -0,0 +1,638 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package relabel
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/grafana/regexp"
9 + commonmodel "github.com/prometheus/common/model"
10 + "github.com/prometheus/prometheus/model/labels"
11 + "github.com/stretchr/testify/assert"
12 + "github.com/stretchr/testify/require"
13 +
14 + prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
15 +)
16 +
17 +func TestRegexp_String(t *testing.T) {
18 + tests := map[string]struct {
19 + re Regexp
20 + want string
21 + }{
22 + "NewRegexp returns the un-anchored source": {re: MustNewRegexp("(.*)_total"), want: "(.*)_total"},
23 + "NewRegexp with empty source": {re: MustNewRegexp(""), want: ""},
24 + "zero value": {re: Regexp{}, want: ""},
25 + "raw non-anchored value is safe (no panic)": {re: Regexp{Regexp: regexp.MustCompile("x")}, want: ""},
26 + }
27 + for name, tc := range tests {
28 + t.Run(name, func(t *testing.T) {
29 + var got string
30 + require.NotPanics(t, func() { got = tc.re.String() })
31 + assert.Equal(t, tc.want, got)
32 + })
33 + }
34 +}
35 +
36 +func TestProcessor_Apply(t *testing.T) {
37 + tests := map[string]struct {
38 + cfgs []Config
39 + in prompkg.Sample
40 + want prompkg.Sample
41 + keep bool
42 + sameLabelBacking bool
43 + }{
44 + "labelmap matching __name__ maps it once, not its own output": {
45 + cfgs: []Config{{
46 + Regex: MustNewRegexp("(.+)"),
47 + Replacement: "copy_${1}",
48 + Action: LabelMap,
49 + }},
50 + in: sample("m", map[string]string{"job": "api"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
51 + want: sample("m", map[string]string{
52 + "job": "api",
53 + "copy___name__": "m",
54 + "copy_job": "api",
55 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
56 + keep: true,
57 + },
58 + "explicit empty separator joins without the default ';'": {
59 + cfgs: []Config{{
60 + SourceLabels: []string{"a", "b"},
61 + Separator: "",
62 + separatorSet: true,
63 + TargetLabel: "c",
64 + Action: Replace,
65 + }},
66 + in: sample("m", map[string]string{"a": "foo", "b": "bar"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
67 + want: sample("m", map[string]string{"a": "foo", "b": "bar", "c": "foobar"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
68 + keep: true,
69 + },
70 + "non-canonical action is canonicalized (KEEP -> keep)": {
71 + cfgs: []Config{{
72 + SourceLabels: []string{"a"},
73 + Regex: MustNewRegexp("foo"),
74 + Action: Action("KEEP"),
75 + }},
76 + in: sample("m", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
77 + want: sample("m", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
78 + keep: true,
79 + },
80 + "zero rules pass through without rematerializing labels": {
81 + in: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
82 + want: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
83 + keep: true,
84 + sameLabelBacking: true,
85 + },
86 + "replace rewrites label and preserves sample payload": {
87 + cfgs: []Config{
88 + {
89 + SourceLabels: []string{"a"},
90 + Regex: MustNewRegexp("f(.*)"),
91 + TargetLabel: "b",
92 + Replacement: "ch${1}",
93 + Action: Replace,
94 + },
95 + },
96 + in: sample("test_total", map[string]string{"a": "foo"}, 1.5, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
97 + want: sample("test_total", map[string]string{
98 + "a": "foo",
99 + "b": "choo",
100 + }, 1.5, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
101 + keep: true,
102 + },
103 + "replace can rewrite __name__": {
104 + cfgs: []Config{
105 + {
106 + SourceLabels: []string{commonmodel.MetricNameLabel},
107 + Regex: MustNewRegexp("(.*)_total"),
108 + TargetLabel: commonmodel.MetricNameLabel,
109 + Replacement: "${1}",
110 + Action: Replace,
111 + },
112 + },
113 + in: sample("http_requests_total", map[string]string{"method": "GET"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
114 + want: sample("http_requests", map[string]string{"method": "GET"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
115 + keep: true,
116 + },
117 + "label-only relabel preserves existing utf8 metric name": {
118 + cfgs: []Config{
119 + {
120 + SourceLabels: []string{"method"},
121 + TargetLabel: "method_upper",
122 + Action: Uppercase,
123 + },
124 + },
125 + in: sample("http.requests_total", map[string]string{"method": "get"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
126 + want: sample("http.requests_total", map[string]string{
127 + "method": "get",
128 + "method_upper": "GET",
129 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
130 + keep: true,
131 + },
132 + "replace rewrites __name__ with explicit utf8 scheme at runtime": {
133 + cfgs: []Config{
134 + {
135 + SourceLabels: []string{commonmodel.MetricNameLabel},
136 + Regex: MustNewRegexp("(.*)_total"),
137 + TargetLabel: commonmodel.MetricNameLabel,
138 + Replacement: "${1}.total",
139 + Action: Replace,
140 + NameScheme: commonmodel.UTF8Validation,
141 + },
142 + },
143 + in: sample("http_requests_total", map[string]string{"method": "GET"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
144 + want: sample("http_requests.total", map[string]string{"method": "GET"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
145 + keep: true,
146 + sameLabelBacking: true,
147 + },
148 + "histogram bucket relabel preserves kind and family type": {
149 + cfgs: []Config{
150 + {
151 + SourceLabels: []string{commonmodel.MetricNameLabel},
152 + Regex: MustNewRegexp("(.*)"),
153 + TargetLabel: commonmodel.MetricNameLabel,
154 + Replacement: "nginx_${1}",
155 + Action: Replace,
156 + },
157 + },
158 + in: sample("request_duration_seconds_bucket", map[string]string{
159 + "le": "0.5",
160 + "method": "GET",
161 + }, 42, prompkg.SampleKindHistogramBucket, commonmodel.MetricTypeHistogram),
162 + want: sample("nginx_request_duration_seconds_bucket", map[string]string{
163 + "le": "0.5",
164 + "method": "GET",
165 + }, 42, prompkg.SampleKindHistogramBucket, commonmodel.MetricTypeHistogram),
166 + keep: true,
167 + sameLabelBacking: true,
168 + },
169 + "summary quantile relabel preserves kind and family type": {
170 + cfgs: []Config{
171 + {
172 + SourceLabels: []string{commonmodel.MetricNameLabel},
173 + Regex: MustNewRegexp("(.*)"),
174 + TargetLabel: commonmodel.MetricNameLabel,
175 + Replacement: "nginx_${1}",
176 + Action: Replace,
177 + },
178 + },
179 + in: sample("request_duration_seconds", map[string]string{
180 + "method": "GET",
181 + "quantile": "0.9",
182 + }, 7, prompkg.SampleKindSummaryQuantile, commonmodel.MetricTypeSummary),
183 + want: sample("nginx_request_duration_seconds", map[string]string{
184 + "method": "GET",
185 + "quantile": "0.9",
186 + }, 7, prompkg.SampleKindSummaryQuantile, commonmodel.MetricTypeSummary),
187 + keep: true,
188 + sameLabelBacking: true,
189 + },
190 + "drop drops matching input": {
191 + cfgs: []Config{
192 + {
193 + SourceLabels: []string{"a"},
194 + Regex: MustNewRegexp(".*o.*"),
195 + Action: Drop,
196 + },
197 + },
198 + in: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
199 + keep: false,
200 + },
201 + "drop uses anchored regex semantics": {
202 + cfgs: []Config{
203 + {
204 + SourceLabels: []string{"a"},
205 + Regex: MustNewRegexp("f|o"),
206 + Action: Drop,
207 + },
208 + },
209 + in: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
210 + want: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
211 + keep: true,
212 + sameLabelBacking: true,
213 + },
214 + "keep drops non matching input": {
215 + cfgs: []Config{
216 + {
217 + SourceLabels: []string{"a"},
218 + Regex: MustNewRegexp("no-match"),
219 + Action: Keep,
220 + },
221 + },
222 + in: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
223 + keep: false,
224 + },
225 + "ordered rules see prior __name__ rewrite": {
226 + cfgs: []Config{
227 + {
228 + SourceLabels: []string{"rename_to"},
229 + TargetLabel: commonmodel.MetricNameLabel,
230 + Replacement: "$1",
231 + Action: Replace,
232 + NameScheme: commonmodel.UTF8Validation,
233 + },
234 + {
235 + SourceLabels: []string{commonmodel.MetricNameLabel},
236 + TargetLabel: "seen_name",
237 + Replacement: "prefix_$1",
238 + Action: Replace,
239 + },
240 + },
241 + in: sample("request_total", map[string]string{
242 + "rename_to": "request.total",
243 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
244 + want: sample("request.total", map[string]string{
245 + "rename_to": "request.total",
246 + "seen_name": "prefix_request.total",
247 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeCounter),
248 + keep: true,
249 + },
250 + "keepequal keeps when values match": {
251 + cfgs: []Config{
252 + {
253 + SourceLabels: []string{"__tmp_port"},
254 + TargetLabel: "__port1",
255 + Action: KeepEqual,
256 + },
257 + },
258 + in: sample("test_metric", map[string]string{"__tmp_port": "1234", "__port1": "1234"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
259 + want: sample("test_metric", map[string]string{
260 + "__tmp_port": "1234",
261 + "__port1": "1234",
262 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
263 + keep: true,
264 + },
265 + "dropequal drops when values match": {
266 + cfgs: []Config{
267 + {
268 + SourceLabels: []string{"__tmp_port"},
269 + TargetLabel: "__port1",
270 + Action: DropEqual,
271 + },
272 + },
273 + in: sample("test_metric", map[string]string{"__tmp_port": "1234", "__port1": "1234"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
274 + keep: false,
275 + },
276 + "hashmod matches upstream example": {
277 + cfgs: []Config{
278 + {
279 + SourceLabels: []string{"c"},
280 + TargetLabel: "d",
281 + Action: HashMod,
282 + Modulus: 1000,
283 + },
284 + },
285 + in: sample("test_metric", map[string]string{"a": "foo", "b": "bar", "c": "baz"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
286 + want: sample("test_metric", map[string]string{
287 + "a": "foo",
288 + "b": "bar",
289 + "c": "baz",
290 + "d": "976",
291 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
292 + keep: true,
293 + },
294 + "labelmap copies matching labels": {
295 + cfgs: []Config{
296 + {
297 + Regex: MustNewRegexp("(b.*)"),
298 + Replacement: "bar_${1}",
299 + Action: LabelMap,
300 + },
301 + },
302 + in: sample("test_metric", map[string]string{"a": "foo", "b1": "bar", "b2": "baz"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
303 + want: sample("test_metric", map[string]string{
304 + "a": "foo",
305 + "b1": "bar",
306 + "b2": "baz",
307 + "bar_b1": "bar",
308 + "bar_b2": "baz",
309 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
310 + keep: true,
311 + },
312 + "labeldrop removes matching labels": {
313 + cfgs: []Config{
314 + {
315 + Regex: MustNewRegexp("(b.*)"),
316 + Action: LabelDrop,
317 + },
318 + },
319 + in: sample("test_metric", map[string]string{"a": "foo", "b1": "bar", "b2": "baz"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
320 + want: sample("test_metric", map[string]string{
321 + "a": "foo",
322 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
323 + keep: true,
324 + },
325 + "labelkeep can drop __name__ and therefore drop the sample": {
326 + cfgs: []Config{
327 + {
328 + Regex: MustNewRegexp("(b.*)"),
329 + Action: LabelKeep,
330 + },
331 + },
332 + in: sample("test_metric", map[string]string{"b1": "bar"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
333 + keep: false,
334 + },
335 + "lowercase and uppercase write derived labels": {
336 + cfgs: []Config{
337 + {
338 + SourceLabels: []string{"foo"},
339 + TargetLabel: "foo_uppercase",
340 + Action: Uppercase,
341 + },
342 + {
343 + SourceLabels: []string{"foo"},
344 + TargetLabel: "foo_lowercase",
345 + Action: Lowercase,
346 + },
347 + },
348 + in: sample("test_metric", map[string]string{"foo": "bAr123Foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
349 + want: sample("test_metric", map[string]string{
350 + "foo": "bAr123Foo",
351 + "foo_lowercase": "bar123foo",
352 + "foo_uppercase": "BAR123FOO",
353 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
354 + keep: true,
355 + },
356 + "explicit empty replacement deletes target label": {
357 + cfgs: []Config{
358 + {
359 + SourceLabels: []string{"a"},
360 + TargetLabel: "b",
361 + Replacement: "",
362 + Action: Replace,
363 + replacementSet: true,
364 + },
365 + },
366 + in: sample("test_metric", map[string]string{"a": "foo", "b": "bar"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
367 + want: sample("test_metric", map[string]string{
368 + "a": "foo",
369 + }, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
370 + keep: true,
371 + },
372 + "invalid final metric name drops the sample": {
373 + cfgs: []Config{
374 + {
375 + SourceLabels: []string{commonmodel.MetricNameLabel},
376 + TargetLabel: commonmodel.MetricNameLabel,
377 + Replacement: "",
378 + Action: Replace,
379 + replacementSet: true,
380 + },
381 + },
382 + in: sample("test_metric", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
383 + keep: false,
384 + },
385 + }
386 +
387 + for name, test := range tests {
388 + t.Run(name, func(t *testing.T) {
389 + p, err := New(test.cfgs)
390 + require.NoError(t, err)
391 +
392 + got, drop := p.Apply(test.in)
393 + require.Equal(t, test.keep, !drop.Dropped())
394 + if !drop.Dropped() {
395 + assert.Equal(t, test.want, got)
396 + if test.sameLabelBacking && len(test.in.Labels) > 0 {
397 + require.NotEmpty(t, got.Labels)
398 + assert.True(t, &got.Labels[0] == &test.in.Labels[0])
399 + }
400 + }
401 + })
402 + }
403 +}
404 +
405 +func TestNew_Validate(t *testing.T) {
406 + tests := map[string]struct {
407 + cfgs []Config
408 + wantErrText string
409 + }{
410 + "rejects labeldrop with explicit empty source_labels": {
411 + cfgs: []Config{{
412 + Action: LabelDrop,
413 + Regex: MustNewRegexp("foo"),
414 + sourceLabelsSet: true,
415 + }},
416 + wantErrText: `requires only 'regex'`,
417 + },
418 + "rejects replace with invalid regex-var target label under legacy": {
419 + cfgs: []Config{{
420 + Action: Replace,
421 + SourceLabels: []string{"a"},
422 + TargetLabel: "${1}.x",
423 + NameScheme: commonmodel.LegacyValidation,
424 + }},
425 + wantErrText: `invalid 'target_label'`,
426 + },
427 + "rejects labelmap with invalid replacement under legacy": {
428 + cfgs: []Config{{
429 + Action: LabelMap,
430 + Regex: MustNewRegexp("(.+)"),
431 + Replacement: "bad.name",
432 + NameScheme: commonmodel.LegacyValidation,
433 + }},
434 + wantErrText: `invalid 'replacement'`,
435 + },
436 + "rejects unknown action": {
437 + cfgs: []Config{{Action: Action("wat")}},
438 + wantErrText: `unknown relabel action "wat"`,
439 + },
440 + "rejects missing target label for replace": {
441 + cfgs: []Config{{Action: Replace}},
442 + wantErrText: `requires 'target_label' value`,
443 + },
444 + "rejects hashmod without modulus": {
445 + cfgs: []Config{{
446 + Action: HashMod,
447 + TargetLabel: "d",
448 + }},
449 + wantErrText: `requires non-zero modulus`,
450 + },
451 + "rejects labeldrop extra fields": {
452 + cfgs: []Config{{
453 + Action: LabelDrop,
454 + Regex: MustNewRegexp("foo"),
455 + Replacement: "bar",
456 + replacementSet: true,
457 + }},
458 + wantErrText: `requires only 'regex'`,
459 + },
460 + "rejects keepequal with replacement": {
461 + cfgs: []Config{{
462 + Action: KeepEqual,
463 + TargetLabel: "__port1",
464 + Replacement: "bar",
465 + replacementSet: true,
466 + }},
467 + wantErrText: `'replacement' can not be set for keepequal action`,
468 + },
469 + "rejects legacy invalid target label": {
470 + cfgs: []Config{{
471 + Action: Lowercase,
472 + TargetLabel: "${3}",
473 + NameScheme: commonmodel.LegacyValidation,
474 + }},
475 + wantErrText: `"${3}" is invalid 'target_label' for lowercase action`,
476 + },
477 + "accepts utf8 target label for lowercase": {
478 + cfgs: []Config{{
479 + Action: Lowercase,
480 + TargetLabel: "${3}",
481 + NameScheme: commonmodel.UTF8Validation,
482 + }},
483 + },
484 + "defaults to utf8 validation when unset (accepts a target legacy would reject)": {
485 + cfgs: []Config{{
486 + Action: Lowercase,
487 + TargetLabel: "${3}",
488 + }},
489 + },
490 + "rejects invalid name scheme": {
491 + cfgs: []Config{{
492 + Action: Lowercase,
493 + TargetLabel: "foo",
494 + NameScheme: commonmodel.ValidationScheme(99),
495 + }},
496 + wantErrText: `unknown relabel config name validation method specified`,
497 + },
498 + }
499 +
500 + for name, test := range tests {
501 + t.Run(name, func(t *testing.T) {
502 + _, err := New(test.cfgs)
503 + if test.wantErrText == "" {
504 + require.NoError(t, err)
505 + return
506 + }
507 +
508 + require.ErrorContains(t, err, test.wantErrText)
509 + })
510 + }
511 +}
512 +
513 +func sample(name string, lbs map[string]string, value float64, kind prompkg.SampleKind, familyType commonmodel.MetricType) prompkg.Sample {
514 + return prompkg.Sample{
515 + Name: name,
516 + Labels: labels.FromMap(lbs),
517 + Value: value,
518 + Kind: kind,
519 + FamilyType: familyType,
520 + }
521 +}
522 +
523 +func TestProcessor_Apply_dropInfo(t *testing.T) {
524 + tests := map[string]struct {
525 + cfgs []Config
526 + in prompkg.Sample
527 + wantReason DropReason
528 + wantRule int
529 + wantAction Action
530 + }{
531 + "drop rule matched": {
532 + cfgs: []Config{{SourceLabels: []string{"a"}, Regex: MustNewRegexp("foo"), Action: Drop}},
533 + in: sample("m", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
534 + wantReason: DropReasonDropRuleMatched,
535 + wantRule: 0,
536 + wantAction: Drop,
537 + },
538 + "dropequal matched": {
539 + cfgs: []Config{{SourceLabels: []string{"a"}, TargetLabel: "b", Action: DropEqual}},
540 + in: sample("m", map[string]string{"a": "x", "b": "x"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
541 + wantReason: DropReasonDropEqualMatched,
542 + wantRule: 0,
543 + wantAction: DropEqual,
544 + },
545 + "keepequal did not match": {
546 + cfgs: []Config{{SourceLabels: []string{"a"}, TargetLabel: "b", Action: KeepEqual}},
547 + in: sample("m", map[string]string{"a": "x", "b": "y"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
548 + wantReason: DropReasonKeepEqualMismatch,
549 + wantRule: 0,
550 + wantAction: KeepEqual,
551 + },
552 + "keep rule did not match": {
553 + cfgs: []Config{{SourceLabels: []string{"a"}, Regex: MustNewRegexp("foo"), Action: Keep}},
554 + in: sample("m", map[string]string{"a": "bar"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
555 + wantReason: DropReasonKeepRuleMismatch,
556 + wantRule: 0,
557 + wantAction: Keep,
558 + },
559 + "invalid metric name (labelkeep drops __name__)": {
560 + cfgs: []Config{{Regex: MustNewRegexp("keep"), Action: LabelKeep}},
561 + in: sample("m", map[string]string{"keep": "v", "other": "x"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge),
562 + wantReason: DropReasonInvalidMetricName,
563 + wantRule: -1,
564 + wantAction: "",
565 + },
566 + }
567 +
568 + for name, test := range tests {
569 + t.Run(name, func(t *testing.T) {
570 + p, err := New(test.cfgs)
571 + require.NoError(t, err)
572 +
573 + got, drop := p.Apply(test.in)
574 + require.True(t, drop.Dropped())
575 + assert.Equal(t, test.wantReason, drop.Reason)
576 + assert.Equal(t, test.wantRule, drop.RuleIndex)
577 + assert.Equal(t, test.wantAction, drop.Action)
578 + // Apply returns the original sample on drop so the caller can log it.
579 + assert.Equal(t, test.in, got)
580 + })
581 + }
582 +}
583 +
584 +func TestNewTransform(t *testing.T) {
585 + gauge := func() prompkg.Sample {
586 + return sample("m", map[string]string{"a": "foo"}, 1, prompkg.SampleKindScalar, commonmodel.MetricTypeGauge)
587 + }
588 +
589 + t.Run("nil transform when there are no rules", func(t *testing.T) {
590 + tr, err := NewTransform(nil, nil)
591 + require.NoError(t, err)
592 + assert.Nil(t, tr)
593 + })
594 +
595 + t.Run("invalid rules return an error", func(t *testing.T) {
596 + _, err := NewTransform([]Config{{Action: HashMod, TargetLabel: "x"}}, nil)
597 + assert.Error(t, err)
598 + })
599 +
600 + t.Run("applies rules and keeps", func(t *testing.T) {
601 + tr, err := NewTransform([]Config{{
602 + SourceLabels: []string{"a"},
603 + Regex: MustNewRegexp("f(.*)"),
604 + TargetLabel: "b",
605 + Replacement: "x${1}",
606 + Action: Replace,
607 + }}, nil)
608 + require.NoError(t, err)
609 + require.NotNil(t, tr)
610 +
611 + out, keep, err := tr(gauge())
612 + require.NoError(t, err)
613 + assert.True(t, keep)
614 + assert.Equal(t, "xoo", out.Labels.Get("b"))
615 + })
616 +
617 + t.Run("drop calls onDrop with the original sample and no scrape error", func(t *testing.T) {
618 + var observed []DropInfo
619 + var observedSample prompkg.Sample
620 + tr, err := NewTransform([]Config{{
621 + SourceLabels: []string{"a"},
622 + Regex: MustNewRegexp("foo"),
623 + Action: Drop,
624 + }}, func(s prompkg.Sample, d DropInfo) {
625 + observedSample = s
626 + observed = append(observed, d)
627 + })
628 + require.NoError(t, err)
629 +
630 + in := gauge()
631 + _, keep, err := tr(in)
632 + require.NoError(t, err) // a drop is not a scrape error
633 + assert.False(t, keep)
634 + require.Len(t, observed, 1)
635 + assert.Equal(t, DropReasonDropRuleMatched, observed[0].Reason)
636 + assert.Equal(t, in, observedSample)
637 + })
638 +}