From ae8497940d87bd0f3dc4162b7982932a67c59b85 Mon Sep 17 00:00:00 2001 From: Shivanth Date: Wed, 16 Oct 2024 14:30:59 +0200 Subject: [PATCH] Add native histograms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Shivanth Apply suggestions from code review Co-authored-by: Arthur Silva Sens Signed-off-by: Shivanth Fix references and existing tests Signed-off-by: Shivanth Review comments Signed-off-by: Shivanth fmt Signed-off-by: Shivanth validation Signed-off-by: Shivanth Count validation Signed-off-by: Shivanth Validation Signed-off-by: Shivanth Review comments Signed-off-by: Shivanth Space formatting Signed-off-by: Shivanth Review comments Signed-off-by: Shivanth Rename SyncMaptoMap -> SyncMapToMap Signed-off-by: Shivanth Remove exemplars from parameters for constNativeHistogram function Signed-off-by: Shivanth Update prometheus/histogram.go Co-authored-by: George Krajcsovits Signed-off-by: Shivanth MP Update prometheus/histogram.go Co-authored-by: George Krajcsovits Signed-off-by: Shivanth MP Update prometheus/histogram.go Co-authored-by: George Krajcsovits Signed-off-by: Shivanth MP Update prometheus/histogram.go Co-authored-by: Björn Rabenstein Signed-off-by: Shivanth MP Review comments Signed-off-by: Shivanth Lint fix Signed-off-by: Shivanth --- prometheus/histogram.go | 203 ++++++++++++- prometheus/histogram_test.go | 545 ++++++++++++++++++++++++++++++++++- 2 files changed, 744 insertions(+), 4 deletions(-) diff --git a/prometheus/histogram.go b/prometheus/histogram.go index 6deff565d..1a279035b 100644 --- a/prometheus/histogram.go +++ b/prometheus/histogram.go @@ -14,6 +14,7 @@ package prometheus import ( + "errors" "fmt" "math" "runtime" @@ -28,6 +29,11 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +const ( + nativeHistogramSchemaMaximum = 8 + nativeHistogramSchemaMinimum = -4 +) + // nativeHistogramBounds for the frac of observed values. Only relevant for // schema > 0. The position in the slice is the schema. (0 is never used, just // here for convenience of using the schema directly as the index.) @@ -1460,9 +1466,9 @@ func pickSchema(bucketFactor float64) int32 { floor := math.Floor(math.Log2(math.Log2(bucketFactor))) switch { case floor <= -8: - return 8 + return nativeHistogramSchemaMaximum case floor >= 4: - return -4 + return nativeHistogramSchemaMinimum default: return -int32(floor) } @@ -1851,3 +1857,196 @@ func (n *nativeExemplars) addExemplar(e *dto.Exemplar) { n.exemplars = append(n.exemplars[:nIdx], append([]*dto.Exemplar{e}, append(n.exemplars[nIdx:rIdx], n.exemplars[rIdx+1:]...)...)...) } } + +type constNativeHistogram struct { + desc *Desc + dto.Histogram + labelPairs []*dto.LabelPair +} + +func validateCount(sum float64, count uint64, negativeBuckets, positiveBuckets map[int]int64, zeroBucket uint64) error { + var bucketPopulationSum int64 + for _, v := range positiveBuckets { + bucketPopulationSum += v + } + for _, v := range negativeBuckets { + bucketPopulationSum += v + } + bucketPopulationSum += int64(zeroBucket) + + // If the sum of observations is NaN, the number of observations must be greater or equal to the sum of all bucket counts. + // Otherwise, the number of observations must be equal to the sum of all bucket counts . + + if math.IsNaN(sum) && bucketPopulationSum > int64(count) || + !math.IsNaN(sum) && bucketPopulationSum != int64(count) { + return errors.New("the sum of all bucket populations exceeds the count of observations") + } + return nil +} + +// NewConstNativeHistogram returns a metric representing a Prometheus native histogram with +// fixed values for the count, sum, and positive/negative/zero bucket counts. As those parameters +// cannot be changed, the returned value does not implement the Histogram +// interface (but only the Metric interface). Users of this package will not +// have much use for it in regular operations. However, when implementing custom +// OpenTelemetry Collectors, it is useful as a throw-away metric that is generated on the fly +// to send it to Prometheus in the Collect method. +// +// zeroBucket counts all (positive and negative) +// observations in the zero bucket (with an absolute value less or equal +// the current threshold). +// positiveBuckets and negativeBuckets are separate maps for negative and positive +// observations. The map's value is an int64, counting observations in +// that bucket. The map's key is the +// index of the bucket according to the used +// Schema. Index 0 is for an upper bound of 1 in positive buckets and for a lower bound of -1 in negative buckets. +// NewConstNativeHistogram returns an error if +// - the length of labelValues is not consistent with the variable labels in Desc or if Desc is invalid. +// - the schema passed is not between 8 and -4 +// - the sum of counts in all buckets including the zero bucket does not equal the count if sum is not NaN (or exceeds the count if sum is NaN) +// +// See https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/#exponential-histograms for more details about the conversion from OTel to Prometheus. +func NewConstNativeHistogram( + desc *Desc, + count uint64, + sum float64, + positiveBuckets, negativeBuckets map[int]int64, + zeroBucket uint64, + schema int32, + zeroThreshold float64, + createdTimestamp time.Time, + labelValues ...string, +) (Metric, error) { + if desc.err != nil { + return nil, desc.err + } + if err := validateLabelValues(labelValues, len(desc.variableLabels.names)); err != nil { + return nil, err + } + if schema > nativeHistogramSchemaMaximum || schema < nativeHistogramSchemaMinimum { + return nil, errors.New("invalid native histogram schema") + } + if err := validateCount(sum, count, negativeBuckets, positiveBuckets, zeroBucket); err != nil { + return nil, err + } + + NegativeSpan, NegativeDelta := makeBucketsFromMap(negativeBuckets) + PositiveSpan, PositiveDelta := makeBucketsFromMap(positiveBuckets) + ret := &constNativeHistogram{ + desc: desc, + Histogram: dto.Histogram{ + CreatedTimestamp: timestamppb.New(createdTimestamp), + Schema: &schema, + ZeroThreshold: &zeroThreshold, + SampleCount: &count, + SampleSum: &sum, + + NegativeSpan: NegativeSpan, + NegativeDelta: NegativeDelta, + + PositiveSpan: PositiveSpan, + PositiveDelta: PositiveDelta, + + ZeroCount: proto.Uint64(zeroBucket), + }, + labelPairs: MakeLabelPairs(desc, labelValues), + } + if *ret.ZeroThreshold == 0 && *ret.ZeroCount == 0 && len(ret.PositiveSpan) == 0 && len(ret.NegativeSpan) == 0 { + ret.PositiveSpan = []*dto.BucketSpan{{ + Offset: proto.Int32(0), + Length: proto.Uint32(0), + }} + } + return ret, nil +} + +// MustNewConstNativeHistogram is a version of NewConstNativeHistogram that panics where +// NewConstNativeHistogram would have returned an error. +func MustNewConstNativeHistogram( + desc *Desc, + count uint64, + sum float64, + positiveBuckets, negativeBuckets map[int]int64, + zeroBucket uint64, + nativeHistogramSchema int32, + nativeHistogramZeroThreshold float64, + createdTimestamp time.Time, + labelValues ...string, +) Metric { + nativehistogram, err := NewConstNativeHistogram(desc, + count, + sum, + positiveBuckets, + negativeBuckets, + zeroBucket, + nativeHistogramSchema, + nativeHistogramZeroThreshold, + createdTimestamp, + labelValues...) + if err != nil { + panic(err) + } + return nativehistogram +} + +func (h *constNativeHistogram) Desc() *Desc { + return h.desc +} + +func (h *constNativeHistogram) Write(out *dto.Metric) error { + out.Histogram = &h.Histogram + out.Label = h.labelPairs + return nil +} + +func makeBucketsFromMap(buckets map[int]int64) ([]*dto.BucketSpan, []int64) { + if len(buckets) == 0 { + return nil, nil + } + var ii []int + for k := range buckets { + ii = append(ii, k) + } + sort.Ints(ii) + + var ( + spans []*dto.BucketSpan + deltas []int64 + prevCount int64 + nextI int + ) + + appendDelta := func(count int64) { + *spans[len(spans)-1].Length++ + deltas = append(deltas, count-prevCount) + prevCount = count + } + + for n, i := range ii { + count := buckets[i] + // Multiple spans with only small gaps in between are probably + // encoded more efficiently as one larger span with a few empty + // buckets. Needs some research to find the sweet spot. For now, + // we assume that gaps of one or two buckets should not create + // a new span. + iDelta := int32(i - nextI) + if n == 0 || iDelta > 2 { + // We have to create a new span, either because we are + // at the very beginning, or because we have found a gap + // of more than two buckets. + spans = append(spans, &dto.BucketSpan{ + Offset: proto.Int32(iDelta), + Length: proto.Uint32(0), + }) + } else { + // We have found a small gap (or no gap at all). + // Insert empty buckets as needed. + for j := int32(0); j < iDelta; j++ { + appendDelta(0) + } + } + appendDelta(count) + nextI = i + 1 + } + return spans, deltas +} diff --git a/prometheus/histogram_test.go b/prometheus/histogram_test.go index 9ed5971c6..f3ee917ea 100644 --- a/prometheus/histogram_test.go +++ b/prometheus/histogram_test.go @@ -25,11 +25,11 @@ import ( "testing/quick" "time" - "github.com/prometheus/client_golang/prometheus/internal" - dto "github.com/prometheus/client_model/go" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/prometheus/client_golang/prometheus/internal" ) func benchmarkHistogramObserve(w int, b *testing.B) { @@ -1543,3 +1543,544 @@ func TestFindBucket(t *testing.T) { } } } + +func syncMapToMap(syncmap *sync.Map) (m map[int]int64) { + m = map[int]int64{} + syncmap.Range(func(key, value any) bool { + m[key.(int)] = *value.(*int64) + return true + }) + return m +} + +func TestConstNativeHistogram(t *testing.T) { + now := time.Now() + + scenarios := []struct { + name string + observations []float64 // With simulated interval of 1m. + factor float64 + zeroThreshold float64 + maxBuckets uint32 + minResetDuration time.Duration + maxZeroThreshold float64 + want *dto.Histogram + }{ + { + name: "no observations", + factor: 1.1, + want: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(3), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "no observations and zero threshold of zero resulting in no-op span", + factor: 1.1, + zeroThreshold: NativeHistogramZeroThresholdZero, + want: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(3), + ZeroThreshold: proto.Float64(0), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(0)}, + }, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "factor 1.1 results in schema 3", + observations: []float64{0, 1, 2, 3}, + factor: 1.1, + want: &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}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "factor 1.2 results in schema 2", + observations: []float64{0, 1, 1.2, 1.4, 1.8, 2}, + factor: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(6), + SampleSum: proto.Float64(7.4), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + PositiveDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "factor 4 results in schema -1", + observations: []float64{ + 0.0156251, 0.0625, // Bucket -2: (0.015625, 0.0625) + 0.1, 0.25, // Bucket -1: (0.0625, 0.25] + 0.5, 1, // Bucket 0: (0.25, 1] + 1.5, 2, 3, 3.5, // Bucket 1: (1, 4] + 5, 6, 7, // Bucket 2: (4, 16] + 33.33, // Bucket 3: (16, 64] + }, + factor: 4, + want: &dto.Histogram{ + SampleCount: proto.Uint64(14), + SampleSum: proto.Float64(63.2581251), + Schema: proto.Int32(-1), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(-2), Length: proto.Uint32(6)}, + }, + PositiveDelta: []int64{2, 0, 0, 2, -1, -2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "factor 17 results in schema -2", + observations: []float64{ + 0.0156251, 0.0625, // Bucket -1: (0.015625, 0.0625] + 0.1, 0.25, 0.5, 1, // Bucket 0: (0.0625, 1] + 1.5, 2, 3, 3.5, 5, 6, 7, // Bucket 1: (1, 16] + 33.33, // Bucket 2: (16, 256] + }, + factor: 17, + want: &dto.Histogram{ + SampleCount: proto.Uint64(14), + SampleSum: proto.Float64(63.2581251), + Schema: proto.Int32(-2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(-1), Length: proto.Uint32(4)}, + }, + PositiveDelta: []int64{2, 2, 3, -6}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "negative buckets", + observations: []float64{0, -1, -1.2, -1.4, -1.8, -2}, + factor: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(6), + SampleSum: proto.Float64(-7.4), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + NegativeDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "negative and positive buckets", + observations: []float64{0, -1, -1.2, -1.4, -1.8, -2, 1, 1.2, 1.4, 1.8, 2}, + factor: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(11), + SampleSum: proto.Float64(0), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + NegativeDelta: []int64{1, -1, 2, -2, 2}, + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + PositiveDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "wide zero bucket", + observations: []float64{0, -1, -1.2, -1.4, -1.8, -2, 1, 1.2, 1.4, 1.8, 2}, + factor: 1.2, + zeroThreshold: 1.4, + want: &dto.Histogram{ + SampleCount: proto.Uint64(11), + SampleSum: proto.Float64(0), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(1.4), + ZeroCount: proto.Uint64(7), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(4), Length: proto.Uint32(1)}, + }, + NegativeDelta: []int64{2}, + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(4), Length: proto.Uint32(1)}, + }, + PositiveDelta: []int64{2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "NaN observation", + observations: []float64{0, 1, 1.2, 1.4, 1.8, 2, math.NaN()}, + factor: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(7), + SampleSum: proto.Float64(math.NaN()), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + PositiveDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "+Inf observation", + observations: []float64{0, 1, 1.2, 1.4, 1.8, 2, math.Inf(+1)}, + factor: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(7), + SampleSum: proto.Float64(math.Inf(+1)), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + {Offset: proto.Int32(4092), Length: proto.Uint32(1)}, + }, + PositiveDelta: []int64{1, -1, 2, -2, 2, -1}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "-Inf observation", + observations: []float64{0, 1, 1.2, 1.4, 1.8, 2, math.Inf(-1)}, + factor: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(7), + SampleSum: proto.Float64(math.Inf(-1)), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(4097), Length: proto.Uint32(1)}, + }, + NegativeDelta: []int64{1}, + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + PositiveDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "limited buckets but nothing triggered", + observations: []float64{0, 1, 1.2, 1.4, 1.8, 2}, + factor: 1.2, + maxBuckets: 4, + want: &dto.Histogram{ + SampleCount: proto.Uint64(6), + SampleSum: proto.Float64(7.4), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + PositiveDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by halving resolution", + observations: []float64{0, 1, 1.1, 1.2, 1.4, 1.8, 2, 3}, + factor: 1.2, + maxBuckets: 4, + want: &dto.Histogram{ + SampleCount: proto.Uint64(8), + SampleSum: proto.Float64(11.5), + Schema: proto.Int32(1), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + PositiveDelta: []int64{1, 2, -1, -2, 1}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by widening the zero bucket", + observations: []float64{0, 1, 1.1, 1.2, 1.4, 1.8, 2, 3}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(8), + SampleSum: proto.Float64(11.5), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(1), + ZeroCount: proto.Uint64(2), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(7)}, + }, + PositiveDelta: []int64{1, 1, -2, 2, -2, 0, 1}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by widening the zero bucket twice", + observations: []float64{0, 1, 1.1, 1.2, 1.4, 1.8, 2, 3, 4}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(9), + SampleSum: proto.Float64(15.5), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(1.189207115002721), + ZeroCount: proto.Uint64(3), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(2), Length: proto.Uint32(7)}, + }, + PositiveDelta: []int64{2, -2, 2, -2, 0, 1, 0}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by reset", + observations: []float64{0, 1, 1.1, 1.2, 1.4, 1.8, 2, 3, 4}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + minResetDuration: 5 * time.Minute, + want: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(7), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(7), Length: proto.Uint32(2)}, + }, + PositiveDelta: []int64{1, 0}, + CreatedTimestamp: timestamppb.New(now.Add(8 * time.Minute)), // We expect reset to happen after 8 observations. + }, + }, + { + name: "limited buckets but nothing triggered, negative observations", + observations: []float64{0, -1, -1.2, -1.4, -1.8, -2}, + factor: 1.2, + maxBuckets: 4, + want: &dto.Histogram{ + SampleCount: proto.Uint64(6), + SampleSum: proto.Float64(-7.4), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + NegativeDelta: []int64{1, -1, 2, -2, 2}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by halving resolution, negative observations", + observations: []float64{0, -1, -1.1, -1.2, -1.4, -1.8, -2, -3}, + factor: 1.2, + maxBuckets: 4, + want: &dto.Histogram{ + SampleCount: proto.Uint64(8), + SampleSum: proto.Float64(-11.5), + Schema: proto.Int32(1), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(1), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(5)}, + }, + NegativeDelta: []int64{1, 2, -1, -2, 1}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by widening the zero bucket, negative observations", + observations: []float64{0, -1, -1.1, -1.2, -1.4, -1.8, -2, -3}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(8), + SampleSum: proto.Float64(-11.5), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(1), + ZeroCount: proto.Uint64(2), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(7)}, + }, + NegativeDelta: []int64{1, 1, -2, 2, -2, 0, 1}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by widening the zero bucket twice, negative observations", + observations: []float64{0, -1, -1.1, -1.2, -1.4, -1.8, -2, -3, -4}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + want: &dto.Histogram{ + SampleCount: proto.Uint64(9), + SampleSum: proto.Float64(-15.5), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(1.189207115002721), + ZeroCount: proto.Uint64(3), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(2), Length: proto.Uint32(7)}, + }, + NegativeDelta: []int64{2, -2, 2, -2, 0, 1, 0}, + CreatedTimestamp: timestamppb.New(now), + }, + }, + { + name: "buckets limited by reset, negative observations", + observations: []float64{0, -1, -1.1, -1.2, -1.4, -1.8, -2, -3, -4}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + minResetDuration: 5 * time.Minute, + want: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(-7), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(7), Length: proto.Uint32(2)}, + }, + NegativeDelta: []int64{1, 0}, + CreatedTimestamp: timestamppb.New(now.Add(8 * time.Minute)), // We expect reset to happen after 8 observations. + }, + }, + { + name: "buckets limited by halving resolution, then reset", + observations: []float64{0, 1, 1.1, 1.2, 1.4, 1.8, 2, 5, 5.1, 3, 4}, + factor: 1.2, + maxBuckets: 4, + minResetDuration: 9 * time.Minute, + want: &dto.Histogram{ + SampleCount: proto.Uint64(3), + SampleSum: proto.Float64(12.1), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(7), Length: proto.Uint32(4)}, + }, + PositiveDelta: []int64{1, 0, -1, 1}, + CreatedTimestamp: timestamppb.New(now.Add(9 * time.Minute)), // We expect reset to happen after 8 minutes. + }, + }, + { + name: "buckets limited by widening the zero bucket, then reset", + observations: []float64{0, 1, 1.1, 1.2, 1.4, 1.8, 2, 5, 5.1, 3, 4}, + factor: 1.2, + maxBuckets: 4, + maxZeroThreshold: 1.2, + minResetDuration: 9 * time.Minute, + want: &dto.Histogram{ + SampleCount: proto.Uint64(3), + SampleSum: proto.Float64(12.1), + Schema: proto.Int32(2), + ZeroThreshold: proto.Float64(2.938735877055719e-39), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(7), Length: proto.Uint32(4)}, + }, + PositiveDelta: []int64{1, 0, -1, 1}, + CreatedTimestamp: timestamppb.New(now.Add(9 * time.Minute)), // We expect reset to happen after 8 minutes. + }, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + var ( + ts = now + funcToCall func() + whenToCall time.Duration + ) + + his := NewHistogram(HistogramOpts{ + Name: "name", + Help: "help", + NativeHistogramBucketFactor: s.factor, + NativeHistogramZeroThreshold: s.zeroThreshold, + NativeHistogramMaxBucketNumber: s.maxBuckets, + NativeHistogramMinResetDuration: s.minResetDuration, + NativeHistogramMaxZeroThreshold: s.maxZeroThreshold, + now: func() time.Time { return ts }, + afterFunc: func(d time.Duration, f func()) *time.Timer { + funcToCall = f + whenToCall = d + return nil + }, + }) + + ts = ts.Add(time.Minute) + for _, o := range s.observations { + his.Observe(o) + ts = ts.Add(time.Minute) + whenToCall -= time.Minute + if funcToCall != nil && whenToCall <= 0 { + funcToCall() + funcToCall = nil + } + } + _his := his.(*histogram) + n := atomic.LoadUint64(&_his.countAndHotIdx) + hotIdx := n >> 63 + cold := _his.counts[hotIdx] + consthist, err := NewConstNativeHistogram(_his.Desc(), + cold.count, + math.Float64frombits(cold.sumBits), + syncMapToMap(&cold.nativeHistogramBucketsPositive), + syncMapToMap(&cold.nativeHistogramBucketsNegative), + cold.nativeHistogramZeroBucket, + cold.nativeHistogramSchema, + math.Float64frombits(cold.nativeHistogramZeroThresholdBits), + _his.lastResetTime, + ) + if err != nil { + t.Fatal("unexpected error writing metric", err) + } + m2 := &dto.Metric{} + + if err := consthist.Write(m2); err != nil { + t.Fatal("unexpected error writing metric", err) + } + got := m2.Histogram + if !proto.Equal(s.want, got) { + t.Errorf("want histogram %q, got %q", s.want, got) + } + }) + } +}