Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
prometheushandler 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 byhistogram_quantile()— it never emits a+Infbucket, and by default it emits no_bucketseries at all. Counters are also published asunknownunder OpenMetrics because they lack the_totalsuffix the encoder keys on.This fixes the exposition.
Handlerkeeps its name, its fields and its place instats.MultiHandler; consumers get the fix on a version bump with no application changes.Other handlers (
datadog,influxdb,otlp,veneur) are untouched.Nothing fails to compile, but the published series change:
_totalsuffixHISTORY.mdcarries the full entry.The defects
makeMetricBucketsallocated exactlylen(buckets)entries and never appended an overflow buckethistogram_quantile()returnsNaNunless the highest bucket is+Inf. Observations above the top boundary were counted in_sum/_countbut landed in no bucketstats.Bucketsis empty by default and a miss returned a nil slice with no errorcollectranged over it zero times and wrote no_bucketseries, while_sum/_countwere emitted unconditionally — so nothing looked wronglabel.lesscompared values as raw strings+Infsorted first (+is ASCII 43, digits start at 48) and10sorted ahead of2WriteStatsdeduplicated# TYPEon the bare field name, scope discardedWithPrefixexists precisely so subsystems can reuse short names likehits, so this fired readilyappendMetricwrote an explicit timestamp_totalsuffixunknownObserveandBuckets.Setname the same metric differentlyObservetakes a name relative to the engine;Setneeds 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 bucketsTwo things worth reviewer attention
The
+Inffix is not one line.metricState.updaterebuilds the bucket set whenlen(state.buckets) != len(buckets). Appending+Infmakes the stored slice permanently one longer than the registry slice, so without moving that check every observation reallocates and zeroes the counts —_countclimbing while every_bucketstays at 0 or 1. That is worse than the defect being fixed, so both changes are in one commit with a regression test.The
# TYPEfix 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_bucketseries group by boundary across every scope while_countand_sumsort 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 toObserveand the two cannot drift. AWithPrefixsub-engine computes its own key, removing the one-registration-per-derived-prefix problem. Additive —HistogramBuckets.Setis unchanged.prometheus.DefaultBucketsis 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— cleango test -race ./prometheus/... .— cleanprometheus/common/expfmt, in a throwaway module sogo.modis untouched): counters parse asCOUNTER, both sub-engine families typed, histogram parses asHISTOGRAMwith+Inf==_count, buckets strictly increasing, no timestamps. Quantiles over 100 deterministic observations come out exact — p500.505, p950.9595, p990.9999, noNaNNote
FieldType's zero value isCounter, andreportVersionOncebuilds bareField{}literals rather than callingMakeField, so the internal version metrics are counters and are renamed togo_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