perf(metrics): build the measurement option once per route, method and status - #3972
perf(metrics): build the measurement option once per route, method and status#3972aryanmehrotra wants to merge 21 commits into
Conversation
ecf0dc7 to
7a41fd9
Compare
|
Reviewed against Checks I ran:
|
NitinKumar004
left a comment
There was a problem hiding this comment.
Production code is correct; requesting changes for test coverage of the new fast path, cache behavior under catch-all traffic, and cross-PR cache coordination.
Medium
- The
optionRecorderfast path — the one all real traffic now uses — has no label assertions.metricsManagerimplementsRecordHistogramOpt, so production flows throughoptionRecorder, yet the only test touching it (TestMetricsOptRecorderIsUsedAndStillRecords) drops the opts and just counts hits. Nothing asserts the path is the route template/users/{id}(not/users/7), that method/status keys are present, or that status isattribute.String(matching the slow path so OTLP types agree). ChangeString→Intor emit the concrete path and every test stays green while dashboards break and cardinality explodes.
Low
TestMetricsOptCacheIsBoundedonly assertsrequire.Positive(optCacheLimit)— a compile-time constant. It never drives >limit distinct keys throughrecord()nor inspectscount/cache size, so deleting theif o.count.Load() < optCacheLimitguard (re-introducing unbounded growth) still passes. The advertised bound has no executable coverage.- Shared 4096-slot cache lets catch-all raw-path traffic starve real routes. The
PathPrefix("/")catch-all always takes theraw r.URL.Pathfallback; 4096+ unique unmatched paths saturate the cache, after which every first-seen legitimate combo can never be stored andrecord()rebuildsmetric.WithAttributesper request forever — the optimization silently reverts to baseline for production traffic (memory stays bounded; correctness unaffected). Cache only whenRouteTemplate(r) != "", or give the raw-path fallback a separate budget. attrsRecorderis dead + untested in-tree, and its cache is unbounded. No in-tree type selects it (*metricsManagerimplements both), so it runs at 0% coverage, and itssync.Maphas nooptCacheLimitceiling — an externalRecordHistogramAttrs-only backend grows it without bound under catch-all traffic.- The headline "-650 B/request" is not reproducible from the shipped benchmarks (they yield ~424 B/op).
⚠️ Cross-PR (HIGH, coordinate with #3970)
These metrics caches + #3970's tracerSpanCache are three caches on the same attacker-influenced key space with inconsistent bounding (attrsRecorder.cache unbounded). One malicious request stream inflates several. Bound all three consistently and review as one unit.
Requested changes: assert label/type/template on the opt path; make the cache-bound test actually drive the guard; cache only templated routes (or a separate raw-path budget); bound attrsRecorder.cache; align the perf-table numbers with the shipped benchmarks; coordinate with #3970.
Addresses the blocker on #3970. tracerSpanCache was a package-global sync.Map with no eviction, and its safety argument - "only bounded route templates are cached" - did not survive contact with a real GoFr app. gofr.go registers a PathPrefix("/") catch-all, so mux.CurrentRoute is set for every request and GetPathTemplate() returns "/" even for a path nothing matched. The templated guard is therefore true always, and the route half of the key is not the bound it looked like. Reproduced: method "ZZZQUUX" on "/totally/unknown" resolves templated=true, route="/". The method half is worse. net/http accepts any RFC 7230 token, so an unauthenticated client streaming M00001, M00002, ... minted a permanent *spanMeta per request. Reproduced: 5000 distinct methods against the catch-all produced 5000 permanent entries. The pre-cache fmt.Sprintf path retained no state, so this was introduced by the cache rather than inherited. Two things now bound it. cacheableMethod restricts the key to the nine defined HTTP methods, which turns "methods x routes" back into the fixed quantity the cache was designed around; a request with a made-up method still works, it just rebuilds its span metadata as every request did before the cache existed. routeCache then caps the whole thing at 4096 entries, because the first bound is an argument about reachability and process memory should rest on a number too. routeCache and cacheableMethod live in their own file because the metrics caches in #3972 have the same shape on the same attacker-influenced key space, and the reviewer asked for all three to be bounded consistently rather than one at a time. BenchmarkTracer documented the non-recording path but did not measure it: TestTracerPropagatesIncomingTraceContext installs a live recording provider and never restored it, and tests run before benchmarks, so the benchmark ran recording and fed that test's in-memory exporter. Both globals it replaces are restored now, and the benchmark pins the noop provider itself. It reports 11 allocs/op on the path it describes. The non-recording branch this PR optimizes had no test; it has one now. buildSpanName's godoc no longer quotes a 44.3 -> 22.6 ns/op figure that nothing in the tree can reproduce, since the fmt.Sprintf baseline it was measured against is gone.
7c3d4ab to
4eed1cc
Compare
Addresses review on #3972. Both caches now go through the routeCache introduced for the span cache in #3970, under the same rules, because all three sit on the same attacker-influenced key space and bounding them one at a time does not help - one malicious request stream inflates whichever is left unbounded. attrsRecorder.cache had no ceiling at all while its sibling had one. Only a templated route may be cached now. Every branch of the path label that falls back to r.URL.Path yields a caller-controlled string - an unmatched route, a static-asset extension, /static, and the "/" template GoFr's PathPrefix("/") catch-all produces for every unmatched request. Those used to be cached, so a stream of unique unmatched paths filled the cache to its ceiling, after which every first-seen legitimate route could never be stored and rebuilt its measurement option per request forever. Memory stayed bounded and correctness was unaffected, but the optimization silently reverted to baseline for production traffic. The method half is filtered too, for the reason given in cacheableMethod: a bounded route with an unbounded method reopens the same growth. The fast path all production traffic uses now has label assertions. metricsManager implements RecordHistogramOpt, so requests flow through optionRecorder, yet nothing pinned what it emitted. Changing status from attribute.String to attribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both are now caught - verified by making each change and watching the test fail. TestMetricsOptCacheIsBounded asserted that a compile-time constant was positive, so deleting the ceiling kept it green. It drives more than the limit in distinct keys through record() and inspects the cache size now; deleting the ceiling fails it, TestRouteCacheStopsAtItsLimit and TestAttrsRecorderIsCorrectAndBounded together. attrsRecorder was at zero coverage - no in-tree type selects it, since *metricsManager implements both optional interfaces and optionRecorder wins - and now has direct tests for its labels, its reuse and its bound. The "-650 B/request" figure is dropped. The shipped benchmarks give BenchmarkRecordHistogramAttrs at 424 B/op and 4 allocs/op against BenchmarkRecordHistogramOpt at 0 B/op and 0 allocs/op, with BenchmarkAttrBuild_HTTP at 448 B/op for the attribute build a cache hit also avoids. Those are the numbers the PR body now quotes.
7a41fd9 to
cd1bf5d
Compare
Addresses review on #3972. Both caches now go through the routeCache introduced for the span cache in #3970, under the same rules, because all three sit on the same attacker-influenced key space and bounding them one at a time does not help - one malicious request stream inflates whichever is left unbounded. attrsRecorder.cache had no ceiling at all while its sibling had one. Only a templated route may be cached now. Every branch of the path label that falls back to r.URL.Path yields a caller-controlled string - an unmatched route, a static-asset extension, /static, and the "/" template GoFr's PathPrefix("/") catch-all produces for every unmatched request. Those used to be cached, so a stream of unique unmatched paths filled the cache to its ceiling, after which every first-seen legitimate route could never be stored and rebuilt its measurement option per request forever. Memory stayed bounded and correctness was unaffected, but the optimization silently reverted to baseline for production traffic. The method half is filtered too, for the reason given in cacheableMethod: a bounded route with an unbounded method reopens the same growth. The fast path all production traffic uses now has label assertions. metricsManager implements RecordHistogramOpt, so requests flow through optionRecorder, yet nothing pinned what it emitted. Changing status from attribute.String to attribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both are now caught - verified by making each change and watching the test fail. TestMetricsOptCacheIsBounded asserted that a compile-time constant was positive, so deleting the ceiling kept it green. It drives more than the limit in distinct keys through record() and inspects the cache size now; deleting the ceiling fails it, TestRouteCacheStopsAtItsLimit and TestAttrsRecorderIsCorrectAndBounded together. attrsRecorder was at zero coverage - no in-tree type selects it, since *metricsManager implements both optional interfaces and optionRecorder wins - and now has direct tests for its labels, its reuse and its bound. The "-650 B/request" figure is dropped. The shipped benchmarks give BenchmarkRecordHistogramAttrs at 424 B/op and 4 allocs/op against BenchmarkRecordHistogramOpt at 0 B/op and 0 allocs/op, with BenchmarkAttrBuild_HTTP at 448 B/op for the attribute build a cache hit also avoids. Those are the numbers the PR body now quotes.
cd1bf5d to
3434a4b
Compare
Addresses the blocker on #3970. tracerSpanCache was a package-global sync.Map with no eviction, and its safety argument - "only bounded route templates are cached" - did not survive contact with a real GoFr app. gofr.go registers a PathPrefix("/") catch-all, so mux.CurrentRoute is set for every request and GetPathTemplate() returns "/" even for a path nothing matched. The templated guard is therefore true always, and the route half of the key is not the bound it looked like. Reproduced: method "ZZZQUUX" on "/totally/unknown" resolves templated=true, route="/". The method half is worse. net/http accepts any RFC 7230 token, so an unauthenticated client streaming M00001, M00002, ... minted a permanent *spanMeta per request. Reproduced: 5000 distinct methods against the catch-all produced 5000 permanent entries. The pre-cache fmt.Sprintf path retained no state, so this was introduced by the cache rather than inherited. Two things now bound it. cacheableMethod restricts the key to the nine defined HTTP methods, which turns "methods x routes" back into the fixed quantity the cache was designed around; a request with a made-up method still works, it just rebuilds its span metadata as every request did before the cache existed. routeCache then caps the whole thing at 4096 entries, because the first bound is an argument about reachability and process memory should rest on a number too. routeCache and cacheableMethod live in their own file because the metrics caches in #3972 have the same shape on the same attacker-influenced key space, and the reviewer asked for all three to be bounded consistently rather than one at a time. BenchmarkTracer documented the non-recording path but did not measure it: TestTracerPropagatesIncomingTraceContext installs a live recording provider and never restored it, and tests run before benchmarks, so the benchmark ran recording and fed that test's in-memory exporter. Both globals it replaces are restored now, and the benchmark pins the noop provider itself. It reports 11 allocs/op on the path it describes. The non-recording branch this PR optimizes had no test; it has one now. buildSpanName's godoc no longer quotes a 44.3 -> 22.6 ns/op figure that nothing in the tree can reproduce, since the fmt.Sprintf baseline it was measured against is gone.
f4512ad to
a09f4f6
Compare
Addresses review on #3972. Both caches now go through the routeCache introduced for the span cache in #3970, under the same rules, because all three sit on the same attacker-influenced key space and bounding them one at a time does not help - one malicious request stream inflates whichever is left unbounded. attrsRecorder.cache had no ceiling at all while its sibling had one. Only a templated route may be cached now. Every branch of the path label that falls back to r.URL.Path yields a caller-controlled string - an unmatched route, a static-asset extension, /static, and the "/" template GoFr's PathPrefix("/") catch-all produces for every unmatched request. Those used to be cached, so a stream of unique unmatched paths filled the cache to its ceiling, after which every first-seen legitimate route could never be stored and rebuilt its measurement option per request forever. Memory stayed bounded and correctness was unaffected, but the optimization silently reverted to baseline for production traffic. The method half is filtered too, for the reason given in cacheableMethod: a bounded route with an unbounded method reopens the same growth. The fast path all production traffic uses now has label assertions. metricsManager implements RecordHistogramOpt, so requests flow through optionRecorder, yet nothing pinned what it emitted. Changing status from attribute.String to attribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both are now caught - verified by making each change and watching the test fail. TestMetricsOptCacheIsBounded asserted that a compile-time constant was positive, so deleting the ceiling kept it green. It drives more than the limit in distinct keys through record() and inspects the cache size now; deleting the ceiling fails it, TestRouteCacheStopsAtItsLimit and TestAttrsRecorderIsCorrectAndBounded together. attrsRecorder was at zero coverage - no in-tree type selects it, since *metricsManager implements both optional interfaces and optionRecorder wins - and now has direct tests for its labels, its reuse and its bound. The "-650 B/request" figure is dropped. The shipped benchmarks give BenchmarkRecordHistogramAttrs at 424 B/op and 4 allocs/op against BenchmarkRecordHistogramOpt at 0 B/op and 0 allocs/op, with BenchmarkAttrBuild_HTTP at 448 B/op for the attribute build a cache hit also avoids. Those are the numbers the PR body now quotes.
3434a4b to
d648833
Compare
Addresses the blocker on #3970. tracerSpanCache was a package-global sync.Map with no eviction, and its safety argument - "only bounded route templates are cached" - did not survive contact with a real GoFr app. gofr.go registers a PathPrefix("/") catch-all, so mux.CurrentRoute is set for every request and GetPathTemplate() returns "/" even for a path nothing matched. The templated guard is therefore true always, and the route half of the key is not the bound it looked like. Reproduced: method "ZZZQUUX" on "/totally/unknown" resolves templated=true, route="/". The method half is worse. net/http accepts any RFC 7230 token, so an unauthenticated client streaming M00001, M00002, ... minted a permanent *spanMeta per request. Reproduced: 5000 distinct methods against the catch-all produced 5000 permanent entries. The pre-cache fmt.Sprintf path retained no state, so this was introduced by the cache rather than inherited. Two things now bound it. cacheableMethod restricts the key to the nine defined HTTP methods, which turns "methods x routes" back into the fixed quantity the cache was designed around; a request with a made-up method still works, it just rebuilds its span metadata as every request did before the cache existed. routeCache then caps the whole thing at 4096 entries, because the first bound is an argument about reachability and process memory should rest on a number too. routeCache and cacheableMethod live in their own file because the metrics caches in #3972 have the same shape on the same attacker-influenced key space, and the reviewer asked for all three to be bounded consistently rather than one at a time. BenchmarkTracer documented the non-recording path but did not measure it: TestTracerPropagatesIncomingTraceContext installs a live recording provider and never restored it, and tests run before benchmarks, so the benchmark ran recording and fed that test's in-memory exporter. Both globals it replaces are restored now, and the benchmark pins the noop provider itself. It reports 11 allocs/op on the path it describes. The non-recording branch this PR optimizes had no test; it has one now. buildSpanName's godoc no longer quotes a 44.3 -> 22.6 ns/op figure that nothing in the tree can reproduce, since the fmt.Sprintf baseline it was measured against is gone.
a09f4f6 to
9e20256
Compare
Addresses review on #3972. Both caches now go through the routeCache introduced for the span cache in #3970, under the same rules, because all three sit on the same attacker-influenced key space and bounding them one at a time does not help - one malicious request stream inflates whichever is left unbounded. attrsRecorder.cache had no ceiling at all while its sibling had one. Only a templated route may be cached now. Every branch of the path label that falls back to r.URL.Path yields a caller-controlled string - an unmatched route, a static-asset extension, /static, and the "/" template GoFr's PathPrefix("/") catch-all produces for every unmatched request. Those used to be cached, so a stream of unique unmatched paths filled the cache to its ceiling, after which every first-seen legitimate route could never be stored and rebuilt its measurement option per request forever. Memory stayed bounded and correctness was unaffected, but the optimization silently reverted to baseline for production traffic. The method half is filtered too, for the reason given in cacheableMethod: a bounded route with an unbounded method reopens the same growth. The fast path all production traffic uses now has label assertions. metricsManager implements RecordHistogramOpt, so requests flow through optionRecorder, yet nothing pinned what it emitted. Changing status from attribute.String to attribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both are now caught - verified by making each change and watching the test fail. TestMetricsOptCacheIsBounded asserted that a compile-time constant was positive, so deleting the ceiling kept it green. It drives more than the limit in distinct keys through record() and inspects the cache size now; deleting the ceiling fails it, TestRouteCacheStopsAtItsLimit and TestAttrsRecorderIsCorrectAndBounded together. attrsRecorder was at zero coverage - no in-tree type selects it, since *metricsManager implements both optional interfaces and optionRecorder wins - and now has direct tests for its labels, its reuse and its bound. The "-650 B/request" figure is dropped. The shipped benchmarks give BenchmarkRecordHistogramAttrs at 424 B/op and 4 allocs/op against BenchmarkRecordHistogramOpt at 0 B/op and 0 allocs/op, with BenchmarkAttrBuild_HTTP at 448 B/op for the attribute build a cache hit also avoids. Those are the numbers the PR body now quotes.
d648833 to
ac9f800
Compare
Addresses the blocker on #3970. tracerSpanCache was a package-global sync.Map with no eviction, and its safety argument - "only bounded route templates are cached" - did not survive contact with a real GoFr app. gofr.go registers a PathPrefix("/") catch-all, so mux.CurrentRoute is set for every request and GetPathTemplate() returns "/" even for a path nothing matched. The templated guard is therefore true always, and the route half of the key is not the bound it looked like. Reproduced: method "ZZZQUUX" on "/totally/unknown" resolves templated=true, route="/". The method half is worse. net/http accepts any RFC 7230 token, so an unauthenticated client streaming M00001, M00002, ... minted a permanent *spanMeta per request. Reproduced: 5000 distinct methods against the catch-all produced 5000 permanent entries. The pre-cache fmt.Sprintf path retained no state, so this was introduced by the cache rather than inherited. Two things now bound it. cacheableMethod restricts the key to the nine defined HTTP methods, which turns "methods x routes" back into the fixed quantity the cache was designed around; a request with a made-up method still works, it just rebuilds its span metadata as every request did before the cache existed. routeCache then caps the whole thing at 4096 entries, because the first bound is an argument about reachability and process memory should rest on a number too. routeCache and cacheableMethod live in their own file because the metrics caches in #3972 have the same shape on the same attacker-influenced key space, and the reviewer asked for all three to be bounded consistently rather than one at a time. BenchmarkTracer documented the non-recording path but did not measure it: TestTracerPropagatesIncomingTraceContext installs a live recording provider and never restored it, and tests run before benchmarks, so the benchmark ran recording and fed that test's in-memory exporter. Both globals it replaces are restored now, and the benchmark pins the noop provider itself. It reports 11 allocs/op on the path it describes. The non-recording branch this PR optimizes had no test; it has one now. buildSpanName's godoc no longer quotes a 44.3 -> 22.6 ns/op figure that nothing in the tree can reproduce, since the fmt.Sprintf baseline it was measured against is gone.
9e20256 to
16ee698
Compare
Addresses review on #3972. Both caches now go through the routeCache introduced for the span cache in #3970, under the same rules, because all three sit on the same attacker-influenced key space and bounding them one at a time does not help - one malicious request stream inflates whichever is left unbounded. attrsRecorder.cache had no ceiling at all while its sibling had one. Only a templated route may be cached now. Every branch of the path label that falls back to r.URL.Path yields a caller-controlled string - an unmatched route, a static-asset extension, /static, and the "/" template GoFr's PathPrefix("/") catch-all produces for every unmatched request. Those used to be cached, so a stream of unique unmatched paths filled the cache to its ceiling, after which every first-seen legitimate route could never be stored and rebuilt its measurement option per request forever. Memory stayed bounded and correctness was unaffected, but the optimization silently reverted to baseline for production traffic. The method half is filtered too, for the reason given in cacheableMethod: a bounded route with an unbounded method reopens the same growth. The fast path all production traffic uses now has label assertions. metricsManager implements RecordHistogramOpt, so requests flow through optionRecorder, yet nothing pinned what it emitted. Changing status from attribute.String to attribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both are now caught - verified by making each change and watching the test fail. TestMetricsOptCacheIsBounded asserted that a compile-time constant was positive, so deleting the ceiling kept it green. It drives more than the limit in distinct keys through record() and inspects the cache size now; deleting the ceiling fails it, TestRouteCacheStopsAtItsLimit and TestAttrsRecorderIsCorrectAndBounded together. attrsRecorder was at zero coverage - no in-tree type selects it, since *metricsManager implements both optional interfaces and optionRecorder wins - and now has direct tests for its labels, its reuse and its bound. The "-650 B/request" figure is dropped. The shipped benchmarks give BenchmarkRecordHistogramAttrs at 424 B/op and 4 allocs/op against BenchmarkRecordHistogramOpt at 0 B/op and 0 allocs/op, with BenchmarkAttrBuild_HTTP at 448 B/op for the attribute build a cache hit also avoids. Those are the numbers the PR body now quotes.
ac9f800 to
5d30c46
Compare
|
All six items are addressed. Re-requesting review — the The fast path now has label assertionsYou were right that this was the gap that mattered: I also added a test that compares all three strategies against each other on the same requests, because "the opt path is right" is weaker than "the three cannot disagree": Identical across six request shapes — same keys, same values, same attribute types. Both mutations you named now fail the suite: Catch-all traffic can no longer starve real routesThis was the subtlest of your findings and it needed a design change, not just a ceiling. Every branch of the path label that falls back to
|
Sprintf walks a format string and boxes two arguments that are already strings, to produce a value that concatenation produces directly. Measured by BenchmarkBuildSpanName at the real call site: before 44.33 ns/op 16 B/op 1 allocs/op after 22.61 ns/op 16 B/op 1 allocs/op This is a CPU saving only, not an allocation saving. Both forms pay the one unavoidable allocation for the result string, because the span name escapes into the span; runtime.concatstrings only uses its 32-byte stack buffer when escape analysis proves the result stays in the frame. The span name and http.route attribute are unchanged, and are now pinned by tests rather than left implicit. (cherry picked from commit 9b03ddfc79c1c2bbe7cf7d72d753a9bebbbcc600)
A non-recording span discards SetAttributes by contract, but the call still builds the variadic []attribute.KeyValue to hand it. GoFr installs an SDK provider with NeverSample when no TRACE_EXPORTER is configured, so on the default deployment that slice was allocated and thrown away on every single request. Measured by a Tracer middleware benchmark under NeverSample: before 2072 B/op 19 allocs/op after 2008 B/op 18 allocs/op Recording spans are unaffected: the status attribute is still set, and is now pinned by TestTracerStatusAttributeStillRecorded. The implicit-200 normalization established by #3431 is pinned by TestTracerImplicit200StillRecorded. (cherry picked from commit aba7cbfffee78119a254c4d223bb24f438696a27)
The span name and the http.route attribute set are pure functions of
(method, route template), yet both were rebuilt on every request: one
string allocation for the name and one slice allocation for the
attributes handed to trace.WithAttributes.
They are now built once per (method, route) and reused, following the
routeAttrs cache already used by metrics.go.
Measured by a Tracer middleware benchmark under NeverSample:
before 2008 B/op 18 allocs/op
after 1864 B/op 16 allocs/op
Cumulative across this stack: 2072 B / 19 allocs -> 1864 B / 16 allocs.
Two safety properties, both tested:
* The cache is keyed on the route TEMPLATE, so 500 concrete paths
through /users/{id} produce one entry. An unmatched request carries a
raw, attacker-controlled path and is deliberately never cached, which
is what keeps the cache bounded by routes x methods.
* The attributes are still passed to Start rather than applied after
it. A sampler can read attributes when deciding, so applying them
later would silently change sampling for anyone sampling on
http.route.
The cache is package level because the middleware chain is rebuilt around
the matched handler on every request, so a closure-owned cache would be discarded
each time. Its contents are pure data and provider-independent, so it
stays correct across TracerProvider replacement.
(cherry picked from commit c680c5544438476d60e9b44bc4d37678eecaf60e)
Caching the attribute slice still left two allocations per request: trace.WithAttributes builds an option wrapper around the slice, and passing it to Start builds the variadic []trace.SpanStartOption. Both are immutable once constructed and both are pure functions of (method, route), so they belong in the same per-route cache. Measured by a Tracer middleware benchmark under NeverSample: before 1864 B/op 16 allocs/op after 1824 B/op 14 allocs/op Cumulative across this file: 2072 B / 19 allocs -> 1824 B / 14 allocs. The attributes are still supplied at Start rather than applied afterwards, so sampler behaviour is unchanged for anyone sampling on http.route. (cherry picked from commit 397a7fb510a3f9ebe780cf5d8e3836045fd2d1f1)
…request The W3C propagators look their headers up by the lowercase names from the specs -- "traceparent", "tracestate", "baggage" -- and propagation.HeaderCarrier forwards those to http.Header.Get, which canonicalizes whatever key it is handed. None of those names is already canonical, so every request allocated the canonical string, whether or not the header was present at all. The canonical spellings are constants, so they are resolved once and the carrier reads the header map directly. A key the carrier does not recognize falls through to the ordinary lookup, so a propagator using its own header names -- B3, Jaeger, anything user-installed -- keeps working. TestHeaderCarrierMatchesHeaderCarrier pins that it resolves exactly what propagation.HeaderCarrier resolves, including for an unrecognized key and an absent one. TestTracerPropagatesIncomingTraceContext pins the behaviour that matters: an incoming traceparent is still adopted as the parent span. (cherry picked from commit 76a9accceb90c61d0c9d63f82ba3d839f2aaad1d)
The status wrapper existed solely to put http.response.status_code on the span, but it was created on every request regardless of whether any span would keep it. Two things made that worse than it looks. Tracer is the outermost middleware, so the StatusResponseWriter that Logging installs does not exist yet and the type assertion always failed -- meaning the fallback allocated a fresh wrapper every request rather than the "uncommon" case the old comment described. And GoFr installs an SDK provider with NeverSample when no TRACE_EXPORTER is configured, so on the default deployment nothing ever read it. The wrapper and its deferred attribute are now inside the IsRecording check, which is known immediately after Start. A recording span still gets the wrapper and the attribute, including the implicit-200 normalization, pinned by TestTracerStatusAttributeStillRecorded and TestTracerImplicit200StillRecorded. (cherry picked from commit 73b87213c044ce6889340df3a81e14865cc390ba)
The change caches the span name, attributes and start option per route so they are not rebuilt on every request. Nothing measured that, and one benchmark alone would have been misleading: a service that samples runs both a recording and a non-recording provider, and the middleware must not be quietly worse in either. Two benchmarks, so both paths are covered: BenchmarkTracer non-recording 21 -> 13 allocs/op, 2074 -> 1760 B/op BenchmarkTracer_Recording recording 23 -> 17 allocs/op, 2875 -> 2657 B/op Only allocation counts are quoted. Wall-clock on the machine these were taken on varied by more than 5x between runs of the same binary, so ns/op there says nothing; the allocation counts were stable and moved monotonically with each commit in the series. Also repairs the comment on the cache, which had been scrambled so that a nolint directive split the sentence explaining why the cache is package level.
buildSpanName was inserted directly above methodKV, so methodKV's entire doc -- the semconv rationale and the escape-analysis note -- ended up documenting buildSpanName instead, and methodKV was left with none. Moves buildSpanName below methodKV. No code change.
Addresses the blocker on #3970. tracerSpanCache was a package-global sync.Map with no eviction, and its safety argument - "only bounded route templates are cached" - did not survive contact with a real GoFr app. gofr.go registers a PathPrefix("/") catch-all, so mux.CurrentRoute is set for every request and GetPathTemplate() returns "/" even for a path nothing matched. The templated guard is therefore true always, and the route half of the key is not the bound it looked like. Reproduced: method "ZZZQUUX" on "/totally/unknown" resolves templated=true, route="/". The method half is worse. net/http accepts any RFC 7230 token, so an unauthenticated client streaming M00001, M00002, ... minted a permanent *spanMeta per request. Reproduced: 5000 distinct methods against the catch-all produced 5000 permanent entries. The pre-cache fmt.Sprintf path retained no state, so this was introduced by the cache rather than inherited. Two things now bound it. cacheableMethod restricts the key to the nine defined HTTP methods, which turns "methods x routes" back into the fixed quantity the cache was designed around; a request with a made-up method still works, it just rebuilds its span metadata as every request did before the cache existed. routeCache then caps the whole thing at 4096 entries, because the first bound is an argument about reachability and process memory should rest on a number too. routeCache and cacheableMethod live in their own file because the metrics caches in #3972 have the same shape on the same attacker-influenced key space, and the reviewer asked for all three to be bounded consistently rather than one at a time. BenchmarkTracer documented the non-recording path but did not measure it: TestTracerPropagatesIncomingTraceContext installs a live recording provider and never restored it, and tests run before benchmarks, so the benchmark ran recording and fed that test's in-memory exporter. Both globals it replaces are restored now, and the benchmark pins the noop provider itself. It reports 11 allocs/op on the path it describes. The non-recording branch this PR optimizes had no test; it has one now. buildSpanName's godoc no longer quotes a 44.3 -> 22.6 ns/op figure that nothing in the tree can reproduce, since the fmt.Sprintf baseline it was measured against is gone.
… not lost Addresses review on #3970. headerCarrier replaces propagation.HeaderCarrier to avoid canonicalizing the lookup key on every request, but implemented only Get/Set/Keys. The stdlib type it replaces also satisfies propagation.ValuesGetter, and propagation.Baggage.Extract type-asserts to that interface to combine ALL values of the Baggage header. The assertion failed, so Extract fell back to the single-value Get and silently dropped every baggage member after the first. It only shows up when a request carries more than one Baggage header, which is legal per W3C and is what proxies and service meshes commonly emit, and GoFr installs propagation.Baggage in its default composite propagator - so the path is live. Reproduced against the stdlib carrier with three Baggage headers: stdlib extracted 3 members, headerCarrier extracted 1. The pre-existing equivalence test compared only Get and Keys, so it could not catch this. The new test compares extracted baggage member by member against the stdlib carrier across four header shapes, and asserts the interface is satisfied. Values deliberately does not use the canonical-key fast path: baggage is not one of the keys that map covers, and a carrier replacing a stdlib one has to be a faithful drop-in first and an optimization second. BenchmarkTracer and BenchmarkTracer_Recording send a bare request, so the propagators find nothing and the canonicalization this PR avoids never runs - their delta is the per-route cache and the IsRecording gate alone. BenchmarkTracer_WithPropagationHeaders sends traceparent, tracestate and two Baggage headers, which is what a service behind a mesh actually receives and the only shape where the carrier's saving is visible. The propagator lookup keys are constants now rather than three repeated string literals.
…an design Rebasing onto #3770 replays this branch's tracer.go and tracer_test.go over #3770's, which drops #3770's twelve tracer characterization tests wholesale. All twelve are restored here unchanged - they exercise the middleware through its exported surface and are indifferent to whether the span metadata is cached. Test_TracerContract_ReusesStatusResponseWriter is the one that matters most: it pins the StatusResponseWriter block this branch wraps in the IsRecording gate, which is the overlap the review flagged as the place a careless merge resolution would silently drop the gate. It passes against the gated code, and the gate is verified present.
…d status RecordHistogramAttrs called metric.WithAttributes on every observation, which builds an attribute.Set -- sorting and deduplicating the attributes -- and an option wrapper. For a request metric the attribute combinations come from a small fixed set, so that work produced the same option over and over. RecordHistogramOpt accepts an already-built option, and the middleware caches one per (path, method, status). The cache carries a ceiling: the path label falls back to the raw request path when no route template is available, and a raw path is caller-controlled, so past the limit the option is built per request exactly as before. The recording strategy is now selected once, at middleware construction, from what the backend supports -- prebuilt option, attribute slice, or plain labels -- and each strategy is its own type. The request path makes a single call and carries none of that branching, which also clears the cyclomatic-complexity warning Metrics carried before this change. RecordHistogramOpt is additive: RecordHistogramAttrs and RecordHistogram are unchanged, and an external implementation providing neither is served by the same varargs path it used before. The status attribute remains a string, so OTLP queries expecting a string label keep working across all three paths. (cherry picked from commit e76395aa4eb1ec6c1b8d15b3c4bd61841a5ce8da)
RecordHistogramOpt took a single measurement option and passed it to histogram.Record, whose options parameter is variadic. Handing a lone option to a variadic allocates a fresh one-element slice on every observation, which made this the largest single allocation in the request path once the option itself was cached. The option slice is now cached alongside the rest of the per-(path, method, status) data and passed through with opts..., which reuses the existing slice rather than building one. The signature takes metric.RecordOption rather than MeasurementOption for the same reason: it is what histogram.Record accepts, so nothing has to be converted or re-wrapped at the call site. (cherry picked from commit ebd679a2530f3f9bb78764d2c121cb3257827abc)
The change adds an option-based recorder so a caller whose label combinations come from a small fixed set can build the measurement once and reuse it. Nothing measured what that saves. Two benchmarks, both shipped here, so the before and after are visible side by side rather than needing a checkout of the previous revision: RecordHistogramAttrs 4 allocs/op 424 B/op ~928 ns/op RecordHistogramOpt 0 allocs/op 0 B/op ~164 ns/op The cost being removed is metric.WithAttributes on every observation, which sorts and deduplicates the attributes into a new attribute.Set and wraps it -- work that produces an identical option every time for a request metric keyed by route, method and status.
Addresses review on #3972. Both caches now go through the routeCache introduced for the span cache in #3970, under the same rules, because all three sit on the same attacker-influenced key space and bounding them one at a time does not help - one malicious request stream inflates whichever is left unbounded. attrsRecorder.cache had no ceiling at all while its sibling had one. Only a templated route may be cached now. Every branch of the path label that falls back to r.URL.Path yields a caller-controlled string - an unmatched route, a static-asset extension, /static, and the "/" template GoFr's PathPrefix("/") catch-all produces for every unmatched request. Those used to be cached, so a stream of unique unmatched paths filled the cache to its ceiling, after which every first-seen legitimate route could never be stored and rebuilt its measurement option per request forever. Memory stayed bounded and correctness was unaffected, but the optimization silently reverted to baseline for production traffic. The method half is filtered too, for the reason given in cacheableMethod: a bounded route with an unbounded method reopens the same growth. The fast path all production traffic uses now has label assertions. metricsManager implements RecordHistogramOpt, so requests flow through optionRecorder, yet nothing pinned what it emitted. Changing status from attribute.String to attribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both are now caught - verified by making each change and watching the test fail. TestMetricsOptCacheIsBounded asserted that a compile-time constant was positive, so deleting the ceiling kept it green. It drives more than the limit in distinct keys through record() and inspects the cache size now; deleting the ceiling fails it, TestRouteCacheStopsAtItsLimit and TestAttrsRecorderIsCorrectAndBounded together. attrsRecorder was at zero coverage - no in-tree type selects it, since *metricsManager implements both optional interfaces and optionRecorder wins - and now has direct tests for its labels, its reuse and its bound. The "-650 B/request" figure is dropped. The shipped benchmarks give BenchmarkRecordHistogramAttrs at 424 B/op and 4 allocs/op against BenchmarkRecordHistogramOpt at 0 B/op and 0 allocs/op, with BenchmarkAttrBuild_HTTP at 448 B/op for the attribute build a cache hit also avoids. Those are the numbers the PR body now quotes.
…ption design Rebasing onto the #3770 stack replays this branch's metrics_test.go over #3770's, dropping its twenty metrics characterization tests. All twenty are restored here unchanged. They are worth more on this branch than they were on #3770. The path label resolution they pin - template vs raw path, static extensions forcing the raw path, the /static prefix, trailing-slash trimming, the GraphQL skip - is exactly the logic this branch refactored into metricsPath in order to report whether the label is a bounded route template. They pass against the refactor, which is independent evidence that threading `templated` out did not disturb what gets labelled. Test_MetricsContractFastAndSlowPathsAgree also pins the two recorder strategies emitting identical labels, which is the property this branch's new optionRecorder assertions depend on.
16ee698 to
9aee0c9
Compare
5d30c46 to
589fd80
Compare
…llocs # Conflicts: # pkg/gofr/http/middleware/routecache.go # pkg/gofr/http/middleware/tracer.go # pkg/gofr/http/middleware/tracer_test.go
The cached-option branch discarded the type assertion's ok:
opts, _ := v.([]metric.RecordOption)
o.rec.RecordHistogramOpt(ctx, histogramName, seconds, opts...)
A failed assertion leaves opts nil, and recording with no options emits
the measurement with NO attributes -- an unlabelled time series that
looks like a working metric and silently corrupts whatever queries it.
That is worse than the nil dereference the same shape produced in the
tracer cache, because nothing fails loudly.
Nothing else stores into this cache, so it cannot fail today. It now
falls through to the rebuild the miss path already performs, matching
what spanMetaFor does since #3970.
attrsRecorder already handled this correctly -- its assertion is followed
by an `if b == nil` rebuild -- so only the option path changed.
No behaviour or allocation change: BenchmarkRecordHistogramOpt stays at
0 B/op, 0 allocs/op. pkg/gofr/http/middleware and pkg/gofr/metrics pass
under -race.
Up to date, and a self-reviewMerged 0 behind Requested changes, and where they landed
The numbers, re-run just now
Matches the description exactly. One correction: the description's What I found reviewing my own diffFixed in opts, _ := v.([]metric.RecordOption)
o.rec.RecordHistogramOpt(ctx, histogramName, seconds, opts...)A failed assertion leaves Observation, not a change. Out of scope, pre-existing. Both recorders pass Why this matters beyond the allocation winMeasured against An unauthenticated client inflates metric cardinality by varying the HTTP method alone. #3970 closed the tracer half; this is the metrics half, and it is live on |
Ran it as a service — and I need to correct something I wrote aboveI said this PR "closes the metrics-side cardinality hole". That is wrong, and running it is what showed it. Two binaries, Metric cardinality is unchanged, and was never GoFr's to bound
Identical. The cap at ~2,000 is the OTel SDK's own default cardinality limit, spilling the rest into the overflow bucket — it is doing that on So the attacker-influenced cardinality question is still open, on both, and it is a separate change: it needs the label itself constrained (an allow-list of methods, or bucketing unknown ones), not a cache ceiling. Worth its own issue rather than being folded in here. What the bound does buy, measured
About 9 MB less resident, and flatter as the flood grows — that is the cache ceiling doing its job. The remainder is the SDK's own series, which neither branch changes. Behaviour is identical on real trafficSame app, same requests, exported series compared byte for byte: Identical on both. Two different concrete paths ( Summary of what this PR actually delivers
It does not bound metric cardinality, and the description should not be read as claiming it does. |
Retracting the cardinality framing entirelyI called the metric cardinality an open hole needing its own fix. It is not — it is a configured SDK limit behaving as designed, and I should have checked before writing that.
So the ~2,000 ceiling and the What this PR does, stated plainly
That is the whole claim, and it holds. |
The comment added in 4af7ab8 used en-GB spellings, which both spelling gates reject: typos: `unlabelled` should be `unlabeled` metrics.go:207 misspell: `honoured` is a misspelling of `honored` metrics.go:205 Comment only; no code change.
Umang01-hash
left a comment
There was a problem hiding this comment.
LGTM. Ran it locally at this head — booted examples/http-server and scraped /metrics: app_http_response emits correctly through the real metricsManager→optionRecorder path (path/method/status all String). Benchmarks confirm the win: Opt = 0 allocs/110ns vs Attrs = 4 allocs/424B/284ns. gofmt/vet/build/-race tests/golangci-lint --new-from-rev all clean; 100% cover on every new func. Nice catch bounding the previously-unbounded attrs cache under routeCache + cacheableMethod + the templated gate — that closes a real memory-growth vector, not just a perf tidy.
All comments below are nits, nothing blocking.
| o.rec.RecordHistogramOpt(context.Background(), histogramName, seconds, opts...) | ||
| } | ||
|
|
||
| // attrsRecorder caches the (path, method) attribute pair and copies it into a |
There was a problem hiding this comment.
nit: attrsRecorder is unreachable in prod — *metricsManager implements RecordHistogramOpt and newHistogramRecorder checks metricsOptRecorder first, so optionRecorder always wins. The tier only ever runs from its own test. Fine to keep as a migration path for external Attrs-only Managers, but if that's not a supported surface this could collapse to option+labels.
| // silently unlabeled time series rather than a visible failure. Falling | ||
| // through rebuilds instead. | ||
| if v, ok := o.cache.load(key); ok { | ||
| if opts, ok := v.([]metric.RecordOption); ok { |
There was a problem hiding this comment.
nit: this inner type-assertion can't fail — the cache is single-consumer and only ever holds []metric.RecordOption, which is also why the fall-through has no coverage. opts := v.([]metric.RecordOption) would read as honestly single-typed.
| // its ceiling, after which every first-seen LEGITIMATE route could never be | ||
| // stored and rebuilt its measurement option per request forever. Memory stayed | ||
| // bounded; the optimization silently reverted to baseline for real traffic. | ||
| func metricsPath(r *http.Request) (path string, templated bool) { |
There was a problem hiding this comment.
nit: the new templated return is only pinned indirectly via the cache-growth tests; a direct metricsPath() unit test over static-ext / "/" / /static / unmatched would assert it cleanly. Non-blocking.
TL;DR — 4 allocations per observation → 0. No behaviour change.
The problem
Every request records a histogram. Every observation called:
which sorts and deduplicates the attributes into a new
attribute.Setand wraps it in an option.For a request metric the labels are
(route, method, status)— drawn from a small fixed set. So that work produced an identical option over and over.The fix
Build the option once per
(route, method, status)and reuse it, via a new option-based recorder on the metrics manager.The option is passed as a slice, not a single value — passing one option would allocate a fresh one-element slice for the variadic on every call, undoing the saving.
The numbers
Both benchmarks ship here, so before/after is visible side by side without checking out the old revision:
RecordHistogramAttrs(rebuilds each time)RecordHistogramOpt(cached option)Safety
Test coverage added after review
metricsManagerimplementsRecordHistogramOpt, so requests flow throughoptionRecorder, yet nothing pinned what it emitted — changingstatusfromattribute.Stringtoattribute.Int, or emitting the concrete path instead of the route template, left every test green while dashboards broke and cardinality exploded. Both now fail the suite; verified by making each change.TestMetricsOptCacheIsBoundeddrives the guard. It used to assert only that a compile-time constant was positive, so deleting the ceiling kept it green. Deleting it now fails three tests.attrsRecorderwas at 0% coverage — no in-tree type selects it, since*metricsManagerimplements both optional interfaces andoptionRecorderwins — and now has direct tests for its labels, its reuse and its bound.Measurement
ns/op is quoted here only because both benchmarks ran back to back on the same binary; the allocation counts are the reliable signal.