What
A pure function: RequestObservation in, one line of JSON out. No I/O, no sink, no configuration, no new dependency. The consumer decides where the string goes — LOG(INFO), std::clog, a file, a collector.
Blocked on #202. The formatter is worthless until the observation carries the fields worth logging; today it has six, and the two an access log most wants (the response size and the ADR-0012 derived client) are exactly the two it lacks.
Why this belongs in the runtime rather than in each consumer
Two reasons, and the second is the stronger one.
One vocabulary. The metrics contract that just landed says service_name, http_method, route because a fleet with one spelling can move between services without re-learning the words. A log line is the other half of that pivot: an operator who sees a spike on a route="AddTask" panel should be able to paste route into a log query and have it mean the same thing. If every service formats its own line, that alignment holds by luck.
Escaping is security-adjacent, and target is attacker-controlled. The request target reaches the log line verbatim. JSON string escaping is stricter than the Prometheus label escaping already in metrics.cc — backslash, quote and newline are not enough; every control character below 0x20 needs \uXXXX. A raw control byte or an unescaped quote in a crafted URI produces a line a strict parser rejects, or worse, one that terminates the record early and lets the rest of the URI masquerade as its own log entry. That is log injection, and it is the kind of thing that should be written once with a test that feeds it a control byte, not re-derived per consumer.
Why it costs no dependencies
:server depends on :core and :http and nothing else; nlohmann_json is pulled only by :json, which :server does not take. Taking it would push that dependency onto every consumer of :server.
Hand-rolling is the precedent set two commits ago: the whole Prometheus text exposition — EscapeLabel, FormatNumber, AppendSample — is about 40 lines and exists precisely so /metrics costs nothing. An access-log record is flatter than that: a fixed set of string, integer and duration fields, no nesting, no schema negotiation. The escaping function is the only part that needs care, and it needs care regardless of who writes it.
The parsed trace id is available too — smithy/http/trace_context.h lives in :http, which :server already depends on — so emitting the correlatable trace_id rather than the raw traceparent header adds nothing.
Decisions this needs, none of them obvious
- Field names. Match the metric labels (
http_method, route, service_name) so a dashboard-to-logs pivot is a copy-paste, or keep aura's current keys (X-Forwarded-For, res.body.bytes, duration_ms) so anything grepping the existing format keeps working? I lean to the former — the continuity argument is weaker than it looks, since moving to JSON breaks every existing grep anyway.
- Duration unit. The histogram is microseconds; aura's line is
duration_ms. Microseconds keeps the two comparable and lets a reader divide; milliseconds is friendlier and lossy. I lean to microseconds for the same reason the histogram uses them.
- Extra fields. A handler will want to attach its own — tenant, request id, a feature flag. A formatter taking only a
RequestObservation cannot. Does it take an extra label vector like MetricLabels, or return something appendable, or neither for now?
- Timestamp.
RequestObservation has none, and most log sinks prepend their own. Probably not the formatter's job, but it should be stated rather than left to discovery.
Not in scope
- The sink. No I/O in the runtime; that is the SDK-free posture in
docs/production-guide.md and this does not change it.
- A logging facility.
runtime/include/smithy/core/ has no logger and should not grow one.
- Sampling. A per-request line at full traffic is a volume decision that belongs wherever the sink does.
- An event store. Out of scope here entirely — this issue produces a string.
What
A pure function:
RequestObservationin, one line of JSON out. No I/O, no sink, no configuration, no new dependency. The consumer decides where the string goes —LOG(INFO),std::clog, a file, a collector.Blocked on #202. The formatter is worthless until the observation carries the fields worth logging; today it has six, and the two an access log most wants (the response size and the ADR-0012 derived client) are exactly the two it lacks.
Why this belongs in the runtime rather than in each consumer
Two reasons, and the second is the stronger one.
One vocabulary. The metrics contract that just landed says
service_name,http_method,routebecause a fleet with one spelling can move between services without re-learning the words. A log line is the other half of that pivot: an operator who sees a spike on aroute="AddTask"panel should be able to pasterouteinto a log query and have it mean the same thing. If every service formats its own line, that alignment holds by luck.Escaping is security-adjacent, and
targetis attacker-controlled. The request target reaches the log line verbatim. JSON string escaping is stricter than the Prometheus label escaping already inmetrics.cc— backslash, quote and newline are not enough; every control character below 0x20 needs\uXXXX. A raw control byte or an unescaped quote in a crafted URI produces a line a strict parser rejects, or worse, one that terminates the record early and lets the rest of the URI masquerade as its own log entry. That is log injection, and it is the kind of thing that should be written once with a test that feeds it a control byte, not re-derived per consumer.Why it costs no dependencies
:serverdepends on:coreand:httpand nothing else;nlohmann_jsonis pulled only by:json, which:serverdoes not take. Taking it would push that dependency onto every consumer of:server.Hand-rolling is the precedent set two commits ago: the whole Prometheus text exposition —
EscapeLabel,FormatNumber,AppendSample— is about 40 lines and exists precisely so/metricscosts nothing. An access-log record is flatter than that: a fixed set of string, integer and duration fields, no nesting, no schema negotiation. The escaping function is the only part that needs care, and it needs care regardless of who writes it.The parsed trace id is available too —
smithy/http/trace_context.hlives in:http, which:serveralready depends on — so emitting the correlatabletrace_idrather than the rawtraceparentheader adds nothing.Decisions this needs, none of them obvious
http_method,route,service_name) so a dashboard-to-logs pivot is a copy-paste, or keep aura's current keys (X-Forwarded-For,res.body.bytes,duration_ms) so anything grepping the existing format keeps working? I lean to the former — the continuity argument is weaker than it looks, since moving to JSON breaks every existing grep anyway.duration_ms. Microseconds keeps the two comparable and lets a reader divide; milliseconds is friendlier and lossy. I lean to microseconds for the same reason the histogram uses them.RequestObservationcannot. Does it take an extra label vector likeMetricLabels, or return something appendable, or neither for now?RequestObservationhas none, and most log sinks prepend their own. Probably not the formatter's job, but it should be stated rather than left to discovery.Not in scope
docs/production-guide.mdand this does not change it.runtime/include/smithy/core/has no logger and should not grow one.