Render summaries in the text format from their value objects

What does this MR do and why?

Prometheus::Client::Push renders the registry with Formats::Text.marshal, which read a summary's quantiles, sum and count as value.get.map, value.get.sum and value.get.total. Summary#values hands the format value.sum, the value object of the sum alone, and its get is a Float, so a registry holding a summary with any observation raised NoMethodError: undefined method 'map' for an instance of Float and could not be pushed. The count lives in Summary::Value#total, which Summary#values never hands out, so the format had no way to reach it either. This is the summary half that !299 (merged) left out because it needs a change to Summary itself.

This MR has four commits:

  1. Describe what Summary#get and Summary#values return. The #get and #values examples in summary_spec.rb were skipped with xdescribe and expected quantiles, which Summary does not compute. They now describe what both methods return today, on SimpleValue: #get is the sum of a label set's observations (0.0 without any), and each value #values hands out answers get with that sum. They pass on master. The spec also requires prometheus/client now, as the counter, gauge and histogram specs do: run on its own on master, 3 of its examples fail with NoMethodError: undefined method 'logger' for module Prometheus::Client, because nothing has loaded prometheus/client, which defines Prometheus::Client.configuration and .logger.
  2. Hand out each label set's Summary::Value from Summary#values. Summary drops its #values override and inherits Metric#values, so it hands out each label set's Summary::Value, as Histogram does with Histogram::Value. Summary::Value gains #get, returning the sum, so summary.values[labels].get keeps returning the number it returned before (the first commit's example still passes), while sum and total reach both value objects. A new example reads both; it fails on the first commit with undefined method 'sum' for an instance of Prometheus::Client::SimpleValue.
  3. Render summaries in the text format from their value objects. Formats::Text.summary reads the sum from value.sum.get and the count from value.total.get, and writes no quantile samples, since Summary::Value keeps none. That is also what marshal_multiprocess writes for a summary. The format spec renders a real Summary in place of the double (a Hash of quantiles stubbed to answer sum and total, a shape Summary never produces), and a new Push spec adds a registry holding one. Both fail on the second commit with the NoMethodError above.
  4. Document that a Summary keeps a sum and a count, not quantiles. The README described "an efficient percentile calculation mechanism" and showed Summary#get returning a Hash of quantiles, and the class comment said the same. Both now say what a summary keeps, and the example shows #get returning the sum.

Each commit passes the whole suite on its own, on the four Ruby versions the ruby CI job runs (details below). With this MR the issue's example renders as:

# HELP request_seconds Request time
# TYPE request_seconds summary
request_seconds_sum{route="x"} 0.75
request_seconds_count{route="x"} 2.0

Closes #82 (closed)

What changes for callers

Summary#values is public, so this is the part to look at. In this gem its only reader is Formats::Text, and Push reads only its keys. A caller elsewhere now gets a Summary::Value for each label set instead of the sum's value object. get on it returns the same number as before, which the first commit pins and the second keeps passing; a caller that called increment or set on the old object would have to go through .sum. Summary#get and Formats::Text.marshal_multiprocess are unchanged.

The text format no longer tries to write quantile samples. On master it wrote none either: it raised before writing a summary at all.

I did not add a CHANGELOG.md entry, since the entries are written at release time, but I can add one under Unreleased if you prefer.

The new examples on the previous commit and on this branch

Second commit's summary_spec.rb with the first commit's summary.rb, Ruby 3.4.11:

$ bundle exec rspec spec/prometheus/client/summary_spec.rb
  1) Prometheus::Client::Summary#values returns each label set with its sum and its count
[...]
     NoMethodError:
       undefined method 'sum' for an instance of Prometheus::Client::SimpleValue
     # ./spec/prometheus/client/summary_spec.rb:72:in 'block (4 levels) in <top (required)>'
[...]
14 examples, 1 failure

Third commit's format and Push specs with the second commit's formats/text.rb:

$ bundle exec rspec spec/prometheus/client/formats/text_spec.rb spec/prometheus/client/push_spec.rb
  1) Prometheus::Client::Formats::Text single process metrics .marshal returns a Text format version 0.0.4 compatible representation
[...]
     NoMethodError:
       undefined method 'map' for an instance of Float
     # ./lib/prometheus/client/formats/text.rb:61:in 'Prometheus::Client::Formats::Text.summary'
[...]
  2) Prometheus::Client::Push#add with a summary in the registry sends the summary in the text format
[...]
     NoMethodError:
       undefined method 'map' for an instance of Float
     # ./lib/prometheus/client/formats/text.rb:61:in 'Prometheus::Client::Formats::Text.summary'
[...]
30 examples, 2 failures, 1 pending

The same three spec files from this branch with summary.rb and formats/text.rb from master 6be5328 give 44 examples, 3 failures, 1 pending, the three failures above. On each commit its own spec files pass, and Ruby 3.2.11, 3.3.12 and 4.0.7 give the same counts as 3.4.11.

The whole suite and RuboCop, per commit

bundle exec rake spec with prometheus_multiproc_dir=tmp/ and SKIP_C_EXTENSION_TESTING=1, as the ruby job runs it, after bundle exec rake compile with Rust 1.89.0, on Ruby 3.2.11, 3.3.12, 3.4.11 and 4.0.7 (the same counts on all four):

Tree Result
master 183 examples, 0 failures, 19 pending
first commit 182 examples, 0 failures, 15 pending
second commit 183 examples, 0 failures, 15 pending
third commit 184 examples, 0 failures, 15 pending
fourth commit 184 examples, 0 failures, 15 pending

The first commit replaces the four skipped examples with three that run.

bundle exec rubocop with the project's .rubocop.yml stops with exit 2 on master and on every commit before inspecting anything, because RuboCop 1.75.6 (the version Gemfile.lock pins) refuses its AlignHash and Style/TrailingCommaInLiteral keys as obsolete. With a copy of the config that renames them as the error asks, over lib, spec, examples, Rakefile and the gemspec:

  • With TargetRubyVersion: 2.2 as configured, master and the first two commits have 270 offenses, and the third and fourth 273. The 3 new ones are Lint/Syntax in the Push spec: its new example uses a squiggly heredoc, like the histogram example above it, and the Ruby 2.2 parser cannot read one, which is also why master already reports 14 Lint/Syntax offenses in that file and no other cop runs on it.
  • With TargetRubyVersion: 3.2, the gemspec's required_ruby_version, master and the first commit have 373 offenses and the other three 372, and none falls on a line this MR adds.
The issue's example against a Pushgateway

The issue's code with the gem's default MmapedValue, pushed to prom/pushgateway 1.11.4 in Docker, Ruby 3.4.11.

On master both calls raise, so nothing reaches the Pushgateway:

--- Formats::Text.marshal(registry)
NoMethodError: undefined method 'map' for an instance of Float
/src/lib/prometheus/client/formats/text.rb:61:in 'Prometheus::Client::Formats::Text.summary'
--- Push#add
NoMethodError: undefined method 'map' for an instance of Float
/src/lib/prometheus/client/formats/text.rb:61:in 'Prometheus::Client::Formats::Text.summary'
--- what the Pushgateway serves for the job

On this branch:

--- Formats::Text.marshal(registry)
# HELP request_seconds Request time
# TYPE request_seconds summary
request_seconds_sum{route="x"} 0.75
request_seconds_count{route="x"} 2.0
--- Push#add
HTTP 200
--- what the Pushgateway serves for the job
# HELP request_seconds Request time
# TYPE request_seconds summary
request_seconds_sum{instance="",job="example",route="x"} 0.75
request_seconds_count{instance="",job="example",route="x"} 2

Formats::Text.marshal_multiprocess gives request_seconds_count{route="x"} 2 and request_seconds_sum{route="x"} 0.75 on both.

Merge request reports

Loading
Loading