diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b029c6e9..3a97d5973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Unreleased * [FEATURE] HTTP handlers created by `promhttp` package now support metrics filtering by providing one or more `name[]` query parameters. The default behavior when none are provided remains the same, returning all metrics. #1925 +* [FEATURE] testutil: Add `GatherAndAssertNativeHistogramExists`, `GatherAndAssertNativeHistogramCount`, `GatherAndAssertNativeHistogramSum`, and `GatherAndAssertNativeHistogramIntervalCount` to verify native histograms gathered from a `Gatherer`, which the text-based `GatherAndCompare` cannot represent. #2020 ## Unreleased `exp` module diff --git a/prometheus/testutil/histogram.go b/prometheus/testutil/histogram.go new file mode 100644 index 000000000..d36f99929 --- /dev/null +++ b/prometheus/testutil/histogram.go @@ -0,0 +1,325 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "fmt" + "math" + "sort" + + dto "github.com/prometheus/client_model/go" + + "github.com/prometheus/client_golang/prometheus" +) + +// GatherAndAssertNativeHistogramExists gathers all metrics from the provided +// Gatherer and returns nil if a native histogram with the given name (and, if +// labels are provided, matching those labels) was gathered. It returns an error +// otherwise, including when the named metric is a classic (non-native) histogram. +func GatherAndAssertNativeHistogramExists(g prometheus.Gatherer, name string, labels prometheus.Labels) error { + _, err := findNativeHistogram(g, name, labels) + return err +} + +// GatherAndAssertNativeHistogramCount gathers all metrics from the provided +// Gatherer and asserts that the native histogram identified by name and labels +// has the expected number of observations. The total observation count is exact, +// so there is no tolerance (unlike GatherAndAssertNativeHistogramIntervalCount, +// which estimates). +func GatherAndAssertNativeHistogramCount(g prometheus.Gatherer, name string, labels prometheus.Labels, want uint64) error { + h, err := findNativeHistogram(g, name, labels) + if err != nil { + return err + } + got := nativeHistogramCount(h) + if got != float64(want) { + return fmt.Errorf("unexpected observation count for native histogram %q: got %v, want %d", name, got, want) + } + return nil +} + +// GatherAndAssertNativeHistogramSum gathers all metrics from the provided +// Gatherer and asserts that the sum of observations of the native histogram +// identified by name and labels equals want within the given tolerance. +func GatherAndAssertNativeHistogramSum(g prometheus.Gatherer, name string, labels prometheus.Labels, want, tolerance float64) error { + h, err := findNativeHistogram(g, name, labels) + if err != nil { + return err + } + got := h.GetSampleSum() + if math.Abs(got-want) > tolerance { + return fmt.Errorf("unexpected sum for native histogram %q: got %v, want %v (tolerance %v)", name, got, want, tolerance) + } + return nil +} + +// GatherAndAssertNativeHistogramIntervalCount gathers all metrics from the +// provided Gatherer and asserts that the estimated number of observations of the +// native histogram identified by name and labels that fall into the half-open +// interval (lower, upper] equals want within the given tolerance. The lower +// bound is exclusive and the upper bound is inclusive, matching the semantics of +// Prometheus' histogram_fraction function; an observation sitting exactly on +// lower therefore falls into the preceding interval and is not counted, while one +// sitting exactly on upper is. +// +// Because native histogram buckets are exponential, the count is an estimate: a +// bucket that straddles a boundary is interpolated (logarithmically for regular +// buckets, linearly for the zero bucket), again matching histogram_fraction. +// Choose lower and upper to coincide with bucket boundaries for an exact result. +func GatherAndAssertNativeHistogramIntervalCount(g prometheus.Gatherer, name string, labels prometheus.Labels, lower, upper, want, tolerance float64) error { + h, err := findNativeHistogram(g, name, labels) + if err != nil { + return err + } + buckets := decodeNativeHistogram(h) + got := nativeHistogramRankAt(buckets, upper) - nativeHistogramRankAt(buckets, lower) + if math.Abs(got-want) > tolerance { + return fmt.Errorf( + "unexpected observation count in interval (%v, %v] for native histogram %q: got %v, want %v (tolerance %v)", + lower, upper, name, got, want, tolerance, + ) + } + return nil +} + +// findNativeHistogram gathers metrics from g, locates the metric family named +// name, selects the single series matching labels, and returns its histogram if +// it is a native histogram. It returns a descriptive error otherwise. +func findNativeHistogram(g prometheus.Gatherer, name string, labels prometheus.Labels) (*dto.Histogram, error) { + mfs, err := g.Gather() + if err != nil { + return nil, fmt.Errorf("gathering metrics failed: %w", err) + } + + var mf *dto.MetricFamily + for _, candidate := range mfs { + if candidate.GetName() == name { + mf = candidate + break + } + } + if mf == nil { + return nil, fmt.Errorf("no metric family named %q was gathered", name) + } + + var matches []*dto.Metric + for _, m := range mf.GetMetric() { + if metricMatchesLabels(m, labels) { + matches = append(matches, m) + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("no series of metric %q matched the labels %v", name, labels) + } + if len(matches) > 1 { + return nil, fmt.Errorf("%d series of metric %q matched the labels %v, expected exactly one", len(matches), name, labels) + } + + h := matches[0].GetHistogram() + if h == nil { + return nil, fmt.Errorf("metric %q is not a histogram", name) + } + if !isNativeHistogram(h) { + return nil, fmt.Errorf("metric %q is not a native histogram", name) + } + return h, nil +} + +// isNativeHistogram reports whether h carries native histogram data. The Schema +// pointer is the primary signal: client_golang only populates the native fields +// (Schema included) when sparse buckets are enabled, so a purely classic +// histogram has a nil Schema. +func isNativeHistogram(h *dto.Histogram) bool { + return h.Schema != nil || len(h.GetPositiveSpan()) > 0 || len(h.GetNegativeSpan()) > 0 +} + +// metricMatchesLabels reports whether m carries every label in labels with the +// given value. An empty selector matches any metric. +func metricMatchesLabels(m *dto.Metric, labels prometheus.Labels) bool { + for labelName, labelValue := range labels { + matched := false + for _, lp := range m.GetLabel() { + if lp.GetName() == labelName && lp.GetValue() == labelValue { + matched = true + break + } + } + if !matched { + return false + } + } + return true +} + +// nativeHistogramCount returns the number of observations recorded in h. The +// float count overrides the integer count when greater than zero, as defined by +// the SampleCountFloat field for float (gauge) histograms. +func nativeHistogramCount(h *dto.Histogram) float64 { + if h.GetSampleCountFloat() > 0 { + return h.GetSampleCountFloat() + } + return float64(h.GetSampleCount()) +} + +// nativeHistogramRankAt returns the estimated cumulative number of observations +// with a value less than or equal to v, given buckets sorted ascending by lower +// bound. A bucket that straddles v contributes an interpolated fraction of its +// count. +func nativeHistogramRankAt(buckets []nativeHistogramBucket, v float64) float64 { + var rank float64 + for _, b := range buckets { + switch { + case b.upper <= v: + rank += b.count + case b.lower >= v: + return rank + default: + rank += b.count * nativeHistogramBucketFraction(b, v) + return rank + } + } + return rank +} + +// nativeHistogramBucketFraction returns the fraction of bucket b that lies at or +// below v, with lower < v < upper. The zero bucket is interpolated linearly +// (uniform in value); regular exponential buckets are interpolated on a log2 +// scale (uniform in log of magnitude), matching Prometheus' histogram_fraction. +func nativeHistogramBucketFraction(b nativeHistogramBucket, v float64) float64 { + if b.isZero { + if b.upper == b.lower { + return 0 + } + return (v - b.lower) / (b.upper - b.lower) + } + if b.lower > 0 { + // Positive exponential bucket. + logLower, logUpper := math.Log2(b.lower), math.Log2(b.upper) + if logUpper == logLower { + return 0 + } + return (math.Log2(v) - logLower) / (logUpper - logLower) + } + // Negative exponential bucket: interpolate on the magnitude. As v increases + // toward zero its magnitude shrinks, so the fraction below v grows with the + // magnitude of the lower bound. + logLower, logUpper := math.Log2(-b.lower), math.Log2(-b.upper) + if logLower == logUpper { + return 0 + } + return (logLower - math.Log2(-v)) / (logLower - logUpper) +} + +// nativeHistogramBucket is a single reconstructed bucket of a native histogram. +// For positive buckets the covered range is (lower, upper]; for negative +// buckets it is [lower, upper); for the zero bucket it is [lower, upper] with +// lower <= 0 <= upper. +type nativeHistogramBucket struct { + lower, upper float64 + count float64 + isZero bool +} + +// nativeHistogramUpperBound returns the upper bound of the bucket with the given +// index for the given (standard exponential) schema, i.e. 2^(index * 2^-schema). +func nativeHistogramUpperBound(schema int32, index int) float64 { + if schema <= 0 { + // base = 2^(2^-schema) is an integer power of two, so the bound is an + // exact power of two: 2^(index << -schema). math.Ldexp keeps it exact. + return math.Ldexp(1, index< 0 { + out := make([]float64, len(counts)) + copy(out, counts) + return out + } + out := make([]float64, len(deltas)) + var cumulative int64 + for i, d := range deltas { + cumulative += d + out[i] = float64(cumulative) + } + return out +} + +// decodeNativeHistogram reconstructs the populated buckets of a native histogram +// (zero, positive, and negative), sorted in ascending order of their lower bound. +func decodeNativeHistogram(h *dto.Histogram) []nativeHistogramBucket { + schema := h.GetSchema() + var buckets []nativeHistogramBucket + + zeroCount := float64(h.GetZeroCount()) + if h.GetZeroCountFloat() > 0 { + zeroCount = h.GetZeroCountFloat() + } + zeroThreshold := h.GetZeroThreshold() + if zeroCount > 0 || zeroThreshold > 0 { + buckets = append(buckets, nativeHistogramBucket{ + lower: -zeroThreshold, + upper: zeroThreshold, + count: zeroCount, + isZero: true, + }) + } + + posCounts := decodeBucketCounts(h.GetPositiveDelta(), h.GetPositiveCount()) + for i, index := range decodeSpanIndices(h.GetPositiveSpan()) { + buckets = append(buckets, nativeHistogramBucket{ + lower: nativeHistogramUpperBound(schema, index-1), + upper: nativeHistogramUpperBound(schema, index), + count: posCounts[i], + }) + } + + negCounts := decodeBucketCounts(h.GetNegativeDelta(), h.GetNegativeCount()) + for i, index := range decodeSpanIndices(h.GetNegativeSpan()) { + // A negative bucket with index i covers the value range + // [-upper(i), -upper(i-1)). + buckets = append(buckets, nativeHistogramBucket{ + lower: -nativeHistogramUpperBound(schema, index), + upper: -nativeHistogramUpperBound(schema, index-1), + count: negCounts[i], + }) + } + + sort.Slice(buckets, func(i, j int) bool { + return buckets[i].lower < buckets[j].lower + }) + return buckets +} diff --git a/prometheus/testutil/histogram_test.go b/prometheus/testutil/histogram_test.go new file mode 100644 index 000000000..36a626de9 --- /dev/null +++ b/prometheus/testutil/histogram_test.go @@ -0,0 +1,431 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "math" + "testing" + + dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/proto" + + "github.com/prometheus/client_golang/prometheus" +) + +// newNativeHistogramRegistry registers a native histogram named +// "test_native_histogram" with bucket factor 1.1 (schema 3), observes the given +// values, and returns the registry. +func newNativeHistogramRegistry(t *testing.T, observations ...float64) *prometheus.Registry { + t.Helper() + reg := prometheus.NewRegistry() + h := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "test_native_histogram", + Help: "A native histogram for testing.", + NativeHistogramBucketFactor: 1.1, + }) + reg.MustRegister(h) + for _, v := range observations { + h.Observe(v) + } + return reg +} + +func TestGatherAndAssertNativeHistogramExists(t *testing.T) { + reg := newNativeHistogramRegistry(t, 0, 1, 2, 3) + if err := GatherAndAssertNativeHistogramExists(reg, "test_native_histogram", nil); err != nil { + t.Errorf("expected native histogram to exist: %v", err) + } + if err := GatherAndAssertNativeHistogramExists(reg, "does_not_exist", nil); err == nil { + t.Error("expected error for missing metric family") + } + + // A classic (non-native) histogram must be rejected. + classicReg := prometheus.NewRegistry() + classic := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "classic_histogram", + Help: "A classic histogram.", + Buckets: []float64{1, 2, 3}, + }) + classicReg.MustRegister(classic) + classic.Observe(1) + if err := GatherAndAssertNativeHistogramExists(classicReg, "classic_histogram", nil); err == nil { + t.Error("expected error for classic histogram") + } +} + +func TestGatherAndAssertNativeHistogramCount(t *testing.T) { + reg := newNativeHistogramRegistry(t, 0, 1, 2, 3) + if err := GatherAndAssertNativeHistogramCount(reg, "test_native_histogram", nil, 4); err != nil { + t.Errorf("unexpected error for matching count: %v", err) + } + if err := GatherAndAssertNativeHistogramCount(reg, "test_native_histogram", nil, 5); err == nil { + t.Error("expected error for mismatching count") + } +} + +func TestGatherAndAssertNativeHistogramSum(t *testing.T) { + reg := newNativeHistogramRegistry(t, 0, 1, 2, 3) // sum == 6 + if err := GatherAndAssertNativeHistogramSum(reg, "test_native_histogram", nil, 6, 0); err != nil { + t.Errorf("unexpected error for exact sum: %v", err) + } + if err := GatherAndAssertNativeHistogramSum(reg, "test_native_histogram", nil, 6.4, 0.5); err != nil { + t.Errorf("unexpected error for sum within tolerance: %v", err) + } + if err := GatherAndAssertNativeHistogramSum(reg, "test_native_histogram", nil, 7, 0.5); err == nil { + t.Error("expected error for sum outside tolerance") + } +} + +func TestGatherAndAssertNativeHistogramIntervalCount(t *testing.T) { + const eps = 1e-9 + + // Observations at powers of two are exact bucket upper bounds for schema 3. + reg := newNativeHistogramRegistry(t, 1, 2, 4, 8) + if err := GatherAndAssertNativeHistogramIntervalCount(reg, "test_native_histogram", nil, 0, 100, 4, eps); err != nil { + t.Errorf("whole range should count all observations: %v", err) + } + // (1, 4] captures the observations at 2 and 4; bounds are exact bucket edges. + // The observation at exactly 1 falls in bucket (2^(-1/8), 1], whose upper edge + // is the lower bound, so it belongs to the preceding interval and is excluded. + if err := GatherAndAssertNativeHistogramIntervalCount(reg, "test_native_histogram", nil, 1, 4, 2, eps); err != nil { + t.Errorf("interval (1,4] should count 2 observations: %v", err) + } + + // Interpolation: 100 observations into a single bucket (2^(7/8), 2]. + interpReg := prometheus.NewRegistry() + hi := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "interp_histogram", + Help: "A native histogram for interpolation testing.", + NativeHistogramBucketFactor: 1.1, + }) + interpReg.MustRegister(hi) + for range 100 { + hi.Observe(2.0) + } + bucketLower := nativeHistogramUpperBound(3, 7) // 2^(7/8) + bucketMid := math.Exp2(15.0 / 16.0) // geometric mean of (2^(7/8), 2^(8/8)] + // Up to the geometric mean: half the bucket (log-uniform interpolation). + if err := GatherAndAssertNativeHistogramIntervalCount(interpReg, "interp_histogram", nil, bucketLower, bucketMid, 50, 0.5); err != nil { + t.Errorf("half-bucket interpolation should be ~50: %v", err) + } + // The whole bucket: all 100, exact bounds, no interpolation. + if err := GatherAndAssertNativeHistogramIntervalCount(interpReg, "interp_histogram", nil, bucketLower, 2, 100, eps); err != nil { + t.Errorf("full bucket should count 100: %v", err) + } + + // Negative observations: 100 into the negative bucket [-2, -2^(7/8)). + negReg := prometheus.NewRegistry() + hn := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "neg_histogram", + Help: "A native histogram for negative-bucket testing.", + NativeHistogramBucketFactor: 1.1, + }) + negReg.MustRegister(hn) + for range 100 { + hn.Observe(-2.0) + } + if err := GatherAndAssertNativeHistogramIntervalCount(negReg, "neg_histogram", nil, -100, 0, 100, eps); err != nil { + t.Errorf("negative whole range should count 100: %v", err) + } + + // Quarter of the way up bucket (2^(7/8), 2] on the log scale must count ~25. + bucketQuarter := math.Exp2(29.0 / 32.0) // 2^(7/8 + (1/4)(1/8)) + if err := GatherAndAssertNativeHistogramIntervalCount(interpReg, "interp_histogram", nil, bucketLower, bucketQuarter, 25, 0.5); err != nil { + t.Errorf("quarter-bucket interpolation should be ~25: %v", err) + } + + // The lower (more negative) half of [-2, -2^(7/8)) must count ~50, pinning the + // direction of the negative-bucket interpolation. + negMid := -math.Exp2(15.0 / 16.0) // geometric mean of the magnitudes + if err := GatherAndAssertNativeHistogramIntervalCount(negReg, "neg_histogram", nil, -2, negMid, 50, 0.5); err != nil { + t.Errorf("half negative bucket interpolation should be ~50: %v", err) + } + + // The lower bound is exclusive: 2.0 lands on the upper edge of (2^(7/8), 2], so + // (2, 100] excludes it while (2^(7/8), 2] includes it. + edgeReg := prometheus.NewRegistry() + he := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "edge_histogram", + Help: "A native histogram for boundary testing.", + NativeHistogramBucketFactor: 1.1, + }) + edgeReg.MustRegister(he) + he.Observe(2.0) + if err := GatherAndAssertNativeHistogramIntervalCount(edgeReg, "edge_histogram", nil, 2, 100, 0, eps); err != nil { + t.Errorf("lower bound should be exclusive, want 0 in (2, 100]: %v", err) + } + if err := GatherAndAssertNativeHistogramIntervalCount(edgeReg, "edge_histogram", nil, bucketLower, 2, 1, eps); err != nil { + t.Errorf("interval (2^(7/8), 2] should count 1: %v", err) + } +} + +func TestGatherAndAssertNativeHistogramNotAHistogram(t *testing.T) { + reg := prometheus.NewRegistry() + c := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "a_counter", + Help: "A counter, not a histogram.", + }) + reg.MustRegister(c) + c.Inc() + if err := GatherAndAssertNativeHistogramExists(reg, "a_counter", nil); err == nil { + t.Error("expected error when the named metric is not a histogram") + } +} + +func TestGatherAndAssertNativeHistogramZeroObservations(t *testing.T) { + const eps = 1e-9 + + // A native histogram with no observations still exists and reports count 0. + empty := newNativeHistogramRegistry(t) + if err := GatherAndAssertNativeHistogramExists(empty, "test_native_histogram", nil); err != nil { + t.Errorf("empty native histogram should still exist: %v", err) + } + if err := GatherAndAssertNativeHistogramCount(empty, "test_native_histogram", nil, 0); err != nil { + t.Errorf("empty native histogram count should be 0: %v", err) + } + if err := GatherAndAssertNativeHistogramIntervalCount(empty, "test_native_histogram", nil, -100, 100, 0, eps); err != nil { + t.Errorf("empty native histogram interval should be 0: %v", err) + } + + // An observation of exactly 0 lands in the zero bucket and is counted by an + // interval spanning it. + zero := newNativeHistogramRegistry(t, 0) + if err := GatherAndAssertNativeHistogramIntervalCount(zero, "test_native_histogram", nil, -1, 1, 1, eps); err != nil { + t.Errorf("observation at 0 should be counted in (-1, 1]: %v", err) + } +} + +// floatGatherer returns the given metric families verbatim, letting tests feed +// hand-built float native histograms through the public API (client_golang has +// no public float-histogram API to produce them). +type floatGatherer struct{ mfs []*dto.MetricFamily } + +func (g floatGatherer) Gather() ([]*dto.MetricFamily, error) { return g.mfs, nil } + +func TestGatherAndAssertNativeHistogramFloat(t *testing.T) { + const eps = 1e-9 + + // Float native histogram, schema 0: a zero bucket with float count 2 and one + // positive bucket at index 1 covering (1, 2] with absolute float count 3. + // SampleCountFloat, ZeroCountFloat, and PositiveCount exercise the float paths. + g := floatGatherer{mfs: []*dto.MetricFamily{{ + Name: proto.String("float_native_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{{ + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(5), + SampleSum: proto.Float64(7.5), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.5), + ZeroCountFloat: proto.Float64(2), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(1)}, + }, + PositiveCount: []float64{3}, + }, + }}, + }}} + + if err := GatherAndAssertNativeHistogramExists(g, "float_native_histogram", nil); err != nil { + t.Errorf("float histogram should exist: %v", err) + } + if err := GatherAndAssertNativeHistogramCount(g, "float_native_histogram", nil, 5); err != nil { + t.Errorf("float histogram count: %v", err) + } + if err := GatherAndAssertNativeHistogramSum(g, "float_native_histogram", nil, 7.5, eps); err != nil { + t.Errorf("float histogram sum: %v", err) + } + // Whole range counts all 5 observations (2 in the zero bucket, 3 positive). + if err := GatherAndAssertNativeHistogramIntervalCount(g, "float_native_histogram", nil, -100, 100, 5, eps); err != nil { + t.Errorf("float histogram whole-range interval: %v", err) + } + // (1, 2] captures only the positive bucket's 3 observations (exact edges). + if err := GatherAndAssertNativeHistogramIntervalCount(g, "float_native_histogram", nil, 1, 2, 3, eps); err != nil { + t.Errorf("float histogram interval (1, 2]: %v", err) + } +} + +func TestGatherAndAssertNativeHistogramLabels(t *testing.T) { + reg := prometheus.NewRegistry() + hv := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "vec_histogram", + Help: "A native histogram vector.", + NativeHistogramBucketFactor: 1.1, + }, []string{"code"}) + reg.MustRegister(hv) + hv.WithLabelValues("200").Observe(1) + hv.WithLabelValues("200").Observe(2) + hv.WithLabelValues("404").Observe(1) + + if err := GatherAndAssertNativeHistogramCount(reg, "vec_histogram", prometheus.Labels{"code": "200"}, 2); err != nil { + t.Errorf("selecting code=200 should match 2 observations: %v", err) + } + if err := GatherAndAssertNativeHistogramCount(reg, "vec_histogram", prometheus.Labels{"code": "404"}, 1); err != nil { + t.Errorf("selecting code=404 should match 1 observation: %v", err) + } + // Ambiguous: more than one series and no label selector. + if err := GatherAndAssertNativeHistogramExists(reg, "vec_histogram", nil); err == nil { + t.Error("expected ambiguity error when multiple series match") + } + // No series matches the selector. + if err := GatherAndAssertNativeHistogramExists(reg, "vec_histogram", prometheus.Labels{"code": "500"}); err == nil { + t.Error("expected error when no series matches the labels") + } +} + +func TestNativeHistogramUpperBound(t *testing.T) { + const eps = 1e-12 + for _, tc := range []struct { + schema int32 + index int + want float64 + }{ + {schema: 0, index: 0, want: 1}, + {schema: 0, index: 1, want: 2}, + {schema: 0, index: -1, want: 0.5}, + {schema: 0, index: 3, want: 8}, + {schema: 3, index: 0, want: 1}, + {schema: 3, index: 8, want: 2}, // 2^(8/8) + {schema: 3, index: 16, want: 4}, // 2^(16/8) + {schema: 3, index: 1, want: math.Exp2(1.0 / 8.0)}, + {schema: -1, index: 0, want: 1}, + {schema: -1, index: 1, want: 4}, // base 4 + {schema: -1, index: 2, want: 16}, + {schema: -1, index: -1, want: 0.25}, + {schema: -2, index: 1, want: 16}, // base 16 + } { + got := nativeHistogramUpperBound(tc.schema, tc.index) + if math.Abs(got-tc.want) > eps*math.Max(1, math.Abs(tc.want)) { + t.Errorf("nativeHistogramUpperBound(%d, %d) = %v, want %v", tc.schema, tc.index, got, tc.want) + } + } +} + +func TestDecodeSpanIndices(t *testing.T) { + for _, tc := range []struct { + name string + spans []*dto.BucketSpan + want []int + }{ + { + name: "single bucket at origin", + spans: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(1)}, + }, + want: []int{0}, + }, + { + // The canonical client_golang fixture: observing {1,2,3} at schema 3. + name: "gapped single buckets", + spans: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(1)}, + {Offset: proto.Int32(7), Length: proto.Uint32(1)}, + {Offset: proto.Int32(4), Length: proto.Uint32(1)}, + }, + want: []int{0, 8, 13}, + }, + { + name: "multi-bucket spans with gap", + spans: []*dto.BucketSpan{ + {Offset: proto.Int32(2), Length: proto.Uint32(3)}, + {Offset: proto.Int32(5), Length: proto.Uint32(2)}, + }, + want: []int{2, 3, 4, 10, 11}, + }, + { + name: "negative first offset", + spans: []*dto.BucketSpan{ + {Offset: proto.Int32(-3), Length: proto.Uint32(2)}, + }, + want: []int{-3, -2}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := decodeSpanIndices(tc.spans) + if len(got) != len(tc.want) { + t.Fatalf("decodeSpanIndices() = %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("decodeSpanIndices() = %v, want %v", got, tc.want) + } + } + }) + } +} + +// TestDecodeNativeHistogram reconstructs buckets from a histogram produced by the +// real client and checks the bounds and counts. +func TestDecodeNativeHistogram(t *testing.T) { + // Integer native histogram, schema 3, observing 0,1,2,3. + // Expected: zero bucket count 1, positive buckets at indices 0,8,13 each count 1. + h := &dto.Histogram{ + SampleCount: proto.Uint64(4), + SampleSum: proto.Float64(6), + Schema: proto.Int32(3), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(1)}, + {Offset: proto.Int32(7), Length: proto.Uint32(1)}, + {Offset: proto.Int32(4), Length: proto.Uint32(1)}, + }, + PositiveDelta: []int64{1, 0, 0}, + } + + got := decodeNativeHistogram(h) + + // 1 zero bucket + 3 positive buckets, sorted by lower bound ascending. + if len(got) != 4 { + t.Fatalf("decodeNativeHistogram() returned %d buckets, want 4: %+v", len(got), got) + } + // Zero bucket first. + if !got[0].isZero || got[0].count != 1 { + t.Errorf("bucket[0] = %+v, want zero bucket with count 1", got[0]) + } + // Positive buckets: index 0 -> (2^(-1/8), 1], index 8 -> (2^(7/8), 2], index 13. + const eps = 1e-9 + wantPos := []struct{ lower, upper, count float64 }{ + {lower: math.Exp2(-1.0 / 8.0), upper: 1, count: 1}, + {lower: math.Exp2(7.0 / 8.0), upper: 2, count: 1}, + {lower: math.Exp2(12.0 / 8.0), upper: math.Exp2(13.0 / 8.0), count: 1}, + } + for i, w := range wantPos { + b := got[i+1] + if math.Abs(b.lower-w.lower) > eps || math.Abs(b.upper-w.upper) > eps || b.count != w.count { + t.Errorf("positive bucket[%d] = %+v, want {lower:%v upper:%v count:%v}", i, b, w.lower, w.upper, w.count) + } + } + + // Float native histogram with a negative bucket: schema 0, one negative + // bucket at index 1 (covers value range [-2,-1)) with absolute count 5. + hf := &dto.Histogram{ + SampleCountFloat: proto.Float64(5), + SampleSum: proto.Float64(-7), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(1)}, + }, + NegativeCount: []float64{5}, + } + gotf := decodeNativeHistogram(hf) + if len(gotf) != 1 { + t.Fatalf("decodeNativeHistogram(float) returned %d buckets, want 1: %+v", len(gotf), gotf) + } + nb := gotf[0] + if math.Abs(nb.lower+2) > eps || math.Abs(nb.upper+1) > eps || nb.count != 5 { + t.Errorf("negative bucket = %+v, want {lower:-2 upper:-1 count:5}", nb) + } +}