Skip to content

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

Merged
sathvik09 merged 12 commits into
mainfrom
prometheus-exposition-fixes
Sep 23, 2026
Merged

sathvik09 merged 12 commits into
mainfrom
prometheus-exposition-fixes

Conversation

@sathvik09

@sathvik09 sathvik09 commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

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 — including the library's own go_version_value and stats_version_value
  • timestamps are no longer exposed, which changes staleness behaviour
  • histograms that previously published no buckets now publish them, which takes a histogram from 2 series to 14 (11 boundaries, +Inf, _sum, _count), per label set. stats.Buckets is empty unless a program populates it, so this applies to every histogram without registered boundaries. Counters and gauges are unaffected — one series each, as before

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
8 makeMetricBuckets appended +Inf without checking whether the registered set already ended with it Two le="+Inf" series with identical labels, the second unreachable. Ending a set with math.Inf(+1) is the idiom used by every registration in httpstats, netstats and procstats
9 The registry-miss check tested buckets == nil HistogramBuckets.Set allocates with make, so an empty registration stored a non-nil zero-length slice that bypassed the fallback and left the histogram with a lone +Inf bucket
10 WriteStats suppressed a repeated # TYPE by comparing each metric against its predecessor A family interrupted in the sorted output was declared twice, which makes the text format parser reject the whole exposition — the entire scrape fails and up goes to 0

Three things worth reviewer attention

Defect 10 is a regression this PR would otherwise have introduced. The # TYPE dedup assumes every family is one unbroken run in the sorted output. Ordering by scope guarantees a scope is contiguous, but not a root name within it — the sort orders on the series name while a family is keyed on the root. A histogram q emits q_bucket, q_count, q_sum, so a sibling q_bytes lands between the first two and q_count declares the type again.

Fixing defect 2 widened this. An unregistered histogram used to emit no _bucket series, so its family began at _count and siblings sorting ahead of that were harmless. With bucket series present the family begins at _bucket, and siblings in [_bucket, _count) now fall inside it. Measured against v5.10.0:

sibling of histogram q v5.10.0 defect 2 alone with defect 10 fixed
q_bytes, q_calls ok scrape fails ok
q_depth, q_errors, q_size scrape fails scrape fails ok

Tracking declared families in a set removes the dependency on sort contiguity rather than tightening it. metricKey already pairs scope and name. The sort stays — it is still what keeps families grouped and the output readable, but readability is now all it is responsible for.

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 same invariant is why defect 8 is fixed at the lookup site rather than inside makeMetricBuckets.

The # TYPE scope 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)

It writes to the global stats.Buckets, which is an unsynchronised map handlers read on every histogram measure, so it must be called from init or program setup. The doc comment leads with that.

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. Defect 8's placement is additionally pinned by an accumulation test that fails under the alternative placement, which would have reintroduced the rebuild-per-observation bug
  • 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
  • Duplicate # TYPE confirmed to be rejected by the reference parser (second TYPE line for metric name), and confirmed absent after the fix across the sibling matrix above

Notes

Version metrics. 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.

Not fixed here: the bucket registry key separator. HistogramBuckets.Set splits the key on the last ., but the registrations in httpstats, netstats and procstats are written "http.message:header.size" — the : form that splitMeasureField used before b45dd38 ("fix typo in splitMeasureField()", Aug 2019) changed it. Splitting those strings on : reproduces the keys the handler looks up exactly; splitting on . does not, so all seven first-party registrations have been inert since. That is why defect 8 is dormant in this repo today — and why its guard lands here, before anything arms it. The key fix is a separate change: makeKey needs the : form while measureOne needs the . form, and Engine.SetBuckets should build the stats.Key directly rather than round-tripping through a string it then re-parses.

🤖 Generated with Claude Code

sathvik09 and others added 12 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>
…second

makeMetricBuckets appends a +Inf bucket unconditionally, without checking
whether the last registered boundary already is one. Ending a bucket set
with math.Inf(+1) is the idiom used by every registration in httpstats,
netstats and procstats, and it produced two le="+Inf" series: identical
labels, identical cumulative value, the second unreachable because
metricBuckets.update stops at the first match. Prometheus drops the
duplicate and counts it in
prometheus_target_scrapes_sample_duplicate_timestamp_total.

Those seven registrations are inert today for an unrelated reason — the
registry key separator changed from ':' to '.' in b45dd38 and the keys no
longer match what the handler looks up — so this fires only for callers
whose keys do match. Fixing the key lookup arms it across all three
packages at once, which is why the guard goes in first.

The trim is at the lookup site rather than in makeMetricBuckets because
metricState.update decides whether to rebuild by comparing the stored set
against len(buckets)+1. A makeMetricBuckets that sometimes returned
len(buckets) entries would never match, so every observation would
reallocate the bucket set and discard the counts. buckets[:n-1] is a slice
header over the same array, so nothing is allocated on the measure path.

The registry miss check becomes len(buckets) == 0. HistogramBuckets.Set
allocates with make, so registering an empty boundary list stored a
non-nil zero-length slice that bypassed the DefaultBuckets fallback and
left the histogram with a lone +Inf bucket.

Each test was checked by reverting its fix individually. The accumulation
test covers the rebuild-per-observation regression that the alternative
placement would have introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WriteStats suppressed a repeated "# TYPE" by comparing each metric against
its predecessor, which assumes every family is one unbroken run in the
sorted output. Ordering by scope guarantees a scope is contiguous, but not
a root name within it: the sort orders on the series name while a family
is keyed on the root, and those are not the same ordering. A histogram "q"
emits q_bucket, q_count and q_sum, so a sibling "q_bytes" in the same
scope lands between the first two, overwrites the one-metric memory, and
q_count declares the type a second time.

A repeated declaration is not a dropped sample. The text format parser
rejects the whole exposition — "second TYPE line for metric name" — so
Prometheus discards the entire scrape: every series from that target is
lost and up goes to 0.

The preceding commit that falls back to DefaultBuckets widened this. An
unregistered histogram used to emit no _bucket series at all, so its
family began at _count and any sibling sorting ahead of that was harmless.
With bucket series present the family begins at _bucket, and siblings
in [_bucket, _count) now fall inside it — _bytes, _calls, _cache, _conn.
Verified against v5.10.0: q_bytes and q_calls were previously fine and
would have started failing, while q_depth, q_errors and q_size were
already broken and are now fixed too.

Tracking declared families in a set removes the dependency on contiguity
rather than tightening it. metricKey already pairs scope and name and is
already the store's key, so no new type is needed. The sort stays: it is
still what keeps families grouped and the output readable, but readability
is now all it is responsible for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
appendMetric no longer emits a timestamp, so the time copied onto every
collected metric in metricState.collect has no reader on the output path.
It was four dead stores per histogram series per scrape.

The field stays on the metric struct because the input path needs it:
HandleMeasures sets it from the measure time and metricStore.update
carries it into state.time, which is what MetricTimeout expiry compares
against in cleanup. A comment records that, so the assignments are not
restored by someone reading collect() in isolation.

TestMetricStoreCleanup asserted on the collected time. What that test
covers is which entries survive expiry — the input times drive it, the
collected copy was incidental — so the expectation drops the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Handler's own doc comment still said the handler ignores histograms with
no buckets set, which is what the DefaultBuckets fallback removed. It is
the first thing a reader sees on the package page.

HISTORY.md gains the two facts a consumer cannot derive from the entry as
written. The bucket fallback is quantified: a histogram that published two
series now publishes fourteen, per label set, and stats.Buckets is empty
unless a program populates it, so it applies broadly. Counters and gauges
are called out as unaffected, since the entry otherwise reads as a general
cardinality change. The "# TYPE" entry covers the duplicate-declaration
half as well as the missing-declaration half, because a repeated type line
fails the whole scrape rather than mistyping one metric, and the _total
entry names the version metrics the library renames about itself.

Engine.SetBuckets leads with the constraint instead of closing on it.
stats.Buckets is an unsynchronised map that handlers read on every
histogram measure; the method's receiver makes it read like an instance
call that is safe wherever the engine is in scope, and the one way to
misuse it is a concurrent map write rather than a silent miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sreejitkar

Copy link
Copy Markdown

Review Summary

The core exposition fixes hold up under trace and scratch tests: the +Inf bucket, numeric le ordering, scope-aware # TYPE dedup and the len(buckets)+1 rebuild check. go vet and go test -race pass at 83be4c5. Three problems remain, each found by at least two agents. The new DefaultBuckets fallback reaches this repo's own byte-valued histograms. The _total rename double-suffixes dotted names and can merge two fields into one series. The SetBuckets docs misdescribe how sub-engines behave. 3 findings: 0 critical, 3 high.

Recommendation: COMMENT

(The findings alone would recommend REQUEST_CHANGES. This repo has not opted into formal blocks, so the review posts as a comment.)

🔎 Suggested Areas for Human Review

These areas are the ones most likely to resist casual review — not an exhaustive list of everything worth checking. Review the rest of the diff as you normally would.

  • prometheus/handler.go:171-212: WriteStats dedups # TYPE on the unrendered metricKey{scope, rootName}, but the rendered name is scope + "_" + name with . changed to _. Scope a with name b_c and scope a_b with name c both render as a_b_c and would each get a # TYPE line.
  • prometheus/metric.go:280-290: the len(state.buckets) != len(buckets)+1 check is only correct if every caller trims a trailing +Inf first, and that trim lives in prometheus/handler.go (around line 101).
  • engine.go:130-163: SetBuckets builds its key from makeName(name) and re-splits it, while measureOne splits first and prefixes after. The two keys agree only while concat, splitMeasureField and makeKey agree on separators.
  • prometheus/metric.go:465-488: sorting by scope before name reorders the whole exposition. Check that no consumer depends on the old global name order.

📋 PR Readiness Checklist

Proof-point Status Note
Automated tests ✅ present About 14 new tests, one per defect or invariant (e.g. TestMetricStateBucketsNotRebuilt, TestTypeDeclaredOnceWhenFamilyIsInterrupted, TestEngineSetBuckets*).
CI / test-run output ⚠️ partial The body says go test -race, go vet and gofmt are clean but links no run. Link the Actions run for 83be4c5.
Reference-parser validation ⚠️ partial The expfmt parse and the quantile numbers (p50 0.505, p99 0.9999) come from a throwaway module that is not in the diff. In-tree tests only match substrings. Attach a sample exposition or the module's source.
Sibling-matrix before/after ⚠️ partial The v5.10.0 matrix covers 5 sibling names. In-tree, only query_depth is tested. Turning that test into a table over all 5 would back the claim with code.
Rollback / opt-out note ⚠️ partial HISTORY lists every rename and the 2→14 series growth, but gives no way to cap the default buckets or opt out of them. Emptying DefaultBuckets still emits a lone +Inf bucket.

Reverify Drops

  • ⬇️ Late bucket registration ignored or corrupts histogram — prometheus/metric.go:286: downgraded high→medium. The length-only rebuild check predates this PR (dc7caac metric.go:268). The scenario also requires registering after observations start, which the SetBuckets doc forbids (engine.go:136-138).
  • ⬇️ Dropping timestamps keeps stale series alive — prometheus/append.go:37: downgraded high→medium. Omitting timestamps is standard exporter behaviour, the PR discloses it as breaking, and the every-10K-ops cleanup cadence is unchanged from dc7caac handler.go:96-97. A HISTORY note would still help, because "MetricTimeout is unaffected" undersells the change.
  • ⬇️ HISTORY says no old histogram could be evaluated — HISTORY.md:25: downgraded high→medium. The claim is inaccurate: dc7caac makeMetricBuckets already emitted le="+Inf" for sets ending in math.Inf(+1). It is a changelog wording error with no runtime impact.
  • ⬇️ _total rename's OpenMetrics rationale — prometheus/metric.go:166: downgraded high→medium. The handler serves only text/plain; version=0.0.4 (handler.go:156), so the rationale is doubtful. The rename still follows Prometheus convention and is disclosed as breaking.
  • ⬇️ Engine.SetBuckets arrives before the registry-key fix — engine.go:161: downgraded high→low. The API is additive and the concern is about sequencing.
🟠 High Findings (3)

DefaultBuckets fallback puts the library's own byte histograms on seconds buckets — prometheus/handler.go:85

[Found by 3 agents: bug-hunter, intent-reviewer, silent-failure-hunter]

if len(buckets) == 0 { buckets = DefaultBuckets } fires for every unregistered histogram, whatever its unit. That includes this repo's httpstats and netstats histograms. Their registrations never match the key HandleMeasures looks up, for two reasons. httpstats/metrics.go registers "http.message:body.bytes", which splits on the last . into {Measure: "http.message:body", Field: "bytes"}, while the package emits {Measure: "http.message", Field: "body.bytes"}. netstats has the same mismatch. The lookup also uses the prefixed m.Name, and DefaultEngine is prefixed with progname().

After this PR, each of those histograms publishes 11 boundaries from 0.005 to 10 plus +Inf. A scratch run with one 5000-byte response showed every finite bucket at 0 and +Inf at 1. httpstats has 6 byte histograms and netstats has 2, and httpstats label sets include path, host and status. The result is about 12 extra near-empty series per histogram per label set. histogram_quantile then reports a plausible-looking 10 where it used to report nothing. The PR body calls these registrations inert and defers the key fix. Merging the fallback first ships the cardinality cost to exactly the users of those packages. The handler.go:89-94 comment also describes these bucket sets as in use.

Suggestion: Fix the httpstats and netstats registration keys in this PR (build stats.Key directly), or make the fallback opt-in (Handler.DefaultBuckets, nil by default) or limit it to stats.Duration fields. The unconditional +Inf bucket already makes the exposition valid without default boundaries. Add a test that runs httpstats through prometheus.Handler and asserts non-default le values.
high confidence

_total suffixing double-suffixes dotted names and merges distinct fields — prometheus/metric.go:166

[Found by 2 agents: code-reviewer, bug-hunter]

newMetricEntry checks strings.HasSuffix(name, "_total") against the raw field name. That name has not been sanitized yet, and the scope is not part of it.

  • e.Incr("requests.total") splits into measure requests and field total, so it publishes app_requests_total_total. Reproduced on the PR head. The repo uses this style itself (otlp/example_test.go:155), and the code comment at metric.go:163-165 promises that exact case is left alone.
  • The store still keys on the input name, so a counter hits and a sibling field hits_total in the same scope now render the same name. e.Incr("svc.hits"); e.Set("svc.hits_total", 5) produced one # TYPE line followed by two app_svc_hits_total samples with identical labels. The scraper drops one sample silently, and one value appears under the other's type. Before this PR these were two valid families. TestMetricStore now expects A and A_total to merge, which locks the collision in.

Suggestion: Run the suffix check against the name as it will be exposed. Treat a bare field total, and any name whose sanitized form ends in _total, as already suffixed. Detect the collision when a rendered family name is already declared, or at least document it next to the _total entry in HISTORY.md. Add cases for Incr("x.total") and for x together with x_total.
high confidence

SetBuckets docs say sub-engines don't need their own registration — engine.go:157

[Found by 2 agents: bug-hunter, doc-grounding-reviewer]

engine.go:157-158 and README.md:220-222 both say a WithPrefix sub-engine "computes its own key, so buckets no longer have to be registered once per derived prefix." The implementation is Buckets.Set(e.makeName(name), ...), so the key still carries the full prefix of the engine it is called on. A scratch test on the PR head called e.SetBuckets("latency", 0.5, 7) followed by e.WithPrefix("db").Observe("latency", 1.0). It produced the registered boundaries for app_latency, while app_db_latency silently fell back to the 11 DefaultBuckets. A reader who follows the doc gets exactly the silent miss this PR sets out to remove.

Suggestion: Reword both places: "Call SetBuckets on the same engine or sub-engine that calls Observe. You pass the same short name and never restate the prefix, but each WithPrefix sub-engine needs its own call." Also note that a Handler with its own non-nil Buckets ignores SetBuckets.
high confidence

📋 Agents Dispatched

Ran: code-reviewer, bug-hunter (2 passes), intent-reviewer, reviewability-reviewer, language-pattern-reviewer, silent-failure-hunter, doc-grounding-reviewer, security-scanner
Skipped: security-reviewer (no auth, input handling or sensitive data), dependency-scanner (go.mod unchanged), test-analyzer (~580 lines of tests included), architecture-reviewer (one additive method), type-design-reviewer (no new types of substance), docs-validator (covered by doc-grounding-reviewer), simplification-reviewer (focused change), comment-analyzer (small comment changes), api-standards-reviewer (no REST surface), library-opportunities-reviewer (no duplication)
Complexity tier: standard

security-scanner found no static scanners installed (gosec is the relevant one for Go). Its secret sweep of all 12 changed files returned 0 candidates.


Reviewed by review-toolkit

@sreejitkar sreejitkar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comments from review-toolkit agents.

Comment thread prometheus/handler.go
// _count but no _bucket series at all — nothing looked wrong,
// and no percentile could be computed. Fall back so that a
// histogram is never silently bucket-less.
if len(buckets) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] DefaultBuckets fallback puts the library's own byte histograms on seconds buckets

[Found by 3 agents: bug-hunter, intent-reviewer, silent-failure-hunter] This fallback fires for every unregistered histogram, whatever its unit, including the httpstats and netstats byte histograms. Their registrations ("http.message:body.bytes", etc.) never match the {m.Name, f.Name} key looked up at line 71: the : separator is one reason, the engine prefix is the other. A scratch run with one 5000-byte response put every finite bucket at 0 and +Inf at 1. That adds about 12 near-empty series per histogram per label set, and histogram_quantile reports a plausible 10.

Suggestion: Fix the httpstats/netstats registration keys in this PR (build stats.Key directly), or make the fallback opt-in, or limit it to stats.Duration fields. The unconditional +Inf bucket already makes the exposition valid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the first option — fixed the keys — rather than gating the fallback.

There is a third reason the lookup misses, and it rules out the cheap version of that fix: HistogramBuckets.Set cannot express these keys at all. It splits on the last ., and every field involved carries one of its own (body.bytes, header.size, rtt.seconds), so no string yields {Measure: "http.message", Field: "body.bytes"}. The registrations aren't stale, they're unreachable — rewriting the strings was never available.

What landed:

  • HistogramBuckets.SetKey(Key, ...) — the two halves directly.
  • HistogramBuckets.SetUnprefixed(Key, ...) + Lookup(measure, field) — a package registering from init() runs before any engine exists and can't know the prefix its measures will carry, so Lookup falls back to dropping leading segments. Exact registrations always win, and only SetUnprefixed registrations are matched that way. I tried the simpler global walk first and dropped it: it let svc.billing inherit a set registered for an unrelated billing, which is the same silent-wrong-boundaries defect this PR exists to remove, one level along.
  • httpstats and netstats re-registered through SetUnprefixed.

One 5000-byte response:

before   le="0.005" … le="10" all 0, le="+Inf" 1     histogram_quantile → 10
after    le="100" 0  le="1000" 0  le="10000" 1  …    histogram_quantile → interpolated in [1000, 10000)

The fallback stays unconditional, and on the last line of your suggestion — the +Inf bucket makes the exposition valid but not computable. promql.BucketQuantile returns NaN for a lone +Inf bucket (len(buckets) < 2), so opt-in or Duration-gating would have shipped NaN for every unregistered histogram. With the registrations resolving, the fallback now only reaches histograms nobody registered at all, which is the population DefBuckets serves in the reference client.

Left alone deliberately: otlp/handler.go:127 reads the registry with a direct map access and has the identical miss, but this PR states the other handlers are untouched, so it wants its own change. procstats registers "go.memstats:gc_pause.seconds" against fields declared type:"gauge" — inert whatever its key, so I didn't make it look fixed.

Your suggested test is in as TestHTTPStatsBucketsReachTheHandler, with one deviation: it imports httpstats for its init() registrations and feeds the handler the measure httpstats produces, rather than driving a live server — the server path reports asynchronously and needed polling to be reliable. It asserts le="10000" present and le="0.005" absent, and fails if either half of the fix is reverted independently.

Comment thread prometheus/metric.go
// A name that already ends in _total is left alone, so a program that
// has already adopted the convention does not end up with
// requests_total_total.
if !strings.HasSuffix(name, "_total") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] _total suffixing double-suffixes dotted names and merges distinct fields

[Found by 2 agents: code-reviewer, bug-hunter] This check sees the raw field name, so e.Incr("requests.total") (field total) publishes app_requests_total_total, which is the case the comment above promises to leave alone. Because the store keys on the input name, a counter hits next to a field hits_total in the same scope renders two app_svc_hits_total samples with identical labels under one # TYPE line (reproduced on the PR head). One sample gets dropped silently.

Suggestion: Run the check on the name as it will be exposed (a bare total, or a sanitized name ending in _total, counts as suffixed), and detect or document the x/x_total collision. Add tests for both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves addressed — the collision is fixed rather than documented.

Double-suffixing. The check now runs on the name as exposed, through a hasTotalSuffix helper that renders via appendMetricName. A field is joined to its scope by an _, so Incr("requests.total") arrives as the bare field total and already publishes app_requests_total; . renders as _ besides, so a dotted requests.total is pre-suffixed too. subtotal still gets the suffix — no _ boundary before total.

The collision. Resolved at collect time, where the store holds every entry under one lock and can see the whole scope:

// exposedName returns the name entry publishes under.
//
// It is entry.name except where the _total suffix added to a counter would
// land on a name some other field in the same scope already occupies ...
func (store *metricStore) exposedName(key metricKey, entry *metricEntry) string {
	if entry.mtype != counter || entry.name == key.name {
		return entry.name
	}
	if _, taken := store.entries[metricKey{scope: key.scope, name: entry.name}]; taken {
		return key.name
	}
	return entry.name
}

svc.hits beside svc.hits_total now publishes svc_hits and svc_hits_total — two distinct families, both samples kept. Dropping the suffix costs that counter a naming convention; keeping it cost a series.

Three things that made this the shape to pick:

  • Resolution, not detection. The exposition format has no channel for a warning, and dropping one of the two samples is the behaviour being fixed. Making both survive is the only outcome that loses nothing.
  • Order-independent. The result depends only on which names are present, not on which measure arrived first, so a process does not expose different names across restarts. TestCounterTotalSuffixCollision runs both arrival orders.
  • No mutation, no new locking. entry.name stays as computed at creation; metricStore.collect resolves the exposed name and passes it down to metricState.collect, which only ever used entry.name in the counter/gauge branch. Histogram names stay cached as they were.

TestMetricStore updated. You were right that it locked the collision in — its input has both A and A_total in scope test and it expected both as A_total. It now expects A and A_total, with a comment on why.

Tests, both confirmed to fail when the fix is reverted on its own:

  • TestCounterTotalSuffixUsesRenderedName — table over hits, hits_total, total, requests.total, subtotal. Reverting to the raw-name check fails on total (total_total) and requests.total (requests.total_total).
  • TestCounterTotalSuffixDottedName — end to end, asserts app_requests_total and no total_total.
  • TestCounterTotalSuffixCollision — both arrival orders, asserts both families present and exactly one svc_hits_total sample. Reverting exposedName fails it, along with TestMetricStore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the above — I overstated the stability claim, and the gap was real.

I wrote that the result "depends only on which names are present, not on which measure arrived first." That held for arrival order but not for entry lifetime. metricStore.cleanup sweeps entries whose states have all expired past MetricTimeout, and exposedName was consulting the live entries, so once the colliding sibling stopped being reported the counter silently took its name back:

before cleanup             after the sibling expires
svc_hits 1                 svc_hits_total 1
svc_hits_total 5

A rename mid-process, which a scraper reads as one series going stale and another appearing — with a different value, since it is the counter's own.

Fixed by deciding from every name the store has ever held rather than the live set:

type metricStore struct {
	mutex   sync.RWMutex
	entries map[metricKey]*metricEntry

	// Every name the store has ever held, which cleanup deliberately does not
	// prune ...
	names map[metricKey]struct{}
}

cleanup does not prune names. That is bounded by the program's metric vocabulary rather than by its data — label cardinality lives in metricEntry.states, not here — so it does not reintroduce a leak of the kind the 10K-op cleanup exists to prevent.

TestCounterTotalSuffixCollisionSurvivesCleanup covers it: report both, expire the sibling through cleanup, assert the counter still publishes svc_hits. It fails if exposedName goes back to reading store.entries.

Worth being explicit that ordinary counters are untouched by any of this — the suffix is dropped only where a sibling field in the same scope is literally named X_total:

svc.Incr("hits")                        → svc_hits_total 1
svc.Incr("requests")                    → svc_requests_total 1
app.Incr("hits") + app.Set("hits_total") → app_hits 1  and  app_hits_total 5

Comment thread engine.go
// e.SetBuckets("latency", 0.005, 0.01, 0.025, 0.05, 0.1)
// e.Observe("latency", d)
//
// A sub-engine derived with WithPrefix computes its own key, so buckets no

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] Sub-engines still need their own SetBuckets call

[Found by 2 agents: bug-hunter, doc-grounding-reviewer] Buckets.Set(e.makeName(name), ...) keys on the full prefix of the engine it is called on. e.SetBuckets("latency", 0.5, 7) followed by e.WithPrefix("db").Observe("latency", 1.0) gives app_db_latency the 11 DefaultBuckets (scratch test on the PR head). README.md:220-222 makes the same claim.

Suggestion: "Call SetBuckets on the same engine or sub-engine that calls Observe. You pass the same short name, but each WithPrefix sub-engine needs its own call."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in the docs — but not with the suggested wording, because that replacement isn't right either.

You do not need a handle on each sub-engine. An ancestor can register for any depth by naming the path, because both sides do the same last-dot split and the same prefix concatenation:

root := stats.NewEngine("app", h)
root.SetBuckets("db.latency", 0.2, 0.4)
root.WithPrefix("db").Observe("latency", 0.3)      // resolves

root.SetBuckets("sub.deeper.latency", 1.5, 3.5)
root.WithPrefix("sub").WithPrefix("deeper").Observe("latency", 2.0)   // resolves

So "each WithPrefix sub-engine needs its own call" would trade one wrong claim for another: it implies you must hold every derived engine, when one init function against the root covers the whole tree. What was actually false is the inheritance — that registering on app reaches app.db.

engine.go and README.md now say: the key is the engine's prefix joined to the name; name the metric relative to the engine you call it on; an ancestor can cover a tree by naming paths; and buckets are not inherited — a sub-engine resolves only what was registered for its own prefix.

Your parenthetical about Handler.Buckets is in both places too, and it is the sharper half of this finding: SetBuckets writes the global registry, a Handler with its own non-nil Buckets never reads it, so the call compiles, runs and does nothing. No prefix to get wrong — the API is simply inert, and you land on DefaultBuckets with no signal.

Behaviour left alone, deliberately. Making inheritance real means matching on the engine hierarchy, and the lookup only sees a string: NewEngine("app.db", h) and NewEngine("app", h).WithPrefix("db") are indistinguishable there. Resolving by name suffix instead would let svc.billing inherit a set registered for an unrelated billing — the same silent-wrong-boundaries defect this PR exists to remove. Real inheritance wants engines carrying their own buckets, which is a separate change; TestHistogramBucketsSuffixMatchingIsOptIn pins the weaker guarantee in the meantime.

SetBuckets also builds the stats.Key directly now rather than round-tripping through a string it re-parses, as the PR body said it should. New test: TestEngineSetBucketsFromAncestor, alongside the existing TestEngineSetBucketsKeyMatchesObserve cases.

@sathvik09

Copy link
Copy Markdown
Contributor Author

Thanks — all three reproduced, and two ran deeper than described. Addressed as follows.

Finding 1 — DefaultBuckets on byte histograms

Fixed at the root rather than by gating the fallback.

Both reasons given for the miss are right, and there is a third that rules out the cheap fix: HistogramBuckets.Set cannot express these keys at all. It splits on the last ., and every field involved carries one of its own — body.bytes, header.size, rtt.seconds. No string yields {Measure: "http.message", Field: "body.bytes"}. The registrations are not stale, they are unreachable, so rewriting the strings was never an option.

What landed:

  • HistogramBuckets.SetKey(Key, ...) — takes the two halves directly.
  • HistogramBuckets.SetUnprefixed(Key, ...) and Lookup(measure, field) — a package registering from init() runs before any engine exists and cannot know the prefix its measures will carry, so Lookup falls back to dropping leading segments from the measure name. Exact registrations always win, and only registrations made through SetUnprefixed are matched that way. Applying suffix matching to every registration would let svc.billing inherit a set registered for an unrelated billing — silently wrong boundaries, which is the defect class this PR exists to remove.
  • httpstats and netstats re-registered through SetUnprefixed.

One 5000-byte response through httpstats.NewHandler, scraped:

before   le="0.005" … le="10"  all 0,  le="+Inf" 1     histogram_quantile → 10
after    le="100" 0  le="1000" 0  le="10000" 1  …      histogram_quantile → interpolated in [1000, 10000)

The fallback stays unconditional. With the registrations resolving, it only reaches histograms nobody registered — which is what DefBuckets is for in the reference client. Gating it would ship NaN there instead: promql.BucketQuantile returns NaN for a lone +Inf bucket (len(buckets) < 2), so the +Inf fix makes a histogram valid without making it computable.

Not changed, deliberately:

  • otlp/handler.go:127 reads the registry with a direct map access and has the identical miss. This PR states the other handlers are untouched; fixing it is a separate change with its own output consequences.
  • procstats registers "go.memstats:gc_pause.seconds" against fields declared type:"gauge", so that entry is inert whatever its key. Left alone rather than made to look fixed.

Finding 2 — _total double-suffixing and the collision

Fixed. The check now runs on the name as exposed rather than as received, via a hasTotalSuffix helper that renders through appendMetricName. A field is joined to its scope by an _, so Incr("requests.total") arrives as the bare field total and already publishes app_requests_total; . also renders as _, so requests.total renders pre-suffixed. subtotal still gets the suffix — no _ boundary.

The x / x_total collision is documented, not fixed. svc.hits and svc.hits_total still render one series and a scraper keeps one sample. Detecting it needs the store to reconcile rendered family names across entries, which is more machinery than the case warrants; HISTORY.md now names it under the _total entry and says to rename one of the two.

Finding 3 — SetBuckets docs

Doc fix, but not the wording suggested — that replacement is also misleading. You do not need a handle on each sub-engine. An ancestor can register for any depth by naming the path, because both sides do the same last-dot split and the same prefix concatenation:

root.SetBuckets("db.latency", 0.2, 0.4)
root.WithPrefix("db").Observe("latency", 0.3)   // resolves

So engine.go and README.md now say: the key is the engine's prefix joined to the name, name the metric relative to the engine you call it on, an ancestor can cover a whole tree by naming paths, and buckets are not inherited — a sub-engine resolves only what was registered for its own prefix. The Handler.Buckets trap is now stated in both places too; it is the sharper half, since the call simply has no effect and there is no prefix to get wrong.

Behaviour unchanged. Making inheritance real would mean matching on the engine hierarchy rather than on name suffixes — NewEngine("app.db", h) and NewEngine("app", h).WithPrefix("db") are indistinguishable at lookup time — which means engines carrying their own buckets. Worth doing, not here.

Other items from the review

  • HISTORY.md:25 corrected. You are right that the claim was wrong: sets ending in math.Inf(+1) did emit +Inf. It now reads "any histogram whose registered boundaries did not already end in math.Inf(+1)".
  • Sibling-matrix test. Still the single query_depth case; not yet table-driven.
  • Rollback note. HISTORY.md now records that httpstats/netstats series change shape, which is the larger of the two output changes and was previously undisclosed.

Verification

gofmt -l clean, go vet ./... clean, go test -count=1 ./... — 18/18 packages pass.

Each fix is pinned by a test checked to fail without it, by reverting them individually:

  • reverting the _total check to the raw name → TestCounterTotalSuffixUsesRenderedName/total and /requests.total fail with total_total, requests.total_total
  • reverting the httpstats registration to the string form → TestHTTPStatsBucketsReachTheHandler fails
  • reverting Lookup to a direct map access → same test fails independently

New tests: TestHistogramBucketsSetKeyDottedField, TestHistogramBucketsLookupUnprefixed, TestHistogramBucketsLookupExactWins, TestHistogramBucketsSuffixMatchingIsOptIn, TestEngineSetBucketsFromAncestor, TestHTTPStatsBucketsReachTheHandler, TestCounterTotalSuffixUsesRenderedName, TestCounterTotalSuffixDottedName.

@sathvik09
sathvik09 merged commit 7389692 into main Sep 23, 2026
14 checks passed
@sathvik09
sathvik09 deleted the prometheus-exposition-fixes branch September 23, 2026 17:39
sathvik09 added a commit that referenced this pull request Sep 23, 2026
…ollowups

prometheus: land the review fixes #232 merged without
@sathvik09 sathvik09 mentioned this pull request Sep 23, 2026
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.

3 participants