From 437709c0207ce7d93dccc83106281933d4cfbfb7 Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Tue, 21 Nov 2023 15:30:31 +0100 Subject: [PATCH] Histogram: fourth and final basic collector type (#10) --- CHANGELOG.md | 5 +- docs/src/index.md | 23 +++++- src/Prometheus.jl | 148 ++++++++++++++++++++++++++++++++++++-- test/runtests.jl | 180 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 312 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4aeaad..1a14782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - +## [Unreleased] +### Added + - The fourth basic collector, `Histogram`, have been added. ([#10][github-10]) ## [1.1.0] - 2023-11-13 ### Added @@ -47,6 +49,7 @@ See [README.md](README.md) for details and documentation. [github-6]: https://github.com/fredrikekre/Prometheus.jl/pull/6 [github-7]: https://github.com/fredrikekre/Prometheus.jl/pull/7 +[github-10]: https://github.com/fredrikekre/Prometheus.jl/pull/10 [Unreleased]: https://github.com/fredrikekre/Prometheus.jl/compare/v1.1.0...HEAD [1.1.0]: https://github.com/fredrikekre/Prometheus.jl/compare/v1.0.1...v1.1.0 diff --git a/docs/src/index.md b/docs/src/index.md index d701d38..254ab6e 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -61,9 +61,9 @@ exposed. ## Collectors This section documents the collectors that are currently supported. This include the "basic" -collectors ([Counter](@ref), [Gauge](@ref), [Summary](@ref)) as well as some custom -collectors ([GCCollector](@ref), [ProcessCollector](@ref)). There is also a section on how -to implement your own collector, see [Custom collectors](@ref). +collectors ([Counter](@ref), [Gauge](@ref), [Histogram](@ref), [Summary](@ref)) as well as +some custom collectors ([GCCollector](@ref), [ProcessCollector](@ref)). There is also a +section on how to implement your own collector, see [Custom collectors](@ref). Upstream documentation: - @@ -111,6 +111,23 @@ Prometheus.@time Prometheus.@inprogress ``` +### Histogram + +Quoting the [upstream +documentation](https://prometheus.io/docs/concepts/metric_types/#histogram): +> A histogram samples observations (usually things like request durations or response sizes) +> and counts them in configurable buckets. It also provides a sum of all observed values. + +#### Histogram API reference + +```@docs +Prometheus.Histogram(::String, ::String; kwargs...) +Prometheus.observe(::Prometheus.Histogram, ::Any) +``` +```@docs; canonical=false +Prometheus.@time +``` + ### Summary Quoting the [upstream diff --git a/src/Prometheus.jl b/src/Prometheus.jl index 98be5a8..3979537 100644 --- a/src/Prometheus.jl +++ b/src/Prometheus.jl @@ -317,6 +317,119 @@ function collect!(metrics::Vector, gauge::Gauge) end +########################## +# Histogram <: Collector # +########################## +# https://prometheus.io/docs/instrumenting/writing_clientlibs/#histogram + +# A histogram SHOULD have the same default buckets as other client libraries. +# https://github.com/prometheus/client_python/blob/d8306b7b39ed814f3ec667a7901df249cee8a956/prometheus_client/metrics.py#L565 +const DEFAULT_BUCKETS = [ + .005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, Inf, +] + +mutable struct Histogram <: Collector + @const metric_name::String + @const help::String + @const buckets::Vector{Float64} + @atomic _count::Int + @atomic _sum::Float64 + @const bucket_counters::Vector{Threads.Atomic{Int}} + + function Histogram( + metric_name::String, help::String; buckets::Vector{Float64}=DEFAULT_BUCKETS, + registry::Union{CollectorRegistry, Nothing}=DEFAULT_REGISTRY, + ) + # Make a copy of and verify buckets + buckets = copy(buckets) + issorted(buckets) || throw(ArgumentError("buckets must be sorted")) + length(buckets) > 0 && buckets[end] != Inf && push!(buckets, Inf) + length(buckets) < 2 && throw(ArgumentError("must have at least two buckets")) + initial_sum = 0.0 + initial_count = 0 + bucket_counters = [Threads.Atomic{Int}(0) for _ in 1:length(buckets)] + histogram = new( + verify_metric_name(metric_name), help, buckets, + initial_count, initial_sum, bucket_counters, + ) + if registry !== nothing + register(registry, histogram) + end + return histogram + end +end + +""" + Prometheus.Histogram(name, help; buckets=DEFAULT_BUCKETS, registry=DEFAULT_REGISTRY) + +Construct a Histogram collector. + +**Arguments** + - `name :: String`: the name of the histogram metric. + - `help :: String`: the documentation for the histogram metric. + +**Keyword arguments** + - `buckets :: Vector{Float64}`: the upper bounds for the histogram buckets. The buckets + must be sorted. `Inf` will be added as a last bucket if not already included. The default + buckets are `DEFAULT_BUCKETS = $(DEFAULT_BUCKETS)`. + - `registry :: Prometheus.CollectorRegistry`: the registry in which to register the + collector. If not specified the default registry is used. Pass `registry = nothing` to + skip registration. + +**Methods** + - [`Prometheus.observe`](@ref): add an observation to the histogram. + - [`Prometheus.@time`](@ref): time a section and add the elapsed time as an observation. +""" +Histogram(::String, ::String; kwargs...) + +function metric_names(histogram::Histogram) + return ( + histogram.metric_name * "_count", histogram.metric_name * "_sum", + histogram.metric_name, + ) +end + +""" + Prometheus.observe(histogram::Histogram, v) + +Add the observed value `v` to the histogram. +This increases the sum and count of the histogram with `v` and `1`, respectively, and +increments the counter for all buckets containing `v`. +""" +function observe(histogram::Histogram, v) + @atomic histogram._count += 1 + @atomic histogram._sum += v + for (bucket, bucket_counter) in zip(histogram.buckets, histogram.bucket_counters) + # TODO: Iterate in reverse and break early + if v <= bucket + Threads.atomic_add!(bucket_counter, 1) + end + end + return nothing +end + +function collect!(metrics::Vector, histogram::Histogram) + label_names = LabelNames(("le",)) + push!(metrics, + Metric( + "histogram", histogram.metric_name, histogram.help, + [ + Sample("_count", nothing, nothing, @atomic(histogram._count)), + Sample("_sum", nothing, nothing, @atomic(histogram._sum)), + ( + Sample( + nothing, label_names, + make_label_values(label_names, (histogram.buckets[i],)), + histogram.bucket_counters[i][], + ) for i in 1:length(histogram.buckets) + )..., + ] + ), + ) + return metrics +end + + ######################## # Summary <: Collector # ######################## @@ -357,7 +470,8 @@ Construct a Summary collector. skip registration. **Methods** - - [`Prometheus.observe`](@ref): add an observation to the summary. + - [`Prometheus.observe`](@ref observe(::Summary, ::Any)): add an observation to the + summary. - [`Prometheus.@time`](@ref): time a section and add the elapsed time as an observation. """ Summary(::String, ::String; kwargs...) @@ -404,8 +518,8 @@ specific action depends on the type of collector: - `collector :: Gauge`: set the value of the gauge to the elapsed time ([`Prometheus.set`](@ref)) - - `collector :: Summary`: add the elapsed time as an observation - ([`Prometheus.observe`](@ref)) + - `collector :: Histogram` and `collector :: Summary`: add the elapsed time as an + observation ([`Prometheus.observe`](@ref)) The expression to time, `expr`, can be a single expression (for example a function call), or a code block (`begin`, `let`, etc), e.g. @@ -431,6 +545,7 @@ end at_time(gauge::Gauge, v) = set(gauge, v) at_time(summary::Summary, v) = observe(summary, v) +at_time(histogram::Histogram, v) = observe(histogram, v) at_time(::Collector, v) = unreachable() """ @@ -760,6 +875,7 @@ end prometheus_type(::Type{Counter}) = "counter" prometheus_type(::Type{Gauge}) = "gauge" +prometheus_type(::Type{Histogram}) = "histogram" prometheus_type(::Type{Summary}) = "summary" prometheus_type(::Type) = unreachable() @@ -782,8 +898,30 @@ function collect!(metrics::Vector, family::Family{C}) where C else @assert(child_samples isa Vector{Sample}) for child_sample in child_samples - @assert(child_sample.label_values === nothing) - push!(samples, Sample(child_sample.suffix, label_names, label_values, child_sample.value)) + if C === Histogram && (child_sample.label_names !== nothing) && (child_sample.label_values !== nothing) + # TODO: Only allow child samples to be labeled for Histogram + # collectors for now. + @assert( + length(child_sample.label_names.label_names) == + length(child_sample.label_values.label_values) + ) + # TODO: Bypass constructor verifications + merged_names = LabelNames(( + label_names.label_names..., + child_sample.label_names.label_names..., + )) + merged_values = LabelValues(( + label_values.label_values..., + child_sample.label_values.label_values..., + )) + push!(samples, Sample(child_sample.suffix, merged_names, merged_values, child_sample.value)) + else + @assert( + (child_sample.label_names === nothing) === + (child_sample.label_values === nothing) + ) + push!(samples, Sample(child_sample.suffix, label_names, label_values, child_sample.value)) + end end end end diff --git a/test/runtests.jl b/test/runtests.jl index b0e086a..63f39ab 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -167,6 +167,76 @@ end """ end +@testset "Prometheus.Histogram" begin + # Constructors and implicit registration + empty!(Prometheus.DEFAULT_REGISTRY.collectors) + c = Prometheus.Histogram("metric_name_histogram", "A histogram.") + @test c in Prometheus.DEFAULT_REGISTRY.collectors + r = Prometheus.CollectorRegistry() + c = Prometheus.Histogram("metric_name_histogram", "A histogram."; registry=r) + @test c in r.collectors + @test c.buckets == Prometheus.DEFAULT_BUCKETS + @test c._count == 0 + @test c._sum == 0 + @test all(x -> x[] == 0, c.bucket_counters) + @test_throws( + Prometheus.ArgumentError("metric name \"invalid-name\" is invalid"), + Prometheus.Histogram("invalid-name", "help"), + ) + # Prometheus.observe(...) + v1 = 0.9 + Prometheus.observe(c, v1) + @test c._count == 1 + @test c._sum == v1 + for (ub, counter, known_count) in zip(c.buckets, c.bucket_counters, [zeros(Int, 9); ones(Int, 6)]) + @test counter[] == (v1 > ub ? 0 : 1) == known_count + end + v2 = 10v1 + Prometheus.observe(c, v2) + @test c._count == 2 + @test c._sum == v1 + v2 + for (ub, counter, known_count) in zip(c.buckets, c.bucket_counters, [zeros(Int, 9); [1, 1, 1, 1]; [2, 2]]) + @test counter[] == ((v2 > ub && v1 > ub) ? 0 : v2 > ub ? 1 : 2) == known_count + end + # Prometheus.collect(...) + r = Prometheus.CollectorRegistry() + buckets = [1.0, 2.0, Inf] + c = Prometheus.Histogram("metric_name_histogram", "A histogram."; buckets=buckets, registry=r) + Prometheus.observe(c, 0.5) + Prometheus.observe(c, 1.6) + metrics = Prometheus.collect(c) + @test length(metrics) == 1 + metric = metrics[1] + @test metric.metric_name == c.metric_name + @test metric.help == c.help + @test length(metric.samples) == length(buckets) + 2 + s1, s2 = metric.samples[1], metric.samples[2] + @test s1.suffix == "_count" + @test s2.suffix == "_sum" + @test s1.label_values === nothing + @test s2.label_values === nothing + @test s1.value == 2 + @test s2.value == 0.5 + 1.6 + for (ub, counter, sample, known_count) in zip(c.buckets, c.bucket_counters, metric.samples[3:end], [1, 2, 2]) + @test sample.suffix === nothing + @test (sample.label_names::Prometheus.LabelNames{1}).label_names === (:le,) + @test (sample.label_values::Prometheus.LabelValues{1}).label_values == (string(ub),) + @test sample.value == counter[] == known_count + end + # Prometheus.expose_metric(...) + @test sprint(Prometheus.expose_metric, metric) == + sprint(Prometheus.expose_io, r) == + """ + # HELP metric_name_histogram A histogram. + # TYPE metric_name_histogram histogram + metric_name_histogram_count 2 + metric_name_histogram_sum 2.1 + metric_name_histogram{le="1.0"} 1 + metric_name_histogram{le="2.0"} 2 + metric_name_histogram{le="Inf"} 2 + """ +end + @testset "Prometheus.LabelNames and Prometheus.LabelValues" begin @test_throws( Prometheus.ArgumentError("label name \"invalid-label\" is invalid"), @@ -277,32 +347,46 @@ end @test 0.3 > gauge.value > 0.1 end -@testset "Prometheus.@time summary::Summary" begin - summary = Prometheus.Summary("call_time", "Time of calls"; registry=nothing) - Prometheus.@time summary sleep(0.1) - @test 0.3 > summary._sum > 0.1 - @test summary._count == 1 - Prometheus.@time summary let +@testset "Prometheus.@time collector::$(Collector)" for Collector in (Prometheus.Histogram, Prometheus.Summary) + ishist = Collector === Prometheus.Histogram + buckets = [1.0, Inf] + collector = Collector( + "call_time", "Time of calls"; + (ishist ? (; buckets=buckets) : (;))..., + registry=nothing, + ) + Prometheus.@time collector sleep(0.1) + @test 0.3 > collector._sum > 0.1 + @test collector._count == 1 + ishist && @test (x->x[]).(collector.bucket_counters) == [1, 1] + Prometheus.@time collector let sleep(0.1) end - @test 0.4 > summary._sum > 0.2 - @test summary._count == 2 - Prometheus.@time summary f() = sleep(0.1) + @test 0.4 > collector._sum > 0.2 + @test collector._count == 2 + ishist && @test (x->x[]).(collector.bucket_counters) == [2, 2] + Prometheus.@time collector f() = sleep(0.1) @sync begin @async f() @async f() end - @test 0.7 > summary._sum > 0.4 - @test summary._count == 4 - Prometheus.@time summary function g() + @test 0.7 > collector._sum > 0.4 + @test collector._count == 4 + ishist && @test (x->x[]).(collector.bucket_counters) == [4, 4] + Prometheus.@time collector function g() sleep(0.1) end @sync begin @async g() @async g() end - @test 0.9 > summary._sum > 0.6 - @test summary._count == 6 + @test 0.9 > collector._sum > 0.6 + @test collector._count == 6 + ishist && @test (x->x[]).(collector.bucket_counters) == [6, 6] + if ishist + Prometheus.@time collector sleep(1.1) + @test (x->x[]).(collector.bucket_counters) == [6, 7] + end end @testset "Prometheus.@inprogress gauge::Gauge" begin @@ -353,11 +437,12 @@ end @test_throws Prometheus.UnreachableError Prometheus.at_inprogress_exit(Coll()) end -@testset "Prometheus.Family{Summary}" begin +@testset "Prometheus.Family{$Collector}" for Collector in (Prometheus.Histogram, Prometheus.Summary) r = Prometheus.CollectorRegistry() - c = Prometheus.Family{Prometheus.Summary}( + c = Prometheus.Family{Collector}( "http_request_time", "Time to process requests.", ("endpoint", "status_code"); + (Collector === Prometheus.Histogram ? (; buckets = [2.0, Inf]) : (;))..., registry = r, ) @test c in r.collectors @@ -390,25 +475,50 @@ end metric = metrics[1] @test metric.metric_name == c.metric_name @test metric.help == c.help - @test length(metric.samples) == 4 - s1, s2, s3, s4 = metric.samples - @test s1.label_values.label_values == s2.label_values.label_values == ("/bar/", "404") - @test s3.label_values.label_values == s4.label_values.label_values == ("/foo/", "200") - @test s1.value == 2 # _count - @test s2.value == 6.4 # _sum - @test s3.value == 2 # _count - @test s4.value == 4.6 # _sum - # Prometheus.expose_metric(...) - @test sprint(Prometheus.expose_metric, metric) == - sprint(Prometheus.expose_io, r) == - """ - # HELP http_request_time Time to process requests. - # TYPE http_request_time summary - http_request_time_count{endpoint="/bar/",status_code="404"} 2 - http_request_time_sum{endpoint="/bar/",status_code="404"} 6.4 - http_request_time_count{endpoint="/foo/",status_code="200"} 2 - http_request_time_sum{endpoint="/foo/",status_code="200"} 4.6 - """ + if Collector === Prometheus.Histogram + buckets = Prometheus.labels(c, l1).buckets + @test length(buckets) == length(Prometheus.labels(c, l2).buckets) + @test length(metric.samples) == 2 * (length(buckets) + 2) + # _count and _sum samples + s1, s2, s5, s6 = metric.samples[[1, 2, 5, 6]] + @test s1.label_values.label_values == s2.label_values.label_values == ("/bar/", "404") + @test s5.label_values.label_values == s6.label_values.label_values == ("/foo/", "200") + @test s1.value == 2 # _count + @test s2.value == 6.4 # _sum + @test s5.value == 2 # _count + @test s6.value == 4.6 # _sum + # {le} samples + for (ls, subrange) in ((l1, 7:8), (l2, 3:4)) + for (ub, counter, sample) in zip(buckets, Prometheus.labels(c, ls).bucket_counters, metric.samples[subrange]) + @test sample.suffix === nothing + @test (sample.label_names::Prometheus.LabelNames{3}).label_names === + (:endpoint, :status_code, :le) + @test (sample.label_values::Prometheus.LabelValues{3}).label_values == + (ls..., string(ub)) + @test sample.value == counter[] + end + end + else # Collector === Prometheus.Summary + @test length(metric.samples) == 4 + s1, s2, s3, s4 = metric.samples + @test s1.label_values.label_values == s2.label_values.label_values == ("/bar/", "404") + @test s3.label_values.label_values == s4.label_values.label_values == ("/foo/", "200") + @test s1.value == 2 # _count + @test s2.value == 6.4 # _sum + @test s3.value == 2 # _count + @test s4.value == 4.6 # _sum + # Prometheus.expose_metric(...) + @test sprint(Prometheus.expose_metric, metric) == + sprint(Prometheus.expose_io, r) == + """ + # HELP http_request_time Time to process requests. + # TYPE http_request_time summary + http_request_time_count{endpoint="/bar/",status_code="404"} 2 + http_request_time_sum{endpoint="/bar/",status_code="404"} 6.4 + http_request_time_count{endpoint="/foo/",status_code="200"} 2 + http_request_time_sum{endpoint="/foo/",status_code="200"} 4.6 + """ + end end @testset "Label types for Prometheus.Family{C}" begin