Skip to content

Commit 4ab087d

Browse files
support Prometheus compatible histograms (#93)
as a follow-up/alternative to #45 and to solve #28, I'm starting this PR which adds support for [OTLP histograms](https://opentelemetry.io/docs/specs/otel/metrics/data-model/#histogram) (the common `le` - static bucket type of histograms, not the complex exponential histograms/native histograms). I know that VM focus is on log based buckets and really appreciate those, but having the opportunity to use "normal" histograms with this library would be greatly appreciated, as documented in #28 before working too much on this, I'd like to know whether support for normal / OTLP style buckets could be considered or not. I don't want to spend too much time on this PR if at the end @valyala @hagen1778 you decide against such a feature. I'd also like to start a discussion on the name. I picked `OTLPHistogram` in reference to the histograms described in the OpenTelemetry Protocol, but perhaps the naming is bad. [EDIT]: I think that `CompatibleHistogram` would make more sense than `OTLPHistogram`, had not thought about it until after I started implementing the functions currently the basic functionality is implemented, and a testcase covers a simple usage. if there is a decision in favor of this PR, I will add the missing testcases and complete the implementation (add a function for custom buckets/upper bounds for example) --------- Co-authored-by: Roman Khavronenko <[email protected]> Co-authored-by: hagen1778 <[email protected]>
1 parent 0147a77 commit 4ab087d

File tree

7 files changed

+593
-4
lines changed

7 files changed

+593
-4
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ instead of `CounterVec.With`. See [this example](https://pkg.go.dev/github.com/V
108108

109109
#### Why [Histogram](http://godoc.org/github.com/VictoriaMetrics/metrics#Histogram) buckets contain `vmrange` labels instead of `le` labels like in Prometheus histograms?
110110

111-
Buckets with `vmrange` labels occupy less disk space compared to Promethes-style buckets with `le` labels,
111+
Buckets with `vmrange` labels occupy less disk space compared to Prometheus-style buckets with `le` labels,
112112
because `vmrange` buckets don't include counters for the previous ranges. [VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics) provides `prometheus_buckets`
113113
function, which converts `vmrange` buckets to Prometheus-style buckets with `le` labels. This is useful for building heatmaps in Grafana.
114-
Additionally, its' `histogram_quantile` function transparently handles histogram buckets with `vmrange` labels.
114+
Additionally, its `histogram_quantile` function transparently handles histogram buckets with `vmrange` labels.
115+
116+
However, for compatibility purposes package provides classic [Prometheus Histograms](http://godoc.org/github.com/VictoriaMetrics/metrics#PrometheusHistogram) with `le` labels.

histogram.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ var bucketMultiplier = math.Pow(10, 1.0/bucketsPerDecimal)
4646
//
4747
// Zero histogram is usable.
4848
type Histogram struct {
49-
// Mu gurantees synchronous update for all the counters and sum.
49+
// Mu guarantees synchronous update for all the counters and sum.
5050
//
5151
// Do not use sync.RWMutex, since it has zero sense from performance PoV.
5252
// It only complicates the code.

histogram_example_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func ExampleHistogram() {
2020
func ExampleHistogram_vec() {
2121
for i := 0; i < 3; i++ {
2222
// Dynamically construct metric name and pass it to GetOrCreateHistogram.
23-
name := fmt.Sprintf(`response_size_bytes{path=%q}`, "/foo/bar")
23+
name := fmt.Sprintf(`response_size_bytes{path=%q, code=%q}`, "/foo/bar", 200+i)
2424
response := processRequest()
2525
metrics.GetOrCreateHistogram(name).Update(float64(len(response)))
2626
}

prometheus_histogram.go

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
package metrics
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"math"
7+
"sync"
8+
"time"
9+
)
10+
11+
// PrometheusHistogramDefaultBuckets is a list of the default bucket upper
12+
// bounds. Those default buckets are quite generic, and it is recommended to
13+
// pick custom buckets for improved accuracy.
14+
var PrometheusHistogramDefaultBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
15+
16+
// PrometheusHistogram is a histogram for non-negative values with pre-defined buckets
17+
//
18+
// Each bucket contains a counter for values in the given range.
19+
// Each bucket is exposed via the following metric:
20+
//
21+
// <metric_name>_bucket{<optional_tags>,le="upper_bound"} <counter>
22+
//
23+
// Where:
24+
//
25+
// - <metric_name> is the metric name passed to NewPrometheusHistogram
26+
// - <optional_tags> is optional tags for the <metric_name>, which are passed to NewPrometheusHistogram
27+
// - <upper_bound> - upper bound of the current bucket. all samples <= upper_bound are in that bucket
28+
// - <counter> - the number of hits to the given bucket during Update* calls
29+
//
30+
// Next to the bucket metrics, two additional metrics track the total number of
31+
// samples (_count) and the total sum (_sum) of all samples:
32+
//
33+
// - <metric_name>_sum{<optional_tags>} <counter>
34+
// - <metric_name>_count{<optional_tags>} <counter>
35+
type PrometheusHistogram struct {
36+
// mu guarantees synchronous update for all the counters.
37+
//
38+
// Do not use sync.RWMutex, since it has zero sense from performance PoV.
39+
// It only complicates the code.
40+
mu sync.Mutex
41+
42+
// upperBounds and buckets are aligned by element position:
43+
// upperBounds[i] defines the upper bound for buckets[i].
44+
// buckets[i] contains the count of elements <= upperBounds[i]
45+
upperBounds []float64
46+
buckets []uint64
47+
48+
// count is the counter for all observations on this histogram
49+
count uint64
50+
51+
// sum is the sum of all the values put into Histogram
52+
sum float64
53+
}
54+
55+
// Reset resets previous observations in h.
56+
func (h *PrometheusHistogram) Reset() {
57+
h.mu.Lock()
58+
for i := range h.buckets {
59+
h.buckets[i] = 0
60+
}
61+
h.sum = 0
62+
h.count = 0
63+
h.mu.Unlock()
64+
}
65+
66+
// Update updates h with v.
67+
//
68+
// Negative values and NaNs are ignored.
69+
func (h *PrometheusHistogram) Update(v float64) {
70+
if math.IsNaN(v) || v < 0 {
71+
// Skip NaNs and negative values.
72+
return
73+
}
74+
bucketIdx := -1
75+
for i, ub := range h.upperBounds {
76+
if v <= ub {
77+
bucketIdx = i
78+
break
79+
}
80+
}
81+
h.mu.Lock()
82+
h.sum += v
83+
h.count++
84+
if bucketIdx == -1 {
85+
// +Inf, nothing to do, already accounted for in the total sum
86+
h.mu.Unlock()
87+
return
88+
}
89+
h.buckets[bucketIdx]++
90+
h.mu.Unlock()
91+
}
92+
93+
// UpdateDuration updates request duration based on the given startTime.
94+
func (h *PrometheusHistogram) UpdateDuration(startTime time.Time) {
95+
d := time.Since(startTime).Seconds()
96+
h.Update(d)
97+
}
98+
99+
// NewPrometheusHistogram creates and returns new PrometheusHistogram with the given name
100+
// and PrometheusHistogramDefaultBuckets.
101+
//
102+
// name must be valid Prometheus-compatible metric with possible labels.
103+
// For instance,
104+
//
105+
// - foo
106+
// - foo{bar="baz"}
107+
// - foo{bar="baz",aaa="b"}
108+
//
109+
// The returned histogram is safe to use from concurrent goroutines.
110+
func NewPrometheusHistogram(name string) *PrometheusHistogram {
111+
return defaultSet.NewPrometheusHistogram(name)
112+
}
113+
114+
// NewPrometheusHistogramExt creates and returns new PrometheusHistogram with the given name
115+
// and given upperBounds.
116+
//
117+
// name must be valid Prometheus-compatible metric with possible labels.
118+
// For instance,
119+
//
120+
// - foo
121+
// - foo{bar="baz"}
122+
// - foo{bar="baz",aaa="b"}
123+
//
124+
// The returned histogram is safe to use from concurrent goroutines.
125+
func NewPrometheusHistogramExt(name string, upperBounds []float64) *PrometheusHistogram {
126+
return defaultSet.NewPrometheusHistogramExt(name, upperBounds)
127+
}
128+
129+
// GetOrCreatePrometheusHistogram returns registered PrometheusHistogram with the given name
130+
// or creates a new PrometheusHistogram if the registry doesn't contain histogram with
131+
// 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+
//
142+
// Performance tip: prefer NewPrometheusHistogram instead of GetOrCreatePrometheusHistogram.
143+
func GetOrCreatePrometheusHistogram(name string) *PrometheusHistogram {
144+
return defaultSet.GetOrCreatePrometheusHistogram(name)
145+
}
146+
147+
// GetOrCreatePrometheusHistogramExt returns registered PrometheusHistogram with the given name and
148+
// upperBounds or creates new PrometheusHistogram if the registry doesn't contain histogram
149+
// with the given name.
150+
//
151+
// name must be valid Prometheus-compatible metric with possible labels.
152+
// For instance,
153+
//
154+
// - foo
155+
// - foo{bar="baz"}
156+
// - foo{bar="baz",aaa="b"}
157+
//
158+
// The returned histogram is safe to use from concurrent goroutines.
159+
//
160+
// Performance tip: prefer NewPrometheusHistogramExt instead of GetOrCreatePrometheusHistogramExt.
161+
func GetOrCreatePrometheusHistogramExt(name string, upperBounds []float64) *PrometheusHistogram {
162+
return defaultSet.GetOrCreatePrometheusHistogramExt(name, upperBounds)
163+
}
164+
165+
func newPrometheusHistogram(upperBounds []float64) *PrometheusHistogram {
166+
mustValidateBuckets(upperBounds)
167+
last := len(upperBounds) - 1
168+
if math.IsInf(upperBounds[last], +1) {
169+
upperBounds = upperBounds[:last] // ignore +Inf bucket as it is covered anyways
170+
}
171+
h := PrometheusHistogram{
172+
upperBounds: upperBounds,
173+
buckets: make([]uint64, len(upperBounds)),
174+
}
175+
176+
return &h
177+
}
178+
179+
func mustValidateBuckets(upperBounds []float64) {
180+
if err := ValidateBuckets(upperBounds); err != nil {
181+
panic(err)
182+
}
183+
}
184+
185+
// ValidateBuckets validates the given upperBounds and returns an error
186+
// if validation failed.
187+
func ValidateBuckets(upperBounds []float64) error {
188+
if len(upperBounds) == 0 {
189+
return fmt.Errorf("upperBounds can't be empty")
190+
}
191+
for i := 0; i < len(upperBounds)-1; i++ {
192+
if upperBounds[i] >= upperBounds[i+1] {
193+
return fmt.Errorf("upper bounds for the buckets must be strictly increasing")
194+
}
195+
}
196+
return nil
197+
}
198+
199+
// LinearBuckets returns a list of upperBounds for PrometheusHistogram,
200+
// and whose distribution is as follows:
201+
//
202+
// [start, start + width, start + 2 * width, ... start + (count-1) * width]
203+
//
204+
// Panics if given start, width and count produce negative buckets or none buckets at all.
205+
func LinearBuckets(start, width float64, count int) []float64 {
206+
if count < 1 {
207+
panic("LinearBuckets: count can't be less than 1")
208+
}
209+
upperBounds := make([]float64, count)
210+
for i := range upperBounds {
211+
upperBounds[i] = start
212+
start += width
213+
}
214+
mustValidateBuckets(upperBounds)
215+
return upperBounds
216+
}
217+
218+
// ExponentialBuckets returns a list of upperBounds for PrometheusHistogram,
219+
// and whose distribution is as follows:
220+
//
221+
// [start, start * factor pow 1, start * factor pow 2, ... start * factor pow (count-1)]
222+
//
223+
// Panics if given start, width and count produce negative buckets or none buckets at all.
224+
func ExponentialBuckets(start, factor float64, count int) []float64 {
225+
if count < 1 {
226+
panic("ExponentialBuckets: count can't be less than 1")
227+
}
228+
if factor <= 1 {
229+
panic("ExponentialBuckets: factor must be greater than 1")
230+
}
231+
if start <= 0 {
232+
panic("ExponentialBuckets: start can't be less than 0")
233+
}
234+
upperBounds := make([]float64, count)
235+
for i := range upperBounds {
236+
upperBounds[i] = start
237+
start *= factor
238+
}
239+
mustValidateBuckets(upperBounds)
240+
return upperBounds
241+
}
242+
243+
func (h *PrometheusHistogram) marshalTo(prefix string, w io.Writer) {
244+
cumulativeSum := uint64(0)
245+
h.mu.Lock()
246+
count := h.count
247+
sum := h.sum
248+
for i, ub := range h.upperBounds {
249+
cumulativeSum += h.buckets[i]
250+
tag := fmt.Sprintf(`le="%v"`, ub)
251+
metricName := addTag(prefix, tag)
252+
name, labels := splitMetricName(metricName)
253+
fmt.Fprintf(w, "%s_bucket%s %d\n", name, labels, cumulativeSum)
254+
}
255+
h.mu.Unlock()
256+
257+
tag := fmt.Sprintf("le=%q", "+Inf")
258+
metricName := addTag(prefix, tag)
259+
name, labels := splitMetricName(metricName)
260+
fmt.Fprintf(w, "%s_bucket%s %d\n", name, labels, count)
261+
262+
name, labels = splitMetricName(prefix)
263+
if float64(int64(sum)) == sum {
264+
fmt.Fprintf(w, "%s_sum%s %d\n", name, labels, int64(sum))
265+
} else {
266+
fmt.Fprintf(w, "%s_sum%s %g\n", name, labels, sum)
267+
}
268+
fmt.Fprintf(w, "%s_count%s %d\n", name, labels, count)
269+
}
270+
271+
func (h *PrometheusHistogram) metricType() string {
272+
return "histogram"
273+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package metrics_test
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/VictoriaMetrics/metrics"
8+
)
9+
10+
func ExamplePrometheusHistogram() {
11+
// Define a histogram in global scope.
12+
h := metrics.NewPrometheusHistogram(`request_duration_seconds{path="/foo/bar"}`)
13+
14+
// Update the histogram with the duration of processRequest call.
15+
startTime := time.Now()
16+
processRequest()
17+
h.UpdateDuration(startTime)
18+
}
19+
20+
func ExamplePrometheusHistogram_vec() {
21+
for i := 0; i < 3; i++ {
22+
// Dynamically construct metric name and pass it to GetOrCreatePrometheusHistogram.
23+
name := fmt.Sprintf(`response_size_bytes{path=%q, code=%q}`, "/foo/bar", 200+i)
24+
response := processRequest()
25+
metrics.GetOrCreatePrometheusHistogram(name).Update(float64(len(response)))
26+
}
27+
}

0 commit comments

Comments
 (0)