Skip to content

Commit 83a0ed6

Browse files
goodhistogram: add HistogramVec for labeled histogram collection
Add HistogramVec, a label-partitioned collection of Histograms that implements prometheus.Collector. WithLabelValues returns a *Histogram for direct int64 recording on the hot path; the vec handles Prometheus registration and scrape-time export with label pairs. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent 2162c13 commit 83a0ed6

3 files changed

Lines changed: 289 additions & 0 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ sparse fields (schema, spans, deltas). Because the internal bucket
100100
indices are Prometheus bucket keys by construction, export is a direct
101101
copy with no remapping.
102102

103+
### Labeled histograms (HistogramVec)
104+
105+
For multi-dimensional histograms partitioned by label values, use
106+
`HistogramVec`. It implements `prometheus.Collector` so the entire
107+
vec can be registered with a registry.
108+
109+
```go
110+
vec := goodhistogram.NewHistogramVec(
111+
goodhistogram.Params{Lo: 500, Hi: 60e9, ErrorBound: 0.10},
112+
"request_duration_ns", "Request duration in nanoseconds",
113+
[]string{"method", "path"},
114+
)
115+
prometheus.MustRegister(vec)
116+
117+
// Hot path — WithLabelValues returns a *Histogram for direct recording.
118+
vec.WithLabelValues("GET", "/api").Record(durationNs)
119+
```
120+
103121
### Export to Prometheus proto
104122

105123
If you need the proto directly (e.g. for remote write or custom

vec.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2026 The Cockroach Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
package goodhistogram
10+
11+
import (
12+
"fmt"
13+
"strings"
14+
"sync"
15+
16+
"github.com/prometheus/client_golang/prometheus"
17+
prometheusgo "github.com/prometheus/client_model/go"
18+
)
19+
20+
// HistogramVec is a collection of Histograms partitioned by label values.
21+
// It implements prometheus.Collector so the entire vec can be registered
22+
// with a Prometheus registry. Recording is done on the individual
23+
// *Histogram returned by WithLabelValues.
24+
type HistogramVec struct {
25+
params Params
26+
desc *prometheus.Desc
27+
labelNames []string
28+
29+
mu sync.RWMutex
30+
histograms map[string]*labeledHistogram
31+
}
32+
33+
type labeledHistogram struct {
34+
h *Histogram
35+
labelPairs []*prometheusgo.LabelPair
36+
}
37+
38+
// NewHistogramVec creates a new HistogramVec. All child histograms share the
39+
// same Params. The desc is created internally from name, help, and labelNames.
40+
func NewHistogramVec(p Params, name, help string, labelNames []string) *HistogramVec {
41+
return &HistogramVec{
42+
params: p,
43+
desc: prometheus.NewDesc(name, help, labelNames, nil),
44+
labelNames: labelNames,
45+
histograms: make(map[string]*labeledHistogram),
46+
}
47+
}
48+
49+
// WithLabelValues returns the Histogram for the given label values,
50+
// creating it if it doesn't exist. Panics if the number of values
51+
// doesn't match the number of label names.
52+
func (v *HistogramVec) WithLabelValues(lvs ...string) *Histogram {
53+
if len(lvs) != len(v.labelNames) {
54+
panic(fmt.Sprintf(
55+
"goodhistogram: expected %d label values, got %d",
56+
len(v.labelNames), len(lvs),
57+
))
58+
}
59+
key := strings.Join(lvs, "\x00")
60+
61+
v.mu.RLock()
62+
if lh, ok := v.histograms[key]; ok {
63+
v.mu.RUnlock()
64+
return lh.h
65+
}
66+
v.mu.RUnlock()
67+
68+
v.mu.Lock()
69+
defer v.mu.Unlock()
70+
if lh, ok := v.histograms[key]; ok {
71+
return lh.h
72+
}
73+
h := New(v.params)
74+
v.histograms[key] = &labeledHistogram{
75+
h: h,
76+
labelPairs: makeLabelPairs(v.labelNames, lvs),
77+
}
78+
return h
79+
}
80+
81+
// DeleteLabelValues removes the Histogram for the given label values.
82+
// Returns true if the entry existed.
83+
func (v *HistogramVec) DeleteLabelValues(lvs ...string) bool {
84+
key := strings.Join(lvs, "\x00")
85+
v.mu.Lock()
86+
defer v.mu.Unlock()
87+
_, ok := v.histograms[key]
88+
delete(v.histograms, key)
89+
return ok
90+
}
91+
92+
// Reset removes all child histograms.
93+
func (v *HistogramVec) Reset() {
94+
v.mu.Lock()
95+
defer v.mu.Unlock()
96+
v.histograms = make(map[string]*labeledHistogram)
97+
}
98+
99+
// Describe implements prometheus.Collector.
100+
func (v *HistogramVec) Describe(ch chan<- *prometheus.Desc) {
101+
ch <- v.desc
102+
}
103+
104+
// Collect implements prometheus.Collector.
105+
func (v *HistogramVec) Collect(ch chan<- prometheus.Metric) {
106+
v.mu.RLock()
107+
defer v.mu.RUnlock()
108+
for _, lh := range v.histograms {
109+
ch <- &histogramMetric{
110+
desc: v.desc,
111+
h: lh.h,
112+
labelPairs: lh.labelPairs,
113+
}
114+
}
115+
}
116+
117+
// histogramMetric implements prometheus.Metric for a single labeled histogram.
118+
type histogramMetric struct {
119+
desc *prometheus.Desc
120+
h *Histogram
121+
labelPairs []*prometheusgo.LabelPair
122+
}
123+
124+
func (m *histogramMetric) Desc() *prometheus.Desc { return m.desc }
125+
126+
func (m *histogramMetric) Write(out *prometheusgo.Metric) error {
127+
snap := m.h.Snapshot()
128+
out.Histogram = snap.ToPrometheusHistogram()
129+
out.Label = m.labelPairs
130+
return nil
131+
}
132+
133+
func makeLabelPairs(names, values []string) []*prometheusgo.LabelPair {
134+
pairs := make([]*prometheusgo.LabelPair, len(names))
135+
for i := range names {
136+
n := names[i]
137+
v := values[i]
138+
pairs[i] = &prometheusgo.LabelPair{Name: &n, Value: &v}
139+
}
140+
return pairs
141+
}

vec_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright 2026 The Cockroach Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
package goodhistogram
10+
11+
import (
12+
"sort"
13+
"sync"
14+
"testing"
15+
16+
"github.com/prometheus/client_golang/prometheus"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestHistogramVecGather(t *testing.T) {
21+
vec := NewHistogramVec(
22+
Params{Lo: 100, Hi: 1e9},
23+
"request_duration_ns", "Request duration", []string{"method", "path"},
24+
)
25+
reg := prometheus.NewRegistry()
26+
require.NoError(t, reg.Register(vec))
27+
28+
vec.WithLabelValues("GET", "/api").Record(1000)
29+
vec.WithLabelValues("GET", "/api").Record(2000)
30+
vec.WithLabelValues("POST", "/api").Record(5000)
31+
32+
families, err := reg.Gather()
33+
require.NoError(t, err)
34+
require.Len(t, families, 1)
35+
require.Equal(t, "request_duration_ns", *families[0].Name)
36+
37+
metrics := families[0].Metric
38+
require.Len(t, metrics, 2)
39+
40+
// Sort by first label value for deterministic assertions.
41+
sort.Slice(metrics, func(i, j int) bool {
42+
return *metrics[i].Label[0].Value < *metrics[j].Label[0].Value
43+
})
44+
45+
// GET /api: 2 observations, sum = 3000
46+
require.Equal(t, "method", *metrics[0].Label[0].Name)
47+
require.Equal(t, "GET", *metrics[0].Label[0].Value)
48+
require.Equal(t, uint64(2), *metrics[0].Histogram.SampleCount)
49+
require.Equal(t, float64(3000), *metrics[0].Histogram.SampleSum)
50+
51+
// POST /api: 1 observation, sum = 5000
52+
require.Equal(t, "POST", *metrics[1].Label[0].Value)
53+
require.Equal(t, uint64(1), *metrics[1].Histogram.SampleCount)
54+
require.Equal(t, float64(5000), *metrics[1].Histogram.SampleSum)
55+
}
56+
57+
func TestHistogramVecSamePointer(t *testing.T) {
58+
vec := NewHistogramVec(
59+
Params{Lo: 100, Hi: 1e9},
60+
"test", "test", []string{"x"},
61+
)
62+
h1 := vec.WithLabelValues("a")
63+
h2 := vec.WithLabelValues("a")
64+
require.True(t, h1 == h2, "WithLabelValues should return the same *Histogram")
65+
}
66+
67+
func TestHistogramVecWrongLabelCountPanics(t *testing.T) {
68+
vec := NewHistogramVec(
69+
Params{Lo: 100, Hi: 1e9},
70+
"test", "test", []string{"method", "path"},
71+
)
72+
require.Panics(t, func() { vec.WithLabelValues("GET") })
73+
}
74+
75+
func TestHistogramVecDeleteAndReset(t *testing.T) {
76+
vec := NewHistogramVec(
77+
Params{Lo: 100, Hi: 1e9},
78+
"test", "test", []string{"x"},
79+
)
80+
reg := prometheus.NewRegistry()
81+
require.NoError(t, reg.Register(vec))
82+
83+
vec.WithLabelValues("a").Record(100)
84+
vec.WithLabelValues("b").Record(200)
85+
86+
require.True(t, vec.DeleteLabelValues("a"))
87+
require.False(t, vec.DeleteLabelValues("a"))
88+
89+
families, err := reg.Gather()
90+
require.NoError(t, err)
91+
require.Len(t, families[0].Metric, 1)
92+
require.Equal(t, "b", *families[0].Metric[0].Label[0].Value)
93+
94+
vec.Reset()
95+
families, err = reg.Gather()
96+
require.NoError(t, err)
97+
require.Empty(t, families)
98+
}
99+
100+
func TestHistogramVecConcurrent(t *testing.T) {
101+
vec := NewHistogramVec(
102+
Params{Lo: 100, Hi: 1e9},
103+
"test", "test", []string{"x"},
104+
)
105+
106+
var wg sync.WaitGroup
107+
labels := []string{"a", "b", "c", "d"}
108+
for _, l := range labels {
109+
for g := 0; g < 10; g++ {
110+
wg.Add(1)
111+
go func(label string) {
112+
defer wg.Done()
113+
h := vec.WithLabelValues(label)
114+
for i := 0; i < 1000; i++ {
115+
h.Record(500)
116+
}
117+
}(l)
118+
}
119+
}
120+
wg.Wait()
121+
122+
reg := prometheus.NewRegistry()
123+
require.NoError(t, reg.Register(vec))
124+
families, err := reg.Gather()
125+
require.NoError(t, err)
126+
require.Len(t, families[0].Metric, 4)
127+
for _, m := range families[0].Metric {
128+
require.Equal(t, uint64(10000), *m.Histogram.SampleCount)
129+
}
130+
}

0 commit comments

Comments
 (0)