| 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 | } |