-
Notifications
You must be signed in to change notification settings - Fork 76
Prometheus compatible histograms #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
hagen1778
merged 10 commits into
VictoriaMetrics:master
from
clementnuss:feat/otlp_histogram
Jun 13, 2025
Merged
Changes from 8 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
d1c7648
feat: implement prometheus histograms
clementnuss 2a25c80
chore: implement custom buckets (with validation) and bug fixes
clementnuss 5592cc9
chore: complete PromHistogram.Merge() implementation
clementnuss 9c49c82
test(promHistogram): cover merge edge cases
clementnuss 67ff519
chore(promHistogram): remove Merge implementation and add LinearBuckets
clementnuss 0cfd41a
prometheus_histogram.go: fix typos and erroneous doc
clementnuss d313324
Update prometheus_histogram_test.go
clementnuss f96d9d1
prometheus_histogram: implement PR suggestions
clementnuss 95d307d
review updates
hagen1778 5bfbe76
add test for empty histogram rendering
hagen1778 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| package metrics | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "math" | ||
| "sync" | ||
| "time" | ||
| ) | ||
|
|
||
| // PrometheusHistogramDefaultBuckets is a list of the default bucket upper | ||
| // bounds. Those default buckets are quite generic, and it is recommended to | ||
| // pick custom buckets for improved accuracy. | ||
| var PrometheusHistogramDefaultBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10} | ||
|
|
||
| // PrometheusHistogram is a histogram for non-negative values with pre-defined buckets | ||
| // | ||
| // Each bucket contains a counter for values in the given range. | ||
| // Each bucket is exposed via the following metric: | ||
clementnuss marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // | ||
| // <metric_name>_bucket{<optional_tags>,le="upper_bound"} <counter> | ||
| // | ||
| // Where: | ||
| // | ||
| // - <metric_name> is the metric name passed to NewHistogram | ||
| // - <optional_tags> is optional tags for the <metric_name>, which are passed to NewHistogram | ||
| // - <upper_bound> - upper bound of the current bucket. all samples <= upper_bound are in that bucket | ||
| // - <counter> - the number of hits to the given bucket during Update* calls | ||
| // | ||
| // Next to the bucket metrics, two additional metrics track the total number of | ||
| // samples (_count) and the total sum (_sum) of all samples. | ||
| // | ||
| // <metric_name>_sum{<optional_tags>} <counter> | ||
| // <metric_name>_count{<optional_tags>} <counter> | ||
| type PrometheusHistogram struct { | ||
| // Mu guarantees synchronous update for all the counters and sum. | ||
| // | ||
| // Do not use sync.RWMutex, since it has zero sense from performance PoV. | ||
| // It only complicates the code. | ||
| mu sync.Mutex | ||
|
|
||
| // upperBounds and buckets are aligned by element position: | ||
| // upperBounds[i] defines the upper bound for buckets[i]. | ||
| // buckets[i] contains the count of elements <= upperBounds[i] | ||
| upperBounds []float64 | ||
| buckets []uint64 | ||
clementnuss marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // count is the counter for all observations on this histogram | ||
| count uint64 | ||
|
|
||
| // sum is the sum of all the values put into Histogram | ||
| sum float64 | ||
| } | ||
|
|
||
| // Reset resets previous observations in h. | ||
| func (h *PrometheusHistogram) Reset() { | ||
| h.mu.Lock() | ||
| for i := range h.buckets { | ||
| h.buckets[i] = 0 | ||
| } | ||
|
|
||
| h.sum = 0 | ||
| h.count = 0 | ||
| h.mu.Unlock() | ||
| } | ||
|
|
||
| // Update updates h with v. | ||
| // | ||
| // Negative values and NaNs are ignored. | ||
| func (h *PrometheusHistogram) Update(v float64) { | ||
| if math.IsNaN(v) || v < 0 { | ||
| // Skip NaNs and negative values. | ||
| return | ||
| } | ||
| bucketIdx := -1 | ||
| for i, ub := range h.upperBounds { | ||
| if v <= ub { | ||
| bucketIdx = i | ||
| break | ||
| } | ||
| } | ||
| h.mu.Lock() | ||
| defer h.mu.Unlock() | ||
| h.sum += v | ||
| h.count++ | ||
| if bucketIdx == -1 { | ||
| // +Inf, nothing to do, already accounted for in the total sum | ||
| return | ||
| } | ||
| h.buckets[bucketIdx]++ | ||
| } | ||
|
|
||
| // UpdateDuration updates request duration based on the given startTime. | ||
| func (h *PrometheusHistogram) UpdateDuration(startTime time.Time) { | ||
| d := time.Since(startTime).Seconds() | ||
| h.Update(d) | ||
| } | ||
|
|
||
| // NewPrometheusHistogram creates and returns new PrometheusHistogram with the given name. | ||
| // | ||
| // name must be valid Prometheus-compatible metric with possible labels. | ||
| // For instance, | ||
| // | ||
| // - foo | ||
| // - foo{bar="baz"} | ||
| // - foo{bar="baz",aaa="b"} | ||
| // | ||
| // The returned histogram is safe to use from concurrent goroutines. | ||
| func NewPrometheusHistogram(name string) *PrometheusHistogram { | ||
| return defaultSet.NewPrometheusHistogram(name) | ||
| } | ||
|
|
||
| // NewPrometheusHistogramExt creates and returns new PrometheusHistogram with the given name and upperBounds. | ||
| // | ||
| // name must be valid Prometheus-compatible metric with possible labels. | ||
| // For instance, | ||
| // | ||
| // - foo | ||
| // - foo{bar="baz"} | ||
| // - foo{bar="baz",aaa="b"} | ||
| // | ||
| // The returned histogram is safe to use from concurrent goroutines. | ||
| func NewPrometheusHistogramExt(name string, upperBounds []float64) *PrometheusHistogram { | ||
hagen1778 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return defaultSet.NewPrometheusHistogramExt(name, upperBounds) | ||
| } | ||
|
|
||
| // GetOrCreatePrometheusHistogram returns registered histogram with the given name | ||
| // or creates a new histogram if the registry doesn't contain histogram with | ||
| // the given name. | ||
| // | ||
| // name must be valid Prometheus-compatible metric with possible labels. | ||
| // For instance, | ||
| // | ||
| // - foo | ||
| // - foo{bar="baz"} | ||
| // - foo{bar="baz",aaa="b"} | ||
| // | ||
| // The returned histogram is safe to use from concurrent goroutines. | ||
| // | ||
| // Performance tip: prefer NewPrometheusHistogram instead of GetOrCreatePrometheusHistogram. | ||
| func GetOrCreatePrometheusHistogram(name string) *PrometheusHistogram { | ||
| return defaultSet.GetOrCreatePrometheusHistogram(name) | ||
| } | ||
|
|
||
| // GetOrCreatePrometheusHistogramExt returns registered histogram with the given name and | ||
| // upperBounds or creates new histogram if the registry doesn't contain histogram | ||
| // with the given name. | ||
| // | ||
| // name must be valid Prometheus-compatible metric with possible labels. | ||
| // For instance, | ||
| // | ||
| // - foo | ||
| // - foo{bar="baz"} | ||
| // - foo{bar="baz",aaa="b"} | ||
| // | ||
| // The returned histogram is safe to use from concurrent goroutines. | ||
| // | ||
| // Performance tip: prefer NewPrometheusHistogramExt instead of GetOrCreatePrometheusHistogramExt. | ||
| func GetOrCreatePrometheusHistogramExt(name string, upperBounds []float64) *PrometheusHistogram { | ||
| return defaultSet.GetOrCreatePrometheusHistogramExt(name, upperBounds) | ||
| } | ||
|
|
||
| func newPrometheusHistogram(upperBounds []float64) *PrometheusHistogram { | ||
| mustValidateBuckets(upperBounds) | ||
| last := len(upperBounds) - 1 | ||
| if math.IsInf(upperBounds[last], +1) { | ||
| upperBounds = upperBounds[:last] // ignore +Inf bucket as it is covered anyways | ||
| } | ||
| h := PrometheusHistogram{ | ||
| upperBounds: upperBounds, | ||
| buckets: make([]uint64, len(upperBounds)), | ||
| } | ||
|
|
||
| return &h | ||
| } | ||
|
|
||
| func mustValidateBuckets(upperBounds []float64) { | ||
| if err := ValidateBuckets(upperBounds); err != nil { | ||
| panic(err) | ||
| } | ||
| } | ||
|
|
||
| func ValidateBuckets(upperBounds []float64) error { | ||
| if len(upperBounds) == 0 { | ||
| return fmt.Errorf("upperBounds can't be empty") | ||
| } | ||
| for i := 0; i < len(upperBounds)-1; i++ { | ||
| if upperBounds[i] >= upperBounds[i+1] { | ||
| return fmt.Errorf("upper bounds for the buckets must be strictly increasing") | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // LinearBuckets returns a slice containing `count` upper bounds to be used | ||
| // with a prometheus histogram, and whose distribution is as follows: | ||
| // [start, start + width, start + 2 * width, ... start + (count-1) * width] | ||
| func LinearBuckets(start, width float64, count int) []float64 { | ||
clementnuss marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if count < 1 { | ||
| panic("LinearBuckets needs a positive count") | ||
| } | ||
| upperBounds := make([]float64, count) | ||
| for i := range upperBounds { | ||
| upperBounds[i] = start | ||
| start += width | ||
| } | ||
| return upperBounds | ||
| } | ||
|
|
||
| func (h *PrometheusHistogram) marshalTo(prefix string, w io.Writer) { | ||
| cumulativeSum := uint64(0) | ||
| h.mu.Lock() | ||
| count := h.count | ||
| sum := h.sum | ||
| if count == 0 { | ||
| h.mu.Unlock() | ||
hagen1778 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return | ||
| } | ||
| for i, ub := range h.upperBounds { | ||
| cumulativeSum += h.buckets[i] | ||
| tag := fmt.Sprintf(`le="%v"`, ub) | ||
| metricName := addTag(prefix, tag) | ||
| name, labels := splitMetricName(metricName) | ||
| fmt.Fprintf(w, "%s_bucket%s %d\n", name, labels, cumulativeSum) | ||
| } | ||
| h.mu.Unlock() | ||
|
|
||
| tag := fmt.Sprintf("le=%q", "+Inf") | ||
| metricName := addTag(prefix, tag) | ||
| name, labels := splitMetricName(metricName) | ||
| fmt.Fprintf(w, "%s_bucket%s %d\n", name, labels, count) | ||
|
|
||
| name, labels = splitMetricName(prefix) | ||
| if float64(int64(sum)) == sum { | ||
| fmt.Fprintf(w, "%s_sum%s %d\n", name, labels, int64(sum)) | ||
| } else { | ||
| fmt.Fprintf(w, "%s_sum%s %g\n", name, labels, sum) | ||
| } | ||
| fmt.Fprintf(w, "%s_count%s %d\n", name, labels, count) | ||
| } | ||
|
|
||
| func (h *PrometheusHistogram) metricType() string { | ||
| return "histogram" | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package metrics_test | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "time" | ||
|
|
||
| "github.com/VictoriaMetrics/metrics" | ||
| ) | ||
|
|
||
| func ExamplePrometheusHistogram() { | ||
| // Define a histogram in global scope. | ||
| h := metrics.NewPrometheusHistogram(`request_duration_seconds{path="/foo/bar"}`) | ||
|
|
||
| // Update the histogram with the duration of processRequest call. | ||
| startTime := time.Now() | ||
| processRequest() | ||
| h.UpdateDuration(startTime) | ||
| } | ||
|
|
||
| func ExamplePrometheusHistogram_vec() { | ||
| for i := 0; i < 3; i++ { | ||
| // Dynamically construct metric name and pass it to GetOrCreateHistogram. | ||
| name := fmt.Sprintf(`response_size_bytes{path=%q, code=%q}`, "/foo/bar", 200+i) | ||
| response := processRequest() | ||
| metrics.GetOrCreateHistogram(name).Update(float64(len(response))) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.