Skip to content

A dependency-free Prometheus /metrics endpoint (#91) - #199

Open
aaylward wants to merge 15 commits into
mainfrom
claude/smithy-cpp-dedup-issues-hy5key
Open

A dependency-free Prometheus /metrics endpoint (#91)#199
aaylward wants to merge 15 commits into
mainfrom
claude/smithy-cpp-dedup-issues-hy5key

Conversation

@aaylward

@aaylward aaylward commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #202.

What

The first work item of #91, server side. MetricsRegistry aggregates the existing Observe hooks; MetricsEndpoint serves them in the Prometheus text exposition format, which needs no client library — so this costs zero new dependencies and lives in :server directly. RecordMetrics is Observe wired to a registry, built on it rather than beside it so request timing keeps one implementation.

auto metrics = std::make_shared<smithy::server::MetricsRegistry>(
    smithy::server::MetricsOptions{.enabled = true, .service_name = "todo-service"});
transport.Start(smithy::server::Chain({MetricsEndpoint(metrics),
                                       RecordMetrics(metrics),
                                       HealthEndpoint()},
                                      server.Handler()));

Off unless asked for, and absent rather than idle when off

MetricsOptions::enabled defaults to false. Disabled, RecordMetrics and MetricsEndpoint compose 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. /metrics then 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, so enabled() 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_name aborts 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:

Family Type
http_server_requests_total counter
http_server_requests_success_total counter (status < 400)
http_server_requests_failure_total counter (status >= 400)
http_server_requests_active_gauge gauge (no route)
http_server_request_duration_microseconds histogram

plus 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. MetricsOptions is 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:

  • Status is not a label. The outcome rides on the success and failure counters, which are two views of the same status-keyed tally the total sums — so the three can never disagree, they need no drop accounting of their own, and no series is multiplied by the codes a service happens to return.
  • The active gauge carries no route. It moves at request start, before dispatch, where nothing bounded is known about the path. Every rail leaves the route off it for that reason, and prom_proxy's negative route!="/health" matcher passes a series without the label through untouched — which is what makes the same filter safe on it.
  • 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.
  • Labels are bounded by construction. target is never a label. An unrouted request reports unmatched rather than the empty string, because route!="/health" matches the empty string and unrouted traffic would silently join the serving figures. http_method collapses to CUSTOM outside the nine RFC 9110 verbs (case-sensitively — a rejected get must not report as served GET), and a request rejected before its method parsed reports (unparsed).
  • Two divergences from futility, deliberate. It 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. And futility splits success at >=200 && <400 while yodel and server_pal both use <400, which is what is implemented here.

Folded in: probes stop reporting as the unrouted sentinel

HealthEndpoint built its response without stamping HttpResponse::operation, so every probe reached Observe — 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. MetricsEndpoint had 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: RequestObservation carries 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 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 it logs instead is not the ADR-0012 derived client PerClientRateLimit keys 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, and client — the derived client with its source provenance, never the forgeable header. The provenance rides along because its distribution, not any single value, is the documented 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.

Observe takes 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 mints traceparent — 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.h already included forwarded.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 ExpositionContract suite 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 old method spelling (swept across all five families), a query string does not defeat the /health route, 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 DisabledRegistry suite pins the other half. The identity-composition test uses a plain function as the terminal so std::function::target is 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 route label 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 parse beast_transport.cc without 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. AttemptObservation carries no duration, so the per-attempt latency histogram can't be built from ObserveAttempts today — 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.md from MoonBase's fork of it (adapted, not copied). Unrelated to the metrics work, kept here by request.

Follow-ups filed: #201 (removing smithy from 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

  • Tests added/updated for the change
  • bazel test //... and (cd codegen && gradle build spotlessCheck) pass locally
  • Formatting clean (clang-format, buildifier via npx, spotless)
  • Architectural decisions recorded as an ADR (not applicable — new middleware on the existing composition pattern; no new architecture)

🤖 Generated with Claude Code

https://claude.ai/code/session_014C7WdBD99mUFWGxMvGSjSU

claude added 5 commits August 26, 2026 22:54
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
claude added 4 commits August 27, 2026 00:04
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
claude added 3 commits August 28, 2026 21:26
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
@cursor

cursor Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review

The design is in good shape: identity composition when off, MoonBase names/labels/sentinels pinned by tests, Observe as the single clock, probe operation stamping, and the extra observation fields for #202. CI is green. Bundling #202 / the working-agreement commit with the metrics work is taken as intentional.

Defects below. The first two are the ones that would actually mislead a dashboard or a scraper.

Defects

1. Under max_series, request counters and the duration histogram can disagree. Record enforces the cap on counts_ and latencies_ independently, and CountKey still includes status even though status is not a label. Same method+route with a new status code: the counter insert is refused, the histogram is still updated. http_server_requests_total then undercounts (or never appears for that route) while _count / _sum keep growing — the opposite of “the three counters are views of one tally” once you look at the histogram beside them. The existing cap test only uses distinct routes at 200, so this path is unpinned.

The simplification that also fixes it: since status is no longer a label, key the built-in counters the same way as latency (method,route) and keep running success/failure tallies there. One map, one cap, no way for the families to drift.

2. Application drops split metrics_observations_dropped_total. The unlabeled series is emitted with its HELP/TYPE, then every application family is written, then extra samples of the same metric (with metric=) are appended. Prometheus text 0.0.4 requires a family to be contiguous. Related scrape-corruption hole: application help is interpolated into # HELP with no escaping (newline / backslash).

Emit every dropped sample next to the first TYPE line (and escape HELP).

3. The production-guide snippet aborts if copied. MetricsOptions{.enabled = true} with no service_name is exactly the Fatal the rest of the PR is careful about. Same paragraph still says rejections report operation="" and method unparsed; the scrape is route="unmatched" and http_method="(unparsed)".

Nits (stale contract text)

  • Header + CHANGELOG say nonstandard methods collapse to "other"; code and the exposition tests emit CUSTOM.
  • HistogramData still comments options_.latency_unit, which is gone.
  • Guide claims a bad histogram bucket ladder aborts at registration; NewHistogram does not validate order/emptiness.

No ask to unbundle the working-agreement commit or #202.

Comment thread runtime/src/server/metrics.cc Outdated
Comment thread docs/production-guide.md
Comment thread runtime/src/server/metrics.cc Outdated
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
@cursor

cursor Bot commented Aug 30, 2026

Copy link
Copy Markdown

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.

request_bytes, response_bytes, handler_threw, and client exist so an access log can share Observe's clock and answer "whose bucket did that 429 come from." Unit tests in middleware_test.cc pin the fields in isolation. Nothing a consumer would write does.

Consumer / example: no logger uses the new data

The out-of-tree Observe sink still ignores the observation:

       smithy::server::Observe(
           [&completed](const smithy::server::RequestObservation&) { ++completed; },
           [&started](const smithy::server::RequestStart&) { ++started; }),

metrics_acceptance_test.cc flexes the scrape; PerClientRateLimitKeysOnTheDerivedClientAddressNotTheSpoofableHeader flexes the limiter. Neither formats a log line, neither passes TrustedProxies into Observe, neither asserts observation.client.address is the key the limiter just used. Weather e2e still checks method/target/status only.

The production-guide chain is the same gap in the copy-paste path: PerClientRateLimit(..., trusted) then Observe with no fourth argument, so the sink gets TrustedProxies::None() and would log the peer while the limiter keyed on the forwarded client. The callback comment is still the old six fields (method/target/operation/status/duration/trace_parent); the body is still // latency o.duration. The later prose that lists the new fields is not a test and is not what a consumer copies.

The "Watch the trust boundary" paragraph still tells you to wrap a one-line middleware around DeriveClient because "Observe's sink sees only the finished observation, not the request." That was the hole this commit claims to close. o.client.source is now on the observation; the guide still teaches the pre-#202 workaround.

#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 TrustedProxies on limiter and Observe, sink that records bytes / handler_threw / client.address+source, and the 429 case where the two agree. exception_safety_acceptance_test already throws through Beast without Observehandler_threw through containment is unpinned at that boundary too.

Until that exists, the new members are in-tree coincidence plus documentation.

Comment thread docs/production-guide.md Outdated
Comment thread docs/production-guide.md
Comment thread runtime/tests/server/middleware_test.cc
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 aaylward left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

  • RouteStats unification — 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; EscapeHelp for caller-controlled strings; histogram ladder validation at registration (ADR-0009).
  • Test qualityTheCapCannotSplitACountFromItsLatency reproduces the exact counter/histogram drift scenario. EveryFamilyIsContiguous validates all families, not just the one instance. access_log_acceptance_test.cc tests the composition a reader would copy: generated router, real Beast socket, same TrustedProxies on limiter and Observe, 429 logging, byte counts, and handler_threw through transport containment.
  • Guide fixes — Snippet passes service_name, passes trusted to Observe, 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 (dropping trusted from Observe fails 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants