Skip to content

feat(stats): query time histogram in OpenMetrics and OTLP - #1450

Open
alexkarp-umd wants to merge 22 commits into
pgdogdev:mainfrom
alexkarp-umd:metrics/query-time-histogram
Open

feat(stats): query time histogram in OpenMetrics and OTLP#1450
alexkarp-umd wants to merge 22 commits into
pgdogdev:mainfrom
alexkarp-umd:metrics/query-time-histogram

Conversation

@alexkarp-umd

@alexkarp-umd alexkarp-umd commented Aug 27, 2026

Copy link
Copy Markdown

Fixes #1409.

PgDog reports query latency via simple aggregates (total_query_time, avg_query_time), so there's no way to calculate percentiles. This changeset adds a per-pool histogram of individual query durations to both OpenMetrics and OTel, so histogram_quantile() works:

histogram_quantile(0.99, sum by (le, shard, database) (rate(query_time_seconds_bucket[5m])))

This PR adds:

Implementation

Recording. Stats::query() observes each query duration into a fixed-bucket Histogram in the server's local counters: a plain array of u64, so there's no allocation and no lock on the query path. It's drained into the pool on check-in alongside the existing counts, the same way last_checkout already works.

Config. query_time_buckets in [general] — upper bounds in milliseconds, also readable from PGDOG_QUERY_TIME_BUCKETS. Default [0.1, 0.3, 1, 3, 10, 30, 100, 300, 1000, 3000, 10000, 30000] (100µs → 30s): 13 bucket series per pool including +Inf, plus _sum and _count.

Notes

  1. This PR depends on [Metrics] Datapoints follow OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE #1293. My changes add histograms as a third metric shape alongside that PR's counter/gauge work: it forks to HistogramDataPoint in build_request_with_state, reuses build_attributes unchanged, and stores previous bucket counts in CounterState rather than a second global. The histogram commits sit on top of 92ddcf3c; the rest are [Metrics] Datapoints follow OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE #1293's.
  2. pgdog-stats/src/histogram.rs is mostly self-contained business-logic/implementation. Most of the rest is plumbing to carry a second value alongside Counts through check-in.
  3. otel.rs had to be adapted since histograms can't be a NumberDataPoint. value_for_data_point and wrap_data_points return None for Histogram and the caller forks to histogram_data_point. I'm not sure if this is the best approach but it does work.
  4. query_time_buckets. changing this config option requires a restart. It is not updated by a hot-reload.

Follow-ups

  • Updates to the pgdogdev/docs repo
  • Updates to the pgdogdev/helm repo
  • A latency percentile panel for examples/grafana_prometheus.
  • (eventually) Exponential histogram support for both OpenMetrics and Otel.

AI use disclosure

LLMs wrote most of the code in this PR. I'm an SRE who wants this feature but I have limited Rust experience. I was thorough about designing and writing the tests. But I encourage you to review this code with extra skepticism.

KennanHunter and others added 22 commits August 7, 2026 13:14
…FERENCE in both [otel] section and env var form
Make Otel::temporality_preference an Option; in ConfigAndUsers::load
default it to Cumulative, or Delta when datadog_api_key is set. Move the
Datadog-cumulative warning into ConfigAndUsers::check (with the match
arm collapsed to a guard for readability). Add schema-only defaults so
the generated JSON schema keeps the documented values instead of the
derived Default (0 / null).
Moves the datadog-implied mapping into
Otel::effective_temporality_preference() and calls it from the OTLP
request builder. Fixes tests that install a config directly bypassing
ConfigAndUsers::load
Adds a `Histogram` to `pgdog-stats` and records every completed query into
it. Bucket bounds are latched process-wide at startup from the new
`general.query_time_buckets` setting: histograms are indexed by position, so
re-bucketing at runtime would silently reinterpret already-recorded samples.
That keeps `Histogram` `Copy` and lets pool counts merge element-wise.

Only `last_checkout` is bucketed, since that is what merges into the pool on
check-in; bucketing `total` as well would double-count every sample.

Nothing exports the histogram yet.
Adds `MeasurementType::Histogram` and renders it as the `_bucket`/`_sum`/
`_count` series the OpenMetrics spec expects, with cumulative counts and a
trailing `le="+Inf"`. A measurement can now render as several lines, so
`Metric`'s `Display` prefixes each one.

Bounds are formatted to nine decimals rather than via `{}`: Prometheus
rejects scientific notation, and two distinct bounds collapsing to the same
`le` label would fail the whole scrape.

Also adds the `OpenMetricType::Histogram` variant, which makes the OTLP
exporter's matches non-exhaustive. Both arms return nothing for now rather
than a misleading scalar, so the OTLP endpoint omits the metric entirely
until the next commit gives it a real data point.
Emits a real OTLP `Histogram` data point rather than flattening the
distribution to a scalar. Bucket counts are converted from cumulative to
per-bucket, and 64-bit integers are serialized as decimal strings per
OTLP/JSON.

Deltas are computed against the previous export. Unlike counters, which emit
a full delta against zero on first sight, the first export of a histogram
series is skipped: reporting a lifetime bucket distribution as one interval
would skew per-interval percentiles.
Exporters branch on metric_type() and then pattern-match each
measurement, so a disagreement fails silently: a histogram under a
Gauge metric renders as a bare number or exports as 0.0 over OTLP,
and scalars under a Histogram metric are dropped.

Add MeasurementType::matches and a debug_assert! in Metric::new that
rejects the mismatch at construction time, with should_panic tests
covering both directions.
…ters

Pools::load called Bounds::seconds() inside the per-pool loop and
histogram_data_point cloned the bounds Vec again per data point: two
Vec allocations per pool per scrape for process-constant data.

Convert the bounds to seconds once per scrape and carry them as
Arc<[f64]> on HistogramMeasurement and HistogramDataPoint, so every
pool's measurement and both the OpenMetrics render and OTLP paths
share the single allocation. OTLP JSON output is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review raised a set of correctness and API issues across the histogram
series. None of them change an exported metric value.

Recording and configuration:

- Bounds::defaults() fell back to a zero-length ladder if the built-in
  bounds failed to parse, which would silently file every sample under
  +Inf. Those bounds are a const that parse cannot reject, so expect()
  it and pin the invariant with a test rather than degrade quietly.
- Bounds are deduplicated on the f64 seconds they are exported as,
  rather than on Duration. Past roughly 10^7 seconds an f64 cannot
  resolve nanoseconds, and two bounds that render the same `le` label
  fail the entire Prometheus scrape.
- set_bounds returns a Latch describing what happened instead of a bare
  bool. Losing the latch to a bounds() read before the configuration
  loaded is not the same as an operator changing the setting across a
  reload: a restart does not fix the first, and the warning now says so
  instead of sending the operator round a loop.
- PGDOG_QUERY_TIME_BUCKETS discards the whole ladder when any element
  fails to parse. Keeping whichever values happened to parse built a
  histogram the operator never asked for, and every other setting in
  General is already all-or-nothing.
- AddAssign merges in place and Add is defined in terms of it, rather
  than copying the histogram out and back.
- Histogram::buckets debug-asserts that the counts it returns sum to
  count, since _bucket and _count reach the wire by separate routes.

Export:

- MeasurementType boxes its Histogram variant. The variant had taken
  the enum from 16 to 64 bytes, and every scalar measurement paid that
  padding on every scrape.
- HistogramMeasurement stores per-bucket counts, matching its source.
  Cumulative counts are a property of the OpenMetrics `le` format, so a
  cumulative() helper derives them at render time and the OTLP path no
  longer un-accumulates what the OpenMetrics path had accumulated.

OpenMetrics text and OTLP JSON are unchanged for the same input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XYLvjiaYCeQa7RvLEs4n8
Invalid bounds were dropped and the rest kept, so `[1, 2, -3, 4]` started
PgDog with a three-bucket histogram and nothing in the exported metrics
said which bound went missing. An operator who typos one value should
hear about it, not scrape buckets they never configured.

Bounds::from_millis, from_millis_checked and the Normalized enum are
replaced by a single try_from_millis returning Result<Bounds,
BoundsError>. A ladder is taken whole or not at all: every value must be
finite, greater than zero and small enough for a Duration, and there
must be at least one and no more than MAX_BUCKETS of them.

Sorting and deduplication stay silent. Neither changes which bounds were
asked for, so neither is worth failing over.

config::set now propagates the failure, alongside check() and
validate_lookup_queries(), so a bad ladder refuses startup and a bad
reload leaves the running configuration untouched.

This covers pgdog.toml only. A malformed PGDOG_QUERY_TIME_BUCKETS still
falls back to the defaults without complaint, because every environment
variable in General does — they are read through serde defaults, which
have no channel to report a failure, and test_env_invalid_enum_values
pins that behaviour deliberately. The asymmetry is documented on both
the field and set_histogram_bounds rather than left to be discovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XYLvjiaYCeQa7RvLEs4n8
- Drop `Add`/`Sub` for `Histogram`: pooling merges with `AddAssign`, and
  the OTLP delta exporter computes its own differences, so the operators
  and their tests were dead.
- Take owned per-bucket counts in `HistogramMeasurement::new` so the pools
  exporter moves the vector instead of cloning it.
- Name the OTLP push interval (`DEFAULT_PUSH_INTERVAL`) instead of three
  `10_000` literals.
- Correct the `DEFAULT_BOUNDS_MS` comment, which had the dependency
  direction backwards.

Co-Authored-By: Claude <noreply@anthropic.com>
The `configured` flag picks between two distinct operator-facing warnings —
`AlreadySet` (a restart applies the new ladder) and `DefaultedByRead` (a
restart repeats the same ordering, so it loops). That branch sat inside the
`OnceLock` wrapper, untested. Extract it into `LatchedBounds::conflict()`
so the decision can be pinned down without touching the process-global
latch.

Co-Authored-By: Claude <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Aug 27, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Metrics] Query latency histogram

3 participants