Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,26 @@ mean := snap.Mean()
count, sum := snap.Total()
```

Snapshots include their bucket layout and can be serialized directly.
Comment thread
jasonlmfong marked this conversation as resolved.

```go
data, err := json.Marshal(snap)

var restored goodhistogram.Snapshot
err = json.Unmarshal(data, &restored)
```

JSON decoding validates the snapshot's layout and counts. For other encodings,
call `Validate()` after decoding. The zero value is an unset snapshot: it
round-trips through JSON, returns zero quantiles and a NaN mean, and exports zero
count and sum without a bucket layout. JSON `null` leaves the destination unchanged.

An unset snapshot is the identity for `Merge`, so either `var acc Snapshot` or
`var acc ExactSnapshot` can start an accumulator that adopts the first configured
snapshot's layout. `Sub` also accepts an unset operand to subtract, but an unset
receiver cannot subtract a configured snapshot. Otherwise, `Merge` and `Sub`
require matching schemas, bounds, and bucket counts; they panic on a mismatch.

### Register with Prometheus

A histogram can be registered with a Prometheus registry via
Expand Down
28 changes: 14 additions & 14 deletions exact_minmax.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,13 @@ func (h *WithExactMinMax) Summary() Summary {
// only when Summary().Count > 0. Subtraction is not supported because exact
// extremes cannot be recovered by subtracting snapshots.
type ExactSnapshot struct {
snapshot Snapshot
Snapshot Snapshot
Min int64
Max int64
}

func (h *WithExactMinMax) Snapshot() ExactSnapshot {
es := ExactSnapshot{snapshot: h.histogram.Snapshot()}
es := ExactSnapshot{Snapshot: h.histogram.Snapshot()}
if mn, mx := h.minVal.Load(), h.maxVal.Load(); mn <= mx {
es.Min, es.Max = mn, mx
}
Expand All @@ -114,7 +114,7 @@ func (h *WithExactMinMax) Snapshot() ExactSnapshot {
// ValueAtQuantile returns the exact min at q<=0 and max at q>=1 (which may fall
// outside [lo, hi]); interior quantiles use the base estimate.
func (s *ExactSnapshot) ValueAtQuantile(q float64) float64 {
if s.snapshot.TotalCount == 0 {
if s.Snapshot.TotalCount == 0 {
return 0
}
if q <= 0 {
Expand All @@ -123,12 +123,12 @@ func (s *ExactSnapshot) ValueAtQuantile(q float64) float64 {
if q >= 1 {
return float64(s.Max)
}
return s.snapshot.ValueAtQuantile(q)
return s.Snapshot.ValueAtQuantile(q)
}

func (s *ExactSnapshot) ValuesAtQuantiles(qs []float64) []float64 {
res := s.snapshot.ValuesAtQuantiles(qs)
if s.snapshot.TotalCount == 0 {
res := s.Snapshot.ValuesAtQuantiles(qs)
if s.Snapshot.TotalCount == 0 {
return res
}
for i, q := range qs {
Expand All @@ -143,11 +143,11 @@ func (s *ExactSnapshot) ValuesAtQuantiles(qs []float64) []float64 {
}

func (s *ExactSnapshot) Merge(other *ExactSnapshot) ExactSnapshot {
m := ExactSnapshot{snapshot: s.snapshot.Merge(&other.snapshot)}
m := ExactSnapshot{Snapshot: s.Snapshot.Merge(&other.Snapshot)}
switch {
case s.snapshot.TotalCount == 0:
case s.Snapshot.TotalCount == 0:
m.Min, m.Max = other.Min, other.Max
case other.snapshot.TotalCount == 0:
case other.Snapshot.TotalCount == 0:
m.Min, m.Max = s.Min, s.Max
default:
m.Min = min(s.Min, other.Min)
Expand All @@ -158,26 +158,26 @@ func (s *ExactSnapshot) Merge(other *ExactSnapshot) ExactSnapshot {

// Summary returns the exact count, sum, and extremes in the snapshot.
func (s *ExactSnapshot) Summary() Summary {
return Summary{Count: s.snapshot.TotalCount, Sum: s.snapshot.TotalSum, Min: s.Min, Max: s.Max}
return Summary{Count: s.Snapshot.TotalCount, Sum: s.Snapshot.TotalSum, Min: s.Min, Max: s.Max}
}

// Schema returns the Prometheus native histogram schema (0–8).
func (s *ExactSnapshot) Schema() int32 {
return s.snapshot.Schema()
return s.Snapshot.Schema()
}

// Mean returns the arithmetic mean, or zero for an empty snapshot.
func (s *ExactSnapshot) Mean() float64 {
return s.snapshot.Mean()
return s.Snapshot.Mean()
}

// Total returns the observation count and sum.
func (s *ExactSnapshot) Total() (int64, float64) {
return s.snapshot.Total()
return s.Snapshot.Total()
}

// ToPrometheusHistogram exports the bucket counts, sum, and count. Exact
// extremes are not represented in the Prometheus histogram format.
func (s *ExactSnapshot) ToPrometheusHistogram() *prometheusgo.Histogram {
return s.snapshot.ToPrometheusHistogram()
return s.Snapshot.ToPrometheusHistogram()
}
19 changes: 18 additions & 1 deletion exact_minmax_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package goodhistogram

import (
"encoding/json"
"math/rand"
"sync"
"testing"
Expand Down Expand Up @@ -82,7 +83,7 @@ func TestExactMinMaxQuantileEndpoints(t *testing.T) {

// Interior quantiles must delegate unchanged to the base estimate.
for _, q := range []float64{0.25, 0.5, 0.75, 0.99} {
require.Equalf(t, snap.snapshot.ValueAtQuantile(q), snap.ValueAtQuantile(q),
require.Equalf(t, snap.Snapshot.ValueAtQuantile(q), snap.ValueAtQuantile(q),
"interior q=%.2f must match base estimate", q)
}
}
Expand Down Expand Up @@ -205,6 +206,22 @@ func TestExactMinMaxSnapshotMethods(t *testing.T) {
require.Equal(t, h.Schema(), ph.GetSchema())
}

func TestExactSnapshotSerialization(t *testing.T) {
h := NewWithExactMinMax(Params{Lo: 10, Hi: 100, ErrorBound: 0.5})
for _, value := range []int64{5, 25, 50, 200} {
h.Record(value)
}
original := h.Snapshot()

encoded, err := json.Marshal(original)
require.NoError(t, err)
var decoded ExactSnapshot
require.NoError(t, json.Unmarshal(encoded, &decoded))
require.Equal(t, original, decoded)
require.Equal(t, original.ValuesAtQuantiles([]float64{0, 0.5, 1}),
decoded.ValuesAtQuantiles([]float64{0, 0.5, 1}))
}

func TestExactMinMaxAllocations(t *testing.T) {
p := Params{Lo: 1, Hi: 1000, ErrorBound: 0.05}
// AllocsPerRun warms the shared config cache before measuring. Retain
Expand Down
12 changes: 9 additions & 3 deletions export.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ import (
// ToPrometheusHistogram converts a Snapshot to a prometheusgo.Histogram,
// populating both conventional bucket fields (for backward compatibility) and
// native histogram sparse fields (for efficient Prometheus scraping).
// An unset snapshot exports zero count and sum without a bucket layout.
func (s *Snapshot) ToPrometheusHistogram() *prometheusgo.Histogram {
h := &prometheusgo.Histogram{}

sampleCount := s.TotalCount
sampleSum := float64(s.TotalSum)
h.SampleCount = &sampleCount
h.SampleSum = &sampleSum
if s.isUnset() {
return h
}

// Conventional buckets: cumulative counts with upper bounds.
h.Bucket = s.conventionalBuckets()
Expand All @@ -42,13 +46,14 @@ func (s *Snapshot) ToPrometheusHistogram() *prometheusgo.Histogram {
// +Inf bucket has CumulativeCount == SampleCount, as required by the
// Prometheus exposition format.
func (s *Snapshot) conventionalBuckets() []*prometheusgo.Bucket {
cfg := s.config()
buckets := make([]*prometheusgo.Bucket, 0, len(s.Counts)+1)
// Zeros and underflow values are below all bucket upper bounds,
// so they contribute to every bucket's cumulative count.
cumCount := s.ZeroCount + s.Underflow
for i, c := range s.Counts {
cumCount += c
ub := s.cfg.boundaries[i+1]
ub := cfg.boundaries[i+1]
cc := cumCount
buckets = append(buckets, &prometheusgo.Bucket{
CumulativeCount: &cc,
Expand All @@ -72,7 +77,8 @@ func (s *Snapshot) conventionalBuckets() []*prometheusgo.Bucket {
// the mapping from internal indices to Prometheus bucket keys is a simple
// offset addition: promKey = internalIndex + config.minKey.
func (s *Snapshot) populateNativeFields(h *prometheusgo.Histogram) {
schema := s.cfg.schema
cfg := s.config()
schema := cfg.schema
h.Schema = &schema

// Zero bucket: values at or below zero.
Expand All @@ -99,7 +105,7 @@ func (s *Snapshot) populateNativeFields(h *prometheusgo.Histogram) {
var offset int32
if len(spans) == 0 {
// First span: offset is the absolute Prometheus bucket key.
offset = int32(s.cfg.minKey + i)
offset = int32(cfg.minKey + i)
} else {
// Subsequent spans: offset is the gap since the previous span ended.
offset = int32(gapSinceLastSpan)
Expand Down
Loading
Loading