master
go 249 lines 7.9 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package prometheus
4
5 import (
6 "cmp"
7 "math"
8 "slices"
9
10 "github.com/netdata/netdata/go/plugins/pkg/metrix"
11 prompkg "github.com/netdata/netdata/go/plugins/pkg/prometheus"
12 commonmodel "github.com/prometheus/common/model"
13 )
14
15 // metricFamilySchema captures the per-name distribution schema that metrix requires to stay stable
16 // across a family's series: the quantile set for summaries, the bucket bounds for histograms.
17 // Label keys are intentionally NOT part of the schema — each series carries its own labels, so a
18 // family may legitimately mix label-key sets (V1 rendered every series independently).
19 type metricFamilySchema struct {
20 summaryQuantiles []float64
21 histogramBounds []float64
22 }
23
24 func deriveMetricFamilySchema(mf *prompkg.MetricFamily, typ commonmodel.MetricType) (metricFamilySchema, bool) {
25 for _, metric := range mf.Metrics() {
26 schema, ok := deriveMetricSchema(metric, typ)
27 if ok {
28 return schema, true
29 }
30 }
31
32 return metricFamilySchema{}, false
33 }
34
35 func deriveMetricSchema(metric prompkg.Metric, typ commonmodel.MetricType) (metricFamilySchema, bool) {
36 var schema metricFamilySchema
37
38 switch typ {
39 case commonmodel.MetricTypeGauge:
40 if _, ok := metricScalarValue(metric, commonmodel.MetricTypeGauge); !ok {
41 return metricFamilySchema{}, false
42 }
43 case commonmodel.MetricTypeCounter:
44 if _, ok := metricScalarValue(metric, commonmodel.MetricTypeCounter); !ok {
45 return metricFamilySchema{}, false
46 }
47 case commonmodel.MetricTypeSummary:
48 summary := metric.Summary()
49 if summary == nil {
50 return metricFamilySchema{}, false
51 }
52 qs, ok := summaryQuantiles(summary)
53 if !ok {
54 return metricFamilySchema{}, false
55 }
56 if _, ok := toSummaryPoint(summary); !ok {
57 return metricFamilySchema{}, false
58 }
59 schema.summaryQuantiles = qs
60 case commonmodel.MetricTypeHistogram:
61 histogram := metric.Histogram()
62 if histogram == nil {
63 return metricFamilySchema{}, false
64 }
65 bounds, ok := histogramBounds(histogram)
66 if !ok {
67 return metricFamilySchema{}, false
68 }
69 if _, ok := toHistogramPoint(histogram); !ok {
70 return metricFamilySchema{}, false
71 }
72 schema.histogramBounds = bounds
73 default:
74 return metricFamilySchema{}, false
75 }
76
77 return schema, true
78 }
79
80 // metricIsWritable reports whether a series can be written under the family's canonical schema.
81 // Only the distribution schema must match (metrix keys hist/summary schema by metric name); label
82 // keys may differ between series and are written per-series.
83 func metricIsWritable(metric prompkg.Metric, typ commonmodel.MetricType, schema metricFamilySchema) bool {
84 metricSchema, ok := deriveMetricSchema(metric, typ)
85 if !ok {
86 return false
87 }
88 return slices.Equal(schema.summaryQuantiles, metricSchema.summaryQuantiles) &&
89 slices.Equal(schema.histogramBounds, metricSchema.histogramBounds)
90 }
91
92 func metricScalarValue(metric prompkg.Metric, typ commonmodel.MetricType) (float64, bool) {
93 switch typ {
94 case commonmodel.MetricTypeGauge:
95 if gauge := metric.Gauge(); gauge != nil && isFinite(gauge.Value()) {
96 return gauge.Value(), true
97 }
98 case commonmodel.MetricTypeCounter:
99 if counter := metric.Counter(); counter != nil && isFinite(counter.Value()) {
100 return counter.Value(), true
101 }
102 }
103
104 // Untyped fallthrough: a family resolved to gauge/counter via fallback_type carries its value in
105 // Untyped() (a real typed gauge/counter already returned from the switch). This is the only way a
106 // gauge/counter-typed family reaches here.
107 if untyped := metric.Untyped(); untyped != nil && isFinite(untyped.Value()) {
108 return untyped.Value(), true
109 }
110
111 return 0, false
112 }
113
114 func toSummaryPoint(summary *prompkg.Summary) (metrix.SummaryPoint, bool) {
115 if summary == nil || len(summary.Quantiles()) == 0 {
116 return metrix.SummaryPoint{}, false
117 }
118 // A Prometheus summary leaves every quantile NaN for an empty observation window. Skip the
119 // whole summary so a chart is not created until it has a real value (consistent with the
120 // scalar NaN-skip); writing resumes once any quantile is observed.
121 if summary.IsNaN() {
122 return metrix.SummaryPoint{}, false
123 }
124 if !isFinite(summary.Count()) || !isFinite(summary.Sum()) || summary.Count() < 0 {
125 return metrix.SummaryPoint{}, false
126 }
127
128 quantiles := make([]metrix.QuantilePoint, 0, len(summary.Quantiles()))
129 for _, q := range summary.Quantiles() {
130 // A partially-observed summary can still carry a NaN quantile (an all-NaN summary is
131 // skipped above). Keep the NaN: metrix stores it and chartengine renders that dimension
132 // as a gap. Only an infinite quantile value is rejected.
133 if !isFinite(q.Quantile()) || q.Quantile() < 0 || q.Quantile() > 1 || math.IsInf(q.Value(), 0) {
134 return metrix.SummaryPoint{}, false
135 }
136 quantiles = append(quantiles, metrix.QuantilePoint{
137 Quantile: q.Quantile(),
138 Value: q.Value(),
139 })
140 }
141
142 return metrix.SummaryPoint{
143 Count: summary.Count(),
144 Sum: summary.Sum(),
145 Quantiles: quantiles,
146 }, true
147 }
148
149 // toHistogramPoint validates and converts a scraped histogram into a metrix point. The le="+Inf"
150 // bucket is intentionally dropped: metrix synthesizes the le="+Inf" flattened series from Count, so a
151 // malformed +Inf count is superseded by Count rather than causing the whole histogram to be rejected.
152 // Validation (finiteness, strictly-increasing bounds, monotonic cumulative counts, last bucket <=
153 // Count) therefore runs over the finite buckets only.
154 func toHistogramPoint(histogram *prompkg.Histogram) (metrix.HistogramPoint, bool) {
155 if histogram == nil || len(histogram.Buckets()) == 0 {
156 return metrix.HistogramPoint{}, false
157 }
158 if !isFinite(histogram.Count()) || !isFinite(histogram.Sum()) || histogram.Count() < 0 {
159 return metrix.HistogramPoint{}, false
160 }
161
162 buckets := make([]metrix.BucketPoint, 0, len(histogram.Buckets()))
163 for _, b := range histogram.Buckets() {
164 if math.IsNaN(b.UpperBound()) || math.IsInf(b.UpperBound(), -1) {
165 return metrix.HistogramPoint{}, false
166 }
167 if math.IsInf(b.UpperBound(), +1) {
168 continue
169 }
170 if !isFinite(b.CumulativeCount()) || b.CumulativeCount() < 0 {
171 return metrix.HistogramPoint{}, false
172 }
173 buckets = append(buckets, metrix.BucketPoint{
174 UpperBound: b.UpperBound(),
175 CumulativeCount: b.CumulativeCount(),
176 })
177 }
178
179 slices.SortFunc(buckets, func(a, b metrix.BucketPoint) int { return cmp.Compare(a.UpperBound, b.UpperBound) })
180 for i := 1; i < len(buckets); i++ {
181 if buckets[i].UpperBound <= buckets[i-1].UpperBound {
182 return metrix.HistogramPoint{}, false
183 }
184 if buckets[i].CumulativeCount < buckets[i-1].CumulativeCount {
185 return metrix.HistogramPoint{}, false
186 }
187 }
188 if n := len(buckets); n > 0 && buckets[n-1].CumulativeCount > histogram.Count() {
189 return metrix.HistogramPoint{}, false
190 }
191
192 return metrix.HistogramPoint{
193 Count: histogram.Count(),
194 Sum: histogram.Sum(),
195 Buckets: buckets,
196 }, true
197 }
198
199 func summaryQuantiles(summary *prompkg.Summary) ([]float64, bool) {
200 if summary == nil || len(summary.Quantiles()) == 0 {
201 return nil, false
202 }
203
204 qs := make([]float64, 0, len(summary.Quantiles()))
205 for _, q := range summary.Quantiles() {
206 if !isFinite(q.Quantile()) || q.Quantile() < 0 || q.Quantile() > 1 {
207 return nil, false
208 }
209 qs = append(qs, q.Quantile())
210 }
211 slices.Sort(qs)
212 for i := 1; i < len(qs); i++ {
213 if qs[i] <= qs[i-1] {
214 return nil, false
215 }
216 }
217 return qs, true
218 }
219
220 func histogramBounds(histogram *prompkg.Histogram) ([]float64, bool) {
221 if histogram == nil || len(histogram.Buckets()) == 0 {
222 return nil, false
223 }
224
225 bounds := make([]float64, 0, len(histogram.Buckets()))
226 for _, b := range histogram.Buckets() {
227 if math.IsNaN(b.UpperBound()) || math.IsInf(b.UpperBound(), -1) {
228 return nil, false
229 }
230 if math.IsInf(b.UpperBound(), +1) {
231 continue
232 }
233 bounds = append(bounds, b.UpperBound())
234 }
235 if len(bounds) == 0 {
236 return []float64{}, true
237 }
238 slices.Sort(bounds)
239 for i := 1; i < len(bounds); i++ {
240 if bounds[i] <= bounds[i-1] {
241 return nil, false
242 }
243 }
244 return bounds, true
245 }
246
247 func isFinite(v float64) bool {
248 return !math.IsNaN(v) && !math.IsInf(v, 0)
249 }