Skip to content

prometheus: fix the exposition so histograms, counters and types are valid - #232

Open
sathvik09 wants to merge 8 commits into
mainfrom
prometheus-exposition-fixes
Open

sathvik09 wants to merge 8 commits into
mainfrom
prometheus-exposition-fixes

Conversation

@sathvik09

Copy link
Copy Markdown

Summary

The prometheus handler accepts everything the stats API produces, and much of what it exposes is not valid Prometheus. Most seriously, no histogram it publishes can be evaluated by histogram_quantile() — it never emits a +Inf bucket, and by default it emits no _bucket series at all. Counters are also published as unknown under OpenMetrics because they lack the _total suffix the encoder keys on.

This fixes the exposition. Handler keeps its name, its fields and its place in stats.MultiHandler; consumers get the fix on a version bump with no application changes.

Other handlers (datadog, influxdb, otlp, veneur) are untouched.

⚠️ Breaking for anyone scraping this package

Nothing fails to compile, but the published series change:

  • every counter is renamed with a _total suffix
  • timestamps are no longer exposed, which changes staleness behaviour
  • histograms that previously published no buckets now publish them

HISTORY.md carries the full entry.

The defects

# Defect Effect
1 makeMetricBuckets allocated exactly len(buckets) entries and never appended an overflow bucket histogram_quantile() returns NaN unless the highest bucket is +Inf. Observations above the top boundary were counted in _sum/_count but landed in no bucket
2 stats.Buckets is empty by default and a miss returned a nil slice with no error collect ranged over it zero times and wrote no _bucket series, while _sum/_count were emitted unconditionally — so nothing looked wrong
3 label.less compared values as raw strings +Inf sorted first (+ is ASCII 43, digits start at 48) and 10 sorted ahead of 2
4 WriteStats deduplicated # TYPE on the bare field name, scope discarded Same-named fields from different engine prefixes looked like repeats; every one after the first ingested as untyped. Deriving sub-engines with WithPrefix exists precisely so subsystems can reuse short names like hits, so this fired readily
5 appendMetric wrote an explicit timestamp A series carrying one opts out of stale-marker handling: the scraper keeps serving its last value for 5 minutes after the series stops being exported
6 Counters had no _total suffix The OpenMetrics encoder keys the type line on the suffix, so counters published as unknown
7 Observe and Buckets.Set name the same metric differently Observe takes a name relative to the engine; Set needs the fully-qualified name. A mismatch is an ordinary map miss — a mistyped key and no key at all produce identical output, so the histogram silently loses its buckets

Two things worth reviewer attention

The +Inf fix is not one line. metricState.update rebuilds the bucket set when len(state.buckets) != len(buckets). Appending +Inf makes the stored slice permanently one longer than the registry slice, so without moving that check every observation reallocates and zeroes the counts — _count climbing while every _bucket stays at 0 or 1. That is worse than the defect being fixed, so both changes are in one commit with a regression test.

The # TYPE fix is two changes that must land together. Dedup on scope + root name, and sort by scope before name. Sorting alone leaves the unscoped dedup suppressing types across a scope boundary. Deduping alone is worse than the defect: with the old ordering a histogram's _bucket series group by boundary across every scope while _count and _sum sort away from them, so one family declares its type thirteen times instead of once. There is a test for each half failing on its own.

New API

Engine.SetBuckets(name string, buckets ...any) derives the registry key from the engine's own prefix, so callers pass the same string they pass to Observe and the two cannot drift. A WithPrefix sub-engine computes its own key, removing the one-registration-per-derived-prefix problem. Additive — HistogramBuckets.Set is unchanged.

engine := stats.NewEngine("app", prometheus.DefaultHandler)
engine.SetBuckets("request.latency", 0.005, 0.01, 0.025, 0.05, 0.1, 0.5, 1)
engine.Observe("request.latency", elapsed)

prometheus.DefaultBuckets is the fallback for histograms with nothing registered — the reference client's boundaries, suited to latencies in seconds. It is a floor that keeps percentiles computable, not a substitute for choosing boundaries.

Verification

  • go test ./..., go vet ./..., gofmt -l — clean
  • go test -race ./prometheus/... . — clean
  • Every fix is pinned by a test that fails without it, checked by reverting each one individually rather than assumed
  • Output parsed by the reference parser (prometheus/common/expfmt, in a throwaway module so go.mod is untouched): counters parse as COUNTER, both sub-engine families typed, histogram parses as HISTOGRAM with +Inf == _count, buckets strictly increasing, no timestamps. Quantiles over 100 deterministic observations come out exact — p50 0.505, p95 0.9595, p99 0.9999, no NaN

Note

FieldType's zero value is Counter, and reportVersionOnce builds bare Field{} literals rather than calling MakeField, so the internal version metrics are counters and are renamed to go_version_value_total / stats_version_value_total. Consistent with the rule, though they are semantically info metrics. Left alone — changing their type is a separate decision.

🤖 Generated with Claude Code

sathvik09 and others added 8 commits September 19, 2026 03:54
makeMetricBuckets allocated exactly len(buckets) entries and never
appended an overflow bucket, so observations above the highest registered
boundary were counted in _sum and _count but landed in no bucket at all.
histogram_quantile() returns NaN unless the highest bucket has an upper
bound of +Inf, so no histogram on this path could be evaluated.

The rebuild check in metricState.update has to move with it. The stored
bucket set is now one entry longer than the registry slice, so comparing
against len(buckets) never matches: every observation would reallocate
the bucket set and discard the counts, leaving _count climbing while
every _bucket stayed at 0 or 1. That is worse than the defect being
fixed, which is why both changes are in one commit.

+Inf currently sorts ahead of every numeric boundary because label values
compare as raw strings and '+' is ASCII 43 while digits start at 48. The
golden tests record that ordering; a follow-up commit fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
label.less compared label values as raw strings, so "+Inf" sorted ahead
of every boundary ('+' is ASCII 43, digits start at 48) and "10" sorted
ahead of "2". byNameAndLabels.Less only delegates here, so histogram
buckets came out in the wrong order.

OpenMetrics requires buckets in increasing order; the text format is
indifferent, but the ordering is also what makes the exposition readable
and matches every other Prometheus client.

The comparison is shared by every label, so the numeric path is scoped
to "le" rather than applied wholesale, and falls back to string
comparison when a value does not parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stats.Buckets is empty by default, and the lookup in HandleMeasures
returned a nil slice with no error. collect() then ranged over it zero
times and wrote no _bucket series, while _sum and _count were emitted
unconditionally — so a histogram with no registered boundaries looked
healthy and had no percentiles.

DefaultBuckets holds the boundaries used by the reference Prometheus
client. stats converts Duration values to seconds before bucketing, so
timing histograms land on this range without configuration.

This is a floor, not a replacement for choosing boundaries: a histogram
whose values sit outside the range lands entirely in +Inf. What changes
is that the failure is now visible in the exposition rather than absent
from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WriteStats deduplicated "# TYPE" lines on the bare field name with the
scope discarded, so same-named fields arriving from different engine
prefixes looked like repeats of each other and every one after the first
was emitted untyped. Deriving sub-engines with WithPrefix is idiomatic
across Segment services and exists precisely so subsystems can reuse
short field names, so this fires readily: three sub-engines exposing
hits and size shipped four of six metrics with no type.

The dedup key becomes the scope and the root name together, and
byNameAndLabels.Less orders by scope before name so that each family
stays contiguous.

Both halves are required. Sorting alone leaves the unscoped dedup
suppressing types across a scope boundary. Deduping alone is worse than
the defect: with the old ordering a histogram's _bucket series group by
boundary across every scope while _count and _sum sort away from them,
so one family declares its type thirteen times instead of once. Tests
cover each half failing on its own.

Less compares scope and name in turn rather than the joined string to
avoid allocating per comparison in the sort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
appendMetric wrote metric.time as an explicit timestamp on every sample.
The field is optional in the exposition format, and a series that
carries one opts out of Prometheus stale-marker handling: once the
series stops being exported the scraper keeps serving its last value for
five minutes rather than letting it go stale. An idle metric therefore
looked live long after it stopped reporting.

Dropping it lets the scraper assign scrape time, which is the behaviour
every other exporter has. metric.time stays on the struct, where
MetricTimeout and the store cleanup still depend on it.

This changes staleness behaviour for anyone already scraping this
package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Incr("requests") produced app_requests. Prometheus names an
accumulating count with a "total" suffix, and the convention is load
bearing: the OpenMetrics encoder keys the type line on the suffix, so a
counter without it is published as unknown rather than as a counter.

The suffix is applied in newMetricEntry alongside the cached _bucket,
_sum and _count names for histograms, so it covers every collection path
at once. The store key keeps the raw field name, so nothing about
lookup, state identity or cleanup changes — only what collect() emits.

A name already ending in _total is left alone, so a program that has
already adopted the convention does not produce requests_total_total.

This renames every counter on this path for anyone already scraping the
package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Observe and Buckets.Set name the same metric differently. Observe takes
a name relative to the engine and has the prefix attached after the name
is split; Set attaches no prefix and merely splits what it is handed, so
it needs the fully-qualified name. Registering buckets therefore means
restating the engine prefix, and getting it wrong is an ordinary map
miss: a mistyped key and no key at all produce identical output, so the
histogram silently loses its buckets with no error anywhere.

Deriving sub-engines with WithPrefix makes this worse, since buckets
then have to be registered once per derived prefix, and services derive
a dozen.

SetBuckets moves key construction to the engine, which is the only thing
that knows its own prefix. Callers pass the same string they pass to
Observe, so the two cannot drift, and a sub-engine computes its own key.

Additive: Buckets.Set is unchanged and keeps working. The test reads the
expected key back out of what Observe actually emitted rather than
restating the derivation, so it fails if either side changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HISTORY.md leads the v5.11.0 entry with the breaking change, since
nothing fails to compile but every counter is renamed and staleness
behaviour changes for anyone already scraping the package.

The README gains the bucket registration the handler now needs, using
Engine.SetBuckets. Snippet compile-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant