Skip to content

A structured access-log formatter: one JSON line per request, no sink, no dependencies #203

Description

@aaylward

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions