Repository navigation
prometheus: fix the exposition so histograms, counters and types are valid #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
8300859
prometheus: always emit a +Inf histogram bucket
sathvik09 3721eb8
prometheus: sort histogram le labels numerically
sathvik09 1eed1d7
prometheus: fall back to a default bucket set on a registry miss
sathvik09 612b93c
prometheus: declare metric type once per scope, not per field name
sathvik09 6af41f9
prometheus: stop exposing metric timestamps
sathvik09 2ecadbb
prometheus: suffix counters with _total
sathvik09 63a71a2
stats: add Engine.SetBuckets for prefix-aware bucket registration
sathvik09 7098e8a
docs: record the prometheus exposition changes
sathvik09 bf3b2ba
prometheus: reuse a registered +Inf boundary rather than appending a …
sathvik09 7ea9728
prometheus: declare a metric type once per exposition, not per run
sathvik09 b265ab7
prometheus: stop setting metric.time on collected metrics
sathvik09 83be4c5
docs: state the exposition changes consumers have to act on
sathvik09 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| package stats_test | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| stats "github.com/segmentio/stats/v5" | ||
| "github.com/segmentio/stats/v5/prometheus" | ||
| "github.com/segmentio/stats/v5/statstest" | ||
| ) | ||
|
|
||
| // TestEngineSetBucketsKeyMatchesObserve pins the invariant SetBuckets exists | ||
| // for: the registry key it writes is exactly the one Observe produces for the | ||
| // same name, on the engine it was called on and on any sub-engine derived | ||
| // from it. | ||
| // | ||
| // The key is read back from what Observe actually emitted rather than being | ||
| // restated here, so the test fails if either side of the pair changes. | ||
| func TestEngineSetBucketsKeyMatchesObserve(t *testing.T) { | ||
| for _, test := range []struct { | ||
| scenario string | ||
| name string | ||
| engine func(stats.Handler) *stats.Engine | ||
| }{ | ||
| { | ||
| scenario: "engine with a prefix", | ||
| name: "latency", | ||
| engine: func(h stats.Handler) *stats.Engine { return stats.NewEngine("app", h) }, | ||
| }, | ||
| { | ||
| scenario: "engine with no prefix", | ||
| name: "latency", | ||
| engine: func(h stats.Handler) *stats.Engine { return stats.NewEngine("", h) }, | ||
| }, | ||
| { | ||
| scenario: "sub-engine derived with WithPrefix", | ||
| name: "latency", | ||
| engine: func(h stats.Handler) *stats.Engine { | ||
| return stats.NewEngine("app", h).WithPrefix("sub") | ||
| }, | ||
| }, | ||
| { | ||
| scenario: "sub-engine derived twice", | ||
| name: "latency", | ||
| engine: func(h stats.Handler) *stats.Engine { | ||
| return stats.NewEngine("app", h).WithPrefix("sub").WithPrefix("deeper") | ||
| }, | ||
| }, | ||
| { | ||
| // Observe splits the name on its last dot and prefixes only the | ||
| // measure half, so SetBuckets has to do the same. | ||
| scenario: "dotted name", | ||
| name: "db.latency", | ||
| engine: func(h stats.Handler) *stats.Engine { return stats.NewEngine("app", h) }, | ||
| }, | ||
| } { | ||
| t.Run(test.scenario, func(t *testing.T) { | ||
| name := test.name | ||
|
|
||
| h := &statstest.Handler{} | ||
| e := test.engine(h) | ||
|
|
||
| e.SetBuckets(name, 0.1, 0.2, 0.3) | ||
| e.Observe(name, 0.15) | ||
|
|
||
| // Find the measure Observe produced, skipping the go_version | ||
| // gauge the engine reports once. | ||
| var key stats.Key | ||
| var found bool | ||
| for _, m := range h.Measures() { | ||
| for _, f := range m.Fields { | ||
| if strings.HasSuffix(name, f.Name) { | ||
| key = stats.Key{Measure: m.Name, Field: f.Name} | ||
| found = true | ||
| } | ||
| } | ||
| } | ||
| if !found { | ||
| t.Fatalf("Observe produced no measure for %q", name) | ||
| } | ||
|
|
||
| buckets, ok := stats.Buckets[key] | ||
| if !ok { | ||
| t.Fatalf("SetBuckets did not register %#v; registry holds %#v", | ||
| key, keysOf(stats.Buckets)) | ||
| } | ||
| if len(buckets) != 3 { | ||
| t.Errorf("registered %d buckets, expected 3", len(buckets)) | ||
| } | ||
|
|
||
| delete(stats.Buckets, key) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestEngineSetBucketsEndToEnd runs the whole chain: buckets registered on a | ||
| // sub-engine reach the exposition, rather than the metric silently falling | ||
| // back to the default set. | ||
| func TestEngineSetBucketsEndToEnd(t *testing.T) { | ||
| ph := &prometheus.Handler{} | ||
| e := stats.NewEngine("svc", ph).WithPrefix("sub") | ||
|
|
||
| e.SetBuckets("latency", 0.1, 0.2, 0.3) | ||
| defer delete(stats.Buckets, stats.Key{Measure: "svc.sub", Field: "latency"}) | ||
|
|
||
| e.Observe("latency", 0.15) | ||
|
|
||
| var buf strings.Builder | ||
| ph.WriteStats(&buf) | ||
| out := buf.String() | ||
|
|
||
| for _, want := range []string{ | ||
| `svc_sub_latency_bucket{le="0.1"} 0`, | ||
| `svc_sub_latency_bucket{le="0.2"} 1`, | ||
| `svc_sub_latency_bucket{le="0.3"} 1`, | ||
| `svc_sub_latency_bucket{le="+Inf"} 1`, | ||
| `svc_sub_latency_count 1`, | ||
| } { | ||
| if !strings.Contains(out, want) { | ||
| t.Errorf("missing %q in output:\n%s", want, out) | ||
| } | ||
| } | ||
|
|
||
| // Three registered boundaries plus +Inf. More would mean the registration | ||
| // missed and DefaultBuckets was used instead. | ||
| if n := strings.Count(out, "svc_sub_latency_bucket{"); n != 4 { | ||
| t.Errorf("found %d bucket series, expected 4:\n%s", n, out) | ||
| } | ||
| } | ||
|
|
||
| // TestBucketsSetStillWorks covers the pre-existing registration path, which | ||
| // SetBuckets is additive to. | ||
| func TestBucketsSetStillWorks(t *testing.T) { | ||
| key := stats.Key{Measure: "legacy.svc", Field: "latency"} | ||
| defer delete(stats.Buckets, key) | ||
|
|
||
| stats.Buckets.Set("legacy.svc.latency", 0.1, 0.2) | ||
|
|
||
| if _, ok := stats.Buckets[key]; !ok { | ||
| t.Errorf("Buckets.Set did not register %#v", key) | ||
| } | ||
| } | ||
|
|
||
| func keysOf(b stats.HistogramBuckets) []stats.Key { | ||
| keys := make([]stats.Key, 0, len(b)) | ||
| for k := range b { | ||
| keys = append(keys, k) | ||
| } | ||
| return keys | ||
| } |
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
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 bye.WithPrefix("db").Observe("latency", 1.0)givesapp_db_latencythe 11DefaultBuckets(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."
There was a problem hiding this comment.
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:
So "each
WithPrefixsub-engine needs its own call" would trade one wrong claim for another: it implies you must hold every derived engine, when oneinitfunction against the root covers the whole tree. What was actually false is the inheritance — that registering onappreachesapp.db.engine.goandREADME.mdnow 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.Bucketsis in both places too, and it is the sharper half of this finding:SetBucketswrites the global registry, aHandlerwith its own non-nilBucketsnever reads it, so the call compiles, runs and does nothing. No prefix to get wrong — the API is simply inert, and you land onDefaultBucketswith 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)andNewEngine("app", h).WithPrefix("db")are indistinguishable there. Resolving by name suffix instead would letsvc.billinginherit a set registered for an unrelatedbilling— the same silent-wrong-boundaries defect this PR exists to remove. Real inheritance wants engines carrying their own buckets, which is a separate change;TestHistogramBucketsSuffixMatchingIsOptInpins the weaker guarantee in the meantime.SetBucketsalso builds thestats.Keydirectly now rather than round-tripping through a string it re-parses, as the PR body said it should. New test:TestEngineSetBucketsFromAncestor, alongside the existingTestEngineSetBucketsKeyMatchesObservecases.