Skip to content

Commit d1c7648

Browse files
committed
feat: implement prometheus histograms
cf #28 test: add basic prometheus histogram test case
1 parent 704aa40 commit d1c7648

File tree

3 files changed

+280
-0
lines changed

3 files changed

+280
-0
lines changed

prometheus_histogram.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package metrics
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"math"
7+
"sync"
8+
)
9+
10+
var defaultUpperBounds = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
11+
12+
// Prometheus Histogram is a histogram for non-negative values with pre-defined buckets
13+
//
14+
// Each bucket contains a counter for values in the given range.
15+
// Each bucket is exposed via the following metric:
16+
//
17+
// <metric_name>_bucket{<optional_tags>,le="upper_bound"} <counter>
18+
//
19+
// Where:
20+
//
21+
// - <metric_name> is the metric name passed to NewHistogram
22+
// - <optional_tags> is optional tags for the <metric_name>, which are passed to NewHistogram
23+
// - <upper_bound> - upper bound of the current bucket. all samples <= upper_bound are in that bucket
24+
// - <counter> - the number of hits to the given bucket during Update* calls
25+
//
26+
// Zero histogram is usable.
27+
type PrometheusHistogram struct {
28+
// Mu gurantees synchronous update for all the counters and sum.
29+
//
30+
// Do not use sync.RWMutex, since it has zero sense from performance PoV.
31+
// It only complicates the code.
32+
mu sync.Mutex
33+
34+
upperBounds []float64
35+
buckets []uint64
36+
37+
// count is the counter for all observations on this histogram
38+
count uint64
39+
40+
// sum is the sum of all the values put into Histogram
41+
sum float64
42+
}
43+
44+
// Reset resets the given histogram.
45+
func (h *PrometheusHistogram) Reset() {
46+
h.mu.Lock()
47+
for i := range h.buckets {
48+
h.buckets[i] = 0
49+
}
50+
51+
h.sum = 0
52+
h.count = 0
53+
h.mu.Unlock()
54+
}
55+
56+
// Update updates h with v.
57+
//
58+
// Negative values and NaNs are ignored.
59+
func (h *PrometheusHistogram) Update(v float64) {
60+
if math.IsNaN(v) || v < 0 {
61+
// Skip NaNs and negative values.
62+
return
63+
}
64+
bucketIdx := -1
65+
for i, ub := range h.upperBounds {
66+
if v <= ub {
67+
bucketIdx = i
68+
break
69+
}
70+
}
71+
h.mu.Lock()
72+
h.sum += v
73+
h.count++
74+
if bucketIdx == -1 {
75+
// +Inf, nothing to do, already accounted for in the total count
76+
}
77+
h.buckets[bucketIdx]++
78+
h.mu.Unlock()
79+
}
80+
81+
// Merge merges src to h
82+
func (h *PrometheusHistogram) Merge(src *PrometheusHistogram) {
83+
// first we must compare if the upper bounds are identical
84+
85+
h.mu.Lock()
86+
defer h.mu.Unlock()
87+
88+
src.mu.Lock()
89+
defer src.mu.Unlock()
90+
91+
h.sum += src.sum
92+
h.count += src.count
93+
94+
// TODO: implement actual sum
95+
}
96+
97+
// NewPrometheusHistogram creates and returns new prometheus histogram with the given name.
98+
//
99+
// name must be valid Prometheus-compatible metric with possible labels.
100+
// For instance,
101+
//
102+
// - foo
103+
// - foo{bar="baz"}
104+
// - foo{bar="baz",aaa="b"}
105+
//
106+
// The returned histogram is safe to use from concurrent goroutines.
107+
func NewPrometheusHistogram(name string) *PrometheusHistogram {
108+
return defaultSet.NewPrometheusHistogram(name)
109+
}
110+
111+
// GetOrCreateHistogram returns registered histogram with the given name
112+
// or creates new histogram if the registry doesn't contain histogram with
113+
// the given name.
114+
//
115+
// name must be valid Prometheus-compatible metric with possible labels.
116+
// For instance,
117+
//
118+
// - foo
119+
// - foo{bar="baz"}
120+
// - foo{bar="baz",aaa="b"}
121+
//
122+
// The returned histogram is safe to use from concurrent goroutines.
123+
//
124+
// Performance tip: prefer NewHistogram instead of GetOrCreateHistogram.
125+
func GetOrCreatePrometheusHistogram(name string) *PrometheusHistogram {
126+
return defaultSet.GetOrCreatePrometheusHistogram(name)
127+
}
128+
129+
func newPrometheusHistogram(upperBounds []float64) *PrometheusHistogram {
130+
validateBuckets(&upperBounds)
131+
oh := PrometheusHistogram{
132+
upperBounds: upperBounds,
133+
buckets: make([]uint64, len(upperBounds)),
134+
}
135+
136+
return &oh
137+
}
138+
139+
func validateBuckets(upperBounds *[]float64) {
140+
// TODO
141+
if len(*upperBounds) == 0 {
142+
panic("not good")
143+
}
144+
}
145+
146+
func (h *PrometheusHistogram) marshalTo(prefix string, w io.Writer) {
147+
cumulativeSum := uint64(0)
148+
h.mu.Lock()
149+
count := h.count
150+
sum := h.sum
151+
if count == 0 {
152+
h.mu.Unlock()
153+
return
154+
}
155+
for i, ub := range h.upperBounds {
156+
cumulativeSum += h.buckets[i]
157+
tag := fmt.Sprintf(`le="%v"`, ub)
158+
metricName := addTag(prefix, tag)
159+
name, labels := splitMetricName(metricName)
160+
fmt.Fprintf(w, "%s_bucket%s %d\n", name, labels, cumulativeSum)
161+
}
162+
h.mu.Unlock()
163+
164+
tag := fmt.Sprintf("le=%q", "+Inf")
165+
metricName := addTag(prefix, tag)
166+
name, labels := splitMetricName(metricName)
167+
fmt.Fprintf(w, "%s_bucket%s %d\n", name, labels, count)
168+
169+
name, labels = splitMetricName(prefix)
170+
if float64(int64(sum)) == sum {
171+
fmt.Fprintf(w, "%s_sum%s %d\n", name, labels, int64(sum))
172+
} else {
173+
fmt.Fprintf(w, "%s_sum%s %g\n", name, labels, sum)
174+
}
175+
fmt.Fprintf(w, "%s_count%s %d\n", name, labels, count)
176+
}
177+
178+
func (h *PrometheusHistogram) metricType() string {
179+
return "histogram"
180+
}

prometheus_histogram_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package metrics
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestPrometheusHistogramSerial(t *testing.T) {
10+
name := "TestPrometheusHistogramSerial"
11+
h := NewPrometheusHistogram(name)
12+
13+
// Verify that the histogram is invisible in the output of WritePrometheus when it has no data.
14+
var bb bytes.Buffer
15+
WritePrometheus(&bb, false)
16+
result := bb.String()
17+
if strings.Contains(result, name) {
18+
t.Fatalf("histogram %s shouldn't be visible in the WritePrometheus output; got\n%s", name, result)
19+
}
20+
21+
// Write data to histogram
22+
for i := 98; i < 218; i++ {
23+
h.Update(float64(i)*1e-4)
24+
}
25+
26+
// Make sure the histogram prints <prefix>_bucket on marshalTo call
27+
testMarshalTo(t, h, "prefix", `prefix_bucket{le="0.005"} 0
28+
prefix_bucket{le="0.01"} 3
29+
prefix_bucket{le="0.025"} 120
30+
prefix_bucket{le="0.05"} 120
31+
prefix_bucket{le="0.1"} 120
32+
prefix_bucket{le="0.25"} 120
33+
prefix_bucket{le="0.5"} 120
34+
prefix_bucket{le="1"} 120
35+
prefix_bucket{le="2.5"} 120
36+
prefix_bucket{le="5"} 120
37+
prefix_bucket{le="10"} 120
38+
prefix_bucket{le="+Inf"} 120
39+
prefix_sum 1.8900000000000003
40+
prefix_count 120
41+
`)
42+
}

set.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,64 @@ func (s *Set) GetOrCreateHistogram(name string) *Histogram {
128128
return h
129129
}
130130

131+
// NewPrometheusHistogram creates and returns new Prometheus histogram in s with the given name.
132+
//
133+
// name must be valid Prometheus-compatible metric with possible labels.
134+
// For instance,
135+
//
136+
// - foo
137+
// - foo{bar="baz"}
138+
// - foo{bar="baz",aaa="b"}
139+
//
140+
// The returned histogram is safe to use from concurrent goroutines.
141+
func (s *Set) NewPrometheusHistogram(name string) *PrometheusHistogram {
142+
h := newPrometheusHistogram(defaultUpperBounds)
143+
s.registerMetric(name, h)
144+
return h
145+
}
146+
147+
// GetOrCreatePrometheusHistogram returns registered histogram in s with the given name
148+
// or creates new histogram if s doesn't contain histogram with the given name.
149+
//
150+
// name must be valid Prometheus-compatible metric with possible labels.
151+
// For instance,
152+
//
153+
// - foo
154+
// - foo{bar="baz"}
155+
// - foo{bar="baz",aaa="b"}
156+
//
157+
// The returned histogram is safe to use from concurrent goroutines.
158+
//
159+
// Performance tip: prefer NewPrometheusHistogram instead of GetOrCreatePrometheusHistogram.
160+
func (s *Set) GetOrCreatePrometheusHistogram(name string) *PrometheusHistogram {
161+
s.mu.Lock()
162+
nm := s.m[name]
163+
s.mu.Unlock()
164+
if nm == nil {
165+
// Slow path - create and register missing histogram.
166+
if err := ValidateMetric(name); err != nil {
167+
panic(fmt.Errorf("BUG: invalid metric name %q: %s", name, err))
168+
}
169+
nmNew := &namedMetric{
170+
name: name,
171+
metric: newPrometheusHistogram(defaultUpperBounds),
172+
}
173+
s.mu.Lock()
174+
nm = s.m[name]
175+
if nm == nil {
176+
nm = nmNew
177+
s.m[name] = nm
178+
s.a = append(s.a, nm)
179+
}
180+
s.mu.Unlock()
181+
}
182+
h, ok := nm.metric.(*PrometheusHistogram)
183+
if !ok {
184+
panic(fmt.Errorf("BUG: metric %q isn't a Histogram. It is %T", name, nm.metric))
185+
}
186+
return h
187+
}
188+
131189
// NewCounter registers and returns new counter with the given name in the s.
132190
//
133191
// name must be valid Prometheus-compatible metric with possible labels.

0 commit comments

Comments
 (0)