A dependency-free Prometheus /metrics endpoint (#91) - #199
Conversation
The first work item of #91. The runtime bundles no telemetry SDK by design, but the Prometheus text exposition format needs no client library at all -- it is a few lines of text over HTTP -- so this backend costs zero new dependencies and lives in :server directly. MetricsRegistry aggregates the existing Observe hooks into three families: requests_total{method,operation,status}, request_duration_seconds{method, operation} as a histogram, and requests_in_flight. MetricsEndpoint serves them; RecordMetrics is Observe wired to a registry, built on Observe rather than beside it so request timing keeps one implementation and the scraped numbers cannot drift from the logged ones. Composing the endpoint outside the recorder -- the documented order -- lets scrapes answer without inflating the request rate they report. Cardinality is the failure mode a metrics endpoint actually dies of, so the label set is bounded by construction rather than by convention. `target` is never a label: it carries path parameters and query strings, so one series per distinct URL is one series per request id, and `operation` is the bounded stand-in the router stamps from the model. `method` arrives off the wire, so a loop of `curl -X <random>` would otherwise mint a series per invented verb -- anything outside the standard set collapses to "other", case-sensitively, so a rejected "get" is not reported as served GET traffic. A series cap backstops anything unforeseen (a hand-written handler stamping its own operation) and counts each refused observation once in smithy_metrics_observations_dropped_total, so the limit is something to alert on rather than discover as an OOM. Status is the exact code rather than a class: bounded either way, and `{status=~"5.."}` recovers the class at query time while the reverse direction loses information that matters at 3am. Scope is the server side; the client-side registry #91 also asks for waits on that issue's open hook-shape question, since AttemptObservation carries no duration today and item 3 sequences hook changes separately. Tested: 21 unit tests over the registry and the composed middleware -- exposition format (HELP/TYPE, cumulative buckets, +Inf, sum/count), sub-millisecond latencies surviving the microsecond hook, concurrent recording, method clamping, the series cap's exact accounting, label escaping, and in-flight pairing including the throwing-handler path -- plus two tests over a real Beast socket for the scrape and for HEAD reporting the GET's length. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
The operation label is the part only this level can check. In-tree the endpoint is driven by hand-written handlers that stamp HttpResponse::operation themselves, so those tests would keep passing if the generated router stopped stamping it -- and the label would go empty for every request in every real deployment, collapsing per-operation dashboards into one anonymous bucket. Three cases over a real socket against the generated Todo service, composed from consumer code against the published targets: the model's operation names reach the counters and the histogram, three scrapes add no series of their own and leave nothing in flight, and an unrouted request counts under an empty operation without its target leaking into a label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
MoonBase forked this document and developed it further; this brings the additions back, adapted rather than copied. New rules: fold review feedback into the PR it came from; question the request itself, not just how to build it; panel after pushing, on four lenses including altitude, with read-only agents enforced structurally; TDD as the default rather than bug-fixes-only; mutation checking is not a substitute for writing the test first; test through the objects production uses; watch for tests that do not actually run; CI is cheaper than model tokens. Adds a "Writing it down" section covering comments, commit messages and PR bodies, and turns the verification list into a table naming each command and the CI job that gates it. Adapted, not transplanted: MoonBase's receipts are replaced with this repo's, its squash-merge rationale for terse commit messages is replaced with the merge-commit one that actually applies here, and its "no ADRs, no CHANGELOG" section is dropped since both exist here. Two new receipts are local: #130's stash-vs-cancel question dissolving on contact with the transports, and `make verify` reporting success through a pipe while lint aborted on a missing buildifier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
The registry only accepted RequestObservation, so an application metric had
nowhere to go: a consumer wanting orders_processed_total needed a second
registry behind a second endpoint, and a Prometheus target scrapes one
endpoint. NewCounter/NewGauge/NewHistogram mint typed families served
alongside the built-in HTTP ones.
The registry keeps owning what corrupts a scrape when it goes wrong: label
values are escaped, labels are sorted so {a,b} and {b,a} are one series
rather than two, the per-family cap attributes overruns to
smithy_metrics_observations_dropped_total{metric="..."}, and an invalid or
colliding metric name aborts at registration. Each of those otherwise
produces output Prometheus rejects in full, with nothing in-process to
notice. Handles share ownership of their family, so one outliving its
registry is inert rather than dangling.
Also fixes the exposition's last line losing its terminating newline when no
application families follow.
The consumer example now emits a labelled counter, a histogram and a gauge
from its handler, proving they reach the same scrape as the generated
router's operation labels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
A series nobody has touched is absent from the scrape, so a counter's first exported sample is its first event's value — and increase() has nothing earlier to measure against, which hides that event for good and leaves the panel reading zero. Declare() materializes a series at its baseline; idempotent, cap-respecting, and it never disturbs one that has events. A declared histogram is genuinely empty rather than an observation of zero, so rate(_sum)/rate(_count) stays unbiased — writing the exposition directly avoids the bias a record-only API would have to accept. Ported from MoonBase futility/otel (#1323, #1384). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
BeastServerTransport answers over-limit requests itself, while the parser is still reading, so RecordMetrics never sees them and a 413/431 flood is invisible in the counters. RecordRejections feeds Options::on_rejected; generic in the rejection type, so :server keeps no Beast dependency. They count and nothing more. No latency is filed: a request refused at parse time has no service latency, and zeros would drag rate(_sum)/rate(_count) down, flattering the panel during exactly the flood it should expose. The gauge stays untouched, since such a request was never in flight. A method that never parsed is labeled "unparsed" rather than "other" -- a 431 can fire mid-headers, and that is a different diagnosis from an invented verb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
HealthEndpoint built its response without stamping HttpResponse::operation, so every probe reached Observe — and so any metrics or logging backend — as the empty operation, which is what 404/405/400 dispatch failures already report. An orchestrator polls liveness every few seconds, making it usually the highest-volume path a service has, so the 404 rate was buried under probe volume, the probe's latency contaminated the service duration histogram, and no query could separate them. MetricsEndpoint had the same hole. Both now report their composed path. It is fixed at composition, never off the wire, so this costs one series per composed endpoint rather than reopening the cardinality question `target` was excluded for. Composing probes outside RecordMetrics still leaves them uncounted; the difference is that either choice is now expressible. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
Two changes to MetricsRegistry, both about it being someone else's decision how this gets used. Off by default. MetricsOptions::enabled is false, and off means absent rather than idle: RecordMetrics and MetricsEndpoint compose to the identity, so a disabled registry puts no wrapper on the request path — no timing, no lock, not even a call frame — and /metrics reaches the router like any other unmodeled path. An empty 200 there would read to Prometheus as a live target reporting no series, which is what a service whose metrics have gone silent also looks like. Handles are inert rather than unusable so application code never branches on the flag, but their arguments are still built at the call site, so enabled() is exposed for a hot one. Registration is deliberately not conditional: an invalid name, a type collision or a bad ladder aborts at startup either way, so turning metrics on in production is never the first time those checks run. Configurable exposition, because the format is a contract with whatever is already scraping. MetricsOptions::Aura() is a transcription of MoonBase's shared HTTP vocabulary — the five http_server_* families and their pinned descriptions, service_name/http_method/route, the unmatched and /health route sentinels, CUSTOM and (unparsed) for methods, and the microsecond ladder its three emitter rails pin equal — so a service here can replace an aura, yodel or server_pal one without touching a dashboard. Success and failure are derived from the same status-keyed tally the total sums rather than counted separately, so the three cannot disagree; the in-flight gauge is keyed by method and never by route, which is where every rail leaves it because it moves before dispatch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
The families were smithy_http_requests_total, smithy_http_request_duration_seconds and smithy_http_requests_in_flight. Smithy is the IDL the service is described in — not a property of the request being counted, and not something a dashboard reading HTTP traffic has any reason to know. The prefix named the wrong thing. They are now http_requests_total, http_request_duration_seconds and http_requests_in_flight, which is Prometheus's own conventional naming, and the registry's drop counter is metrics_observations_dropped_total. That name is configurable now too, so nothing in the exposition is hardcoded any more. Deliberately not http_server_*: that stem belongs to the Aura preset, and keeping the two dialects distinct is what lets a test assert one is not being emitted alongside the other. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
The previous two commits built a configurable exposition with a preset for MoonBase's vocabulary. Those services are the consumers, so the configurability was surface with no user: what it actually offered was a way for one service to drift off a contract the fleet shares, and that drift is silent — prom_proxy renders an empty panel, which reads as a quiet service rather than a misconfigured one. So there is one exposition now, transcribed from what //domains/platform/libs/otel_contract pins across MoonBase's Java, Rust and C++ rails: the five http_server_* families with their descriptions, service_name/http_method/route, the unmatched and /health route sentinels, CUSTOM and (unparsed) for methods, and the microsecond ladder. MetricsOptions is down to three fields that are still genuine choices — whether it runs, who it says it is, and the cardinality cap. service_name is required when enabled, and empty aborts. Every dashboard query selects on it, so a service reporting the empty string is scraped, stored, and absent from all of them: success everywhere except the panel nobody is watching yet. The transport tests found this the moment it landed, which is the argument for it. NewHistogram no longer defaults its buckets. The built-in ladder is microseconds and shaped for request latency; inheriting it silently for a histogram of bytes would produce a chart whose bins mean nothing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
Read across aura/middleware_test.cc and futility/otel/http_metrics_test.cc for cases those rails found the hard way. Four were missing here, each catching a class of drift the per-value assertions miss: - No series carries the old `method` spelling. futility needed this pin because that rail alone had historically spelled it `method`; a single stray one forks every dashboard series for the service, and the panel renders empty rather than wrong. Swept across all five families so a family added later cannot quietly reintroduce it. - A query string does not defeat the /health route. Orchestrators poll with one, and if it pushed the probe off the route, prom_proxy's subtraction would stop matching and probe volume would rejoin the serving numbers. - Scanner paths collapse into one series. The cap already backstops this, but only after the damage; collapsing at the label is what stops it starting. - Invented methods collapse on the gauge too, not just the counters. The gauge is keyed by method and moves at request start, so it needs the same normalization — a separate code path from the one the existing method tests cover. Two divergences found and deliberately not changed. futility puts status_code/result/error_type on its failure counter and histogram; yodel and server_pal do not, and otel_contract pins only the route sentinels cross-rail, so the leaner set stays. futility splits success at >=200 && <400 while yodel and server_pal both use <400, which is what is implemented here. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
Closes #202. The observation reported six fields — enough for metrics, which is what it was built for, and not enough for the other thing every server does with a per-request hook. MoonBase's aura says so in its own comment: its access log is kept separate from Observe because the observation lacks the response size and the forwarded client. So the chain runs two clocks per request measuring the same interval, at different depths, free to disagree. The sharper gap is in the same file. The raw x-forwarded-for aura logs instead is not the ADR-0012 derived client PerClientRateLimit keys on, so "whose bucket did that 429 come from" is unanswerable: the service computed the answer and dropped it. Added: request_bytes, response_bytes, handler_threw, and client — the derived client with its provenance, never the forgeable header. The source is worth carrying alongside the address because its distribution, not any single value, is the misconfiguration signal. handler_threw separates a contained crash from a deliberate 500. Both report status 500 with no operation, and a log that cannot tell them apart sends whoever reads a 5xx spike looking for the wrong thing. The exception text is not copied here; it stays on the transport's containment log, under the same trace id. Observe takes the trust boundary as a fourth defaulted parameter rather than reading it off the request. The alternative — having the transport derive and stamp it at ingress, the way it already mints traceparent — is architecturally nicer but puts the boundary in two places that can disagree, which is what ADR-0012 exists to prevent. Defaulting to TrustedProxies::None() means an unconfigured service reports the peer, which is true, rather than nothing. Costs no new dependency: middleware.h already includes forwarded.h. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
ReviewThe design is in good shape: identity composition when off, MoonBase names/labels/sentinels pinned by tests, Defects below. The first two are the ones that would actually mislead a dashboard or a scraper. Defects1. Under The simplification that also fixes it: since status is no longer a label, key the built-in counters the same way as latency ( 2. Application drops split Emit every dropped sample next to the first TYPE line (and escape HELP). 3. The production-guide snippet aborts if copied. Nits (stale contract text)
No ask to unbundle the working-agreement commit or #202. |
Both would mislead a reader of the scrape rather than fail loudly, which
is the failure mode a metrics endpoint has no in-process consumer to
catch.
The counters and the histogram could disagree under the cap. Counters
were keyed by {method,route,status} while the histogram was keyed by
{method,route}, and since status is not exported the counter map held
strictly more keys for the same traffic — so it reached max_series
first. Past that point a new status on an already-admitted route was
refused by the counters while the histogram, whose key already existed,
kept recording: requests_total silently stopped counting while _count
went on rising, and the three counters advertised as views of one tally
stopped agreeing with the histogram beside them. The existing cap test
only varied the route at one status, so it never reached the path.
They now share one route-keyed record with running total/success/failure
tallies and the histogram together — one key, one admission, one cap, so
there is nothing left to drift. The counters and the histogram are still
allowed to differ in the one case that is deliberate: a rejection is
counted and not timed, so a route that only ever saw rejections has
requests and no histogram series rather than an all-zero one claiming an
observation nobody filed.
metrics_observations_dropped_total was emitted in two pieces — the
unlabeled total, then every application family, then its own per-family
attributions — and the format requires a metric's lines to arrive as one
group. Now emitted whole, before the application families. The new test
checks every family for contiguity rather than that one, so the class is
pinned and not just the instance.
Also from review: AppendFamilyHeader interpolated help unescaped, so a
newline in an application family's help text ends the HELP line early and
whatever follows poses as its own directive; NewHistogram never validated
its ladder despite the guide promising it does, and an unsorted one
yields cumulative buckets that disagree with themselves. Both now behave
as documented, with mutation-checked tests.
Stale contract text corrected: "other" is CUSTOM, the rejection paragraph
described operation="" and unparsed rather than route="unmatched" and
(unparsed), and the guide's snippet omitted the service_name that the
same page says is required.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
Beyoncé pass (#202 access-log fields)If you liked it, you should have put a test on it — and a consumer that actually uses it, not one that compiles against the type.
Consumer / example: no logger uses the new dataThe out-of-tree Observe sink still ignores the observation: smithy::server::Observe(
[&completed](const smithy::server::RequestObservation&) { ++completed; },
[&started](const smithy::server::RequestStart&) { ++started; }),
The production-guide chain is the same gap in the copy-paste path: The "Watch the trust boundary" paragraph still tells you to wrap a one-line middleware around #203 being a follow-up does not excuse this. The contract being shipped is "these fields are what an access log needs." Beyoncé wants a consumer test that is that log: generated server, real socket (peer stamped), same Until that exists, the new members are in-tree coincidence plus documentation. |
Review was right: the four fields #202 added had unit tests and nothing a consumer would write. Every one could have been deleted without a file outside the runtime noticing — the consumer module's Observe sink takes `const RequestObservation&` and increments a counter. Worse, the composition the guide teaches demonstrated the bug the commit claimed to close. `PerClientRateLimit` got the trust boundary and `Observe` did not, so a reader copying that chain gets TrustedProxies::None() on the sink: the log names the peer while the limiter keyed on the forwarded client. The snippet now passes the same `trusted` to both, and its callback lists the fields that exist rather than the six it used to. The "Watch the trust boundary" paragraph still taught the pre-#202 workaround — wrap a one-line middleware around DeriveClient "because Observe's sink sees only the finished observation, not the request". That was the hole this closed; `o.client.source` is on the observation now. New out-of-tree acceptance test that IS an access log: generated router, real Beast socket so the peer is stamped, the same TrustedProxies handed to both the limiter and Observe, a sink recording all four fields. It asserts the logged client equals the key the limiter was asked about rather than a literal, so the two cannot drift apart silently; that a 429 is logged with the client it was rejected for, which is the question #202 was opened for; that the byte counts come from the real wire bodies; and that handler_threw survives Beast's containment, which the in-tree test cannot show because it rethrows without a transport. Observe is composed outside the limiter there, deliberately — the limiter short-circuits, and the 429 is exactly the request whose client you want logged. Noted in the guide as the trade against observing traffic you refused. Mutation-checked with the guide's own former bug: dropping `trusted` from Observe makes the log report 127.0.0.1 while the limiter keyed on 203.0.113.7, and three of the four tests fail. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
aaylward
left a comment
There was a problem hiding this comment.
Verdict
Approve — 0 blocking, 1 non-blocking
Both fix commits correctly address every blocking finding from the thread. The RouteStats refactor is the right structural fix (not a band-aid), the new tests pin the exact failure modes reviewers identified, and the access-log acceptance test is a genuine consumer contract across the module boundary. CI is green. This is safe to merge.
Reviewed the two commits that landed after the initial review: 07c72c0 (scrape-integrity fixes) and 1082127 (access-log consumer contract).
What Was Done Well
RouteStatsunification — One{method, route}key owns counters, success/failure tallies, and histogram together. The deliberate rejection-only path (counted, not timed, no empty histogram) is documented and tested.- Exposition integrity — Drop counter emitted whole before application families;
EscapeHelpfor caller-controlled strings; histogram ladder validation at registration (ADR-0009). - Test quality —
TheCapCannotSplitACountFromItsLatencyreproduces the exact counter/histogram drift scenario.EveryFamilyIsContiguousvalidates all families, not just the one instance.access_log_acceptance_test.cctests the composition a reader would copy: generated router, real Beast socket, sameTrustedProxieson limiter andObserve, 429 logging, byte counts, andhandler_threwthrough transport containment. - Guide fixes — Snippet passes
service_name, passestrustedtoObserve, documents Observe-outside-limiter trade-off, removes the pre-#202 workaround paragraph.
Non-Blocking Issues
access_log_acceptance_test.cc— Commit message claims mutation-checking (droppingtrustedfromObservefails 3/4 tests), but there's no explicit mutation test in-file. The tests would fail with wrong wiring, which is good implicit coverage; an explicit negative case or a comment pointing to the failure mode would make the claim auditable without re-running manually.
Test Coverage
Adequate for the fixes. The two commits add 7 new tests covering cap drift, rejection-without-histogram, family contiguity, HELP escaping, histogram ladder aborts, and 4 consumer acceptance scenarios. No gaps that would block merge.
Review's non-blocking point: the commit message said the acceptance test was mutation-checked by dropping `trusted` from Observe, but nothing in the file made that verifiable without re-running the mutation by hand. A claim in a commit message is not a test. The misconfiguration is now wired up and its symptom asserted, as a negative control beside the positive case: a server whose limiter has the trust boundary and whose Observe does not — which is what the production guide's chain used to teach — logs 127.0.0.1 while the limiter keys on 203.0.113.7, and nothing anywhere reports an error. It also states why the positive test compares the two values to each other rather than to literals: both numbers look plausible alone, so only their disagreement is the signal. If the misconfiguration ever stops being observable, this fails and says the positive test has stopped proving anything. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU
Closes #202.
What
The first work item of #91, server side.
MetricsRegistryaggregates the existingObservehooks;MetricsEndpointserves them in the Prometheus text exposition format, which needs no client library — so this costs zero new dependencies and lives in:serverdirectly.RecordMetricsisObservewired to a registry, built on it rather than beside it so request timing keeps one implementation.Off unless asked for, and absent rather than idle when off
MetricsOptions::enableddefaults to false. Disabled,RecordMetricsandMetricsEndpointcompose to the identity — not a wrapper that checks a flag, but no wrapper at all, so a served request runs the call chain it would have run had this never been written./metricsthen reaches the router like any other unmodeled path and 404s; an empty 200 there would read to Prometheus as a live target reporting no series, which is indistinguishable from a service whose metrics have gone silent. Handles from a disabled registry are inert rather than unusable, so application code never branches on the flag; only their arguments still cost anything, soenabled()is exposed for a hot call site whose labels are expensive to build.Registration is deliberately not conditional on the flag. An invalid metric name, a type collision, or a missing
service_nameaborts at startup either way (ADR-0009), so switching metrics on in production is never the first time those checks run.The exposition is MoonBase's, and is not configurable
Five families labeled by
service_name,http_method,route:http_server_requests_totalhttp_server_requests_success_totalhttp_server_requests_failure_totalhttp_server_requests_active_gaugeroute)http_server_request_duration_microsecondsplus
metrics_observations_dropped_total, the registry's own health.This is not a vocabulary invented here. It is MoonBase's shared HTTP serving contract, spoken identically by its Java (yodel), Rust (server_pal) and C++ (futility/otel, behind aura) emitters and pinned across them by
//domains/platform/libs/otel_contract. Those services are who scrapes this, and prom_proxy queries exactly these names with exactly these labels.An earlier revision made all of it configurable with a preset. That was surface with no user, and worse: a knob is a way for one service to drift off a contract the fleet shares, and the drift is silent — the panel renders empty, which looks like a quiet service rather than a misconfigured one.
MetricsOptionsis now three fields that are still genuine choices: whether it runs, who it says it is, and the cardinality cap.Things the diff doesn't show:
route!="/health"matcher passes a series without the label through untouched — which is what makes the same filter safe on it.service_nameis required when enabled, and empty aborts. Every dashboard query selects on it, so a service reporting the empty string is scraped, stored, and absent from all of them: success everywhere except the panel nobody is watching yet. The transport tests found this the moment it landed.targetis never a label. An unrouted request reportsunmatchedrather than the empty string, becauseroute!="/health"matches the empty string and unrouted traffic would silently join the serving figures.http_methodcollapses toCUSTOMoutside the nine RFC 9110 verbs (case-sensitively — a rejectedgetmust not report as servedGET), and a request rejected before its method parsed reports(unparsed).status_code/result/error_typeon its failure counter and histogram; yodel and server_pal do not, andotel_contractpins only the route sentinels cross-rail, so the leaner set stays. And futility splits success at>=200 && <400while yodel and server_pal both use<400, which is what is implemented here.Folded in: probes stop reporting as the unrouted sentinel
HealthEndpointbuilt its response without stampingHttpResponse::operation, so every probe reachedObserve— and so any backend, not just this one — under the same value 404/405/400 dispatch failures use. An orchestrator polls liveness every few seconds, making it usually the highest-volume path a service has, so the 404 rate was buried under probe volume, the probe's latency contaminated the service duration histogram, and no query could separate them.MetricsEndpointhad the same hole.Both now report their composed path — fixed at composition, never off the wire, so it costs one series per composed endpoint. It is also the prerequisite for prom_proxy's fleet-wide
route!="/health"subtraction.Folded in:
RequestObservationcarries what an access log needs (#202)The observation reported six fields — enough for metrics, and not enough for the other thing every server does with a per-request hook. aura says so in its own comment: its access log is kept separate from
Observebecause the observation lacks the response size and the forwarded client, so the chain runs two clocks per request measuring the same interval at different depths, free to disagree.The sharper gap is in the same file: the raw
x-forwarded-forit logs instead is not the ADR-0012 derived clientPerClientRateLimitkeys on, so "whose bucket did that 429 come from" was unanswerable — the service computed the answer and dropped it.Added:
request_bytes,response_bytes,handler_threw, andclient— the derived client with itssourceprovenance, never the forgeable header. The provenance rides along because its distribution, not any single value, is the documented misconfiguration signal.handler_threwseparates a contained crash from a deliberate 500; both report status 500 with no operation, and a log that cannot tell them apart sends whoever reads a 5xx spike looking for the wrong thing.Observetakes the trust boundary as a fourth defaulted parameter rather than reading it off the request. The alternative — the transport deriving and stamping it at ingress, as it already mintstraceparent— is architecturally nicer but puts the boundary in two independently-configured places that can disagree, which is what ADR-0012 exists to prevent.TrustedProxies::None()as the default means an unconfigured service reports the peer, which is true, rather than nothing. Costs no new dependency:middleware.halready includedforwarded.h.This unblocks #203, the structured access-log formatter, which is deliberately not in this PR.
Testing
Unit tests over the registry, the application families, and the composed middleware: exposition format, sub-millisecond latencies surviving the microsecond hook, concurrent recording, method clamping, cap accounting, label escaping and ordering, registration aborts, a handle outliving its registry, and in-flight pairing including the throwing-handler path.
The
ExpositionContractsuite pins the MoonBase literals — the five names with their descriptions, the label set, the outcome split at 400, the gauge keyed by method and never by route, the route and method sentinels, and the 15-bound microsecond ladder. Four of those were borrowed from MoonBase's own rails after reading their tests: no series carries the oldmethodspelling (swept across all five families), a query string does not defeat the/healthroute, scanner paths collapse into one series, and invented methods collapse on the gauge too — a separate code path from the counters, and the one gap the borrowed reading actually found.The
DisabledRegistrysuite pins the other half. The identity-composition test uses a plain function as the terminal sostd::function::targetis non-null exactly while nothing has wrapped it, and asserts the enabled case does wrap — without that second half the first proves nothing.The observation fields get five tests, including both forwarded-client directions: behind a trusted proxy the header's client entry wins, and from an untrusted peer the forged header is ignored. Mutation-checked — forcing
TrustedProxies::None()makes the trusted case report the proxy instead of the client, and the test fails.Two over a real Beast socket. Nine in the out-of-tree consumer module, driving the generated router so the
routelabel is the model's and not a fixture's.Locally:
make verify(127 tests under--config=werror, lockfiles, codegen, goldens, clang-format),make noexcept, gcc ASan and TSan, clang-tidy, buildifier via npx, and the 14-test consumer suite. Two sandbox gaps, both verified another way:make lint's buildifier has no system binary here (npx passes), and clang-tidy cannot parsebeast_transport.ccwithout Boost headers, which CI installs — the files this PR touches are clean.Deliberately not covered
The client-side registry #91 item 1 also asks for.
AttemptObservationcarries no duration, so the per-attempt latency histogram can't be built fromObserveAttemptstoday — that's the open API-shape question in the issue's own comment, and item 3 sequences hook changes separately and first. Items 2–4 remain, so this does not close #91.Second item carried: one commit updates
docs/working-agreement.mdfrom MoonBase's fork of it (adapted, not copied). Unrelated to the metrics work, kept here by request.Follow-ups filed: #201 (removing
smithyfrom runtime surfaces that have nothing to do with the IDL — the metric names were the visible instance and are fixed here) and #203 (the structured access-log formatter, unblocked by the observation work above).Checklist
bazel test //...and(cd codegen && gradle build spotlessCheck)pass locally🤖 Generated with Claude Code
https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU