Skip to content

feat(observability): correlate logs by MoQ session - #217

Merged
englishm merged 13 commits into
cloudflare:mainfrom
itzmanish:session-correlation-id
Aug 28, 2026
Merged

feat(observability): correlate logs by MoQ session#217
englishm merged 13 commits into
cloudflare:mainfrom
itzmanish:session-correlation-id

Conversation

@itzmanish

Copy link
Copy Markdown
Contributor

Summary

Adds end-to-end session correlation across MoQT transport and relay logs using a stable SessionId.

Changes

  • Derives SessionId from the peer-observed QUIC connection ID when available.
  • Threads the ID through sessions, publishers, subscribers, streams, request handling, and all control-message logs.
  • Adds relay root spans carrying session_id, interface, and scope presence.
  • Prevents upstream sessions from inheriting downstream session identities.
  • Adds public/internal connection classification through ConnectionTagger.
  • Preserves existing transport constructors with generated fallback IDs and adds explicit *_with_session_id variants.
  • Correlates client-visible error IDs with their originating session.
  • Escapes peer-controlled reason phrases to prevent log forging.
  • Avoids logging scopes, relay identities, or peer addresses in session spans.
  • Deduplicates dynamic root-span construction.
  • Gates executable-only tracing subscribers behind binary features.

Validation

  • cargo test -p moq-transport
  • cargo test -p moq-relay-ietf --all-features
  • cargo check --workspace --all-targets --all-features
  • Strict Clippy for both changed packages
  • Rustfmt and diff checks

itzmanish and others added 13 commits August 28, 2026 22:19
A MoQ session has no identity today, so no log record can be attributed to a
connection. Add the type that will carry one.

The payload is `Arc<str>` rather than `String` because the id is cloned into
`Session`, `Publisher`, `Subscriber`, both halves of the session run loop and
the relay's per-session context; cloning must be a refcount bump.

`generate()` renders 32 lowercase hex characters via `Uuid::simple()`, matching
the shape of a 16-byte QUIC connection ID. The hyphenated 36-character form is
never produced: one alphabet means a reader never has to strip separators before
comparing an id against a qlog or mlog filename.

Not yet wired into anything.
…Subscriber

Every session now owns a correlation id. `Session::new` clones it into the
`Publisher` and `Subscriber` it builds, so all three expose `session_id()` and
the id is reachable from any session-scoped log site.

The id is taken by value rather than `Option`, so a connection cannot silently
end up without one; the compiler then located all 21 call sites. Two of them
were discarding the value:

  - relay.rs, the --announce forward connection
  - remote.rs, the upstream relay-to-relay connection

both bound as `_quic_client_initial_cid`. That is why relay-to-relay sessions
are anonymous in the logs today. The client binaries already held the CID and
logged "use this to look up qlog/mlog on server" without ever passing it on.

Measured: cloning is a 4.3ns refcount bump against 23.9ns for the equivalent
String clone, and every clone site is per-request rather than per-object.

No log site emits the field yet.
Every log record under the moq_transport::control target now carries the
session id, so control-plane activity can be attributed to a connection.

The plan assumed all 35 sites sat inside `log_control_message` and that one
parameter would cover them. Only 22 do. The remaining 13 are spread across the
setup handshake, the receive loop and the request-completion helpers, so the id
has to be threaded through 11 associated functions: they take owned parameters
rather than `&self`, having been written to be spawned into `select!` arms, so
none of them could reach the field Phase 2 added.

The four setup-handshake sites needed nothing threaded — `connect_with_config`
and `accept_with_config` already take the id as a parameter.

This adds no new records; it widens existing ones. Measured earlier: a disabled
`debug!` costs the same with the field as without it, and nothing on this path
runs at media rate.

Pre-existing rustfmt drift in serve/subgroup.rs and serve/tracks.rs is left
alone; it is present on the base commit and unrelated.
Adds the session id to 27 log sites: 14 in subscriber.rs, 7 in publisher.rs,
and 6 across published.rs, subscribe.rs and subscribed.rs.

None of these needed new plumbing. Publisher and Subscriber carry the id from
Phase 2, and the remaining types reach it through a field they already hold:
Published owns a Publisher, Subscribe owns a Subscriber, and ObjectForwarder
owns a Publisher.

Four sites sit inside `async move` blocks that cannot capture `self`, so they
take a cloned id alongside the clones already being made there. One associated
function, recv_subgroup_objects, took the id as a parameter.

Seven sites in this crate still lack the id: they live on Reader, Writer,
SubgroupOutput, SubscribedNamespace and RequestId, none of which can reach a
session today. Those need new struct fields and are kept separate.
Completes session/ coverage: all 74 error/warn/info/debug sites in the crate's
session module now carry the id.

These seven sites lived on types with no route to a session, so each needed a
decision rather than a field access:

  - Reader and Writer gained a session_id field. Ten construction sites, all
    inside session/, all with the id already in scope.
  - SubgroupOutput takes its id from the Writer it wraps, so `stream()` needed
    no new argument. The test-only Buffer variant generates one.
  - serve_subgroup_objects needed nothing: it already receives the
    SubgroupOutput that now carries the id.
  - SubscribedNamespaceRecv::run already receives both a Writer and a Reader.
  - RequestId::handle_requests_blocked takes the id as a parameter. It has one
    caller, which made that cheaper than a field and nine constructor changes.

An earlier estimate put Reader/Writer at 38 construction sites. That was wrong;
the pattern used to count them also matched TrackWriter, MlogWriter and others.
The real number is ten.
…ite fields

Relay-side correlation is carried by a tracing span around the whole session
rather than a session_id argument on each of the 66 call sites.

SessionContext gains the id and a span() builder. Session::run wraps its body
in that span, so the transport, producer and consumer tasks all inherit it; the
upstream connection in remote.rs instruments its spawned task the same way.

Three reasons this beats editing every call site:

  - The fields are recorded once per session instead of being re-formatted per
    record. A measured field slot costs ~94ns on an enabled record, and this
    path is only reached when debug logging is on.
  - The 45 trace sites and any log line added later are covered for free.
  - Calls into remote.rs inherit the span of the *calling* session, which is
    the correct attribution: one upstream connection is shared by many
    downstream sessions, so an id on the connection itself would be a lie.

The three tokio::spawn sites that escape the span — the UpstreamNamespaces
runner, namespace pulls, and locals — are exactly the shared infrastructure
that must not claim a single session. The span boundary and the ownership rule
coincide.

Five sites in relay.rs run before or after the session and take the id
explicitly. Two of them log connection_path, which is credential-bearing; they
are marked FIXME for the redaction change and left otherwise alone.

Also adds the session-established line, carrying interface, scope and peer
socket address. Never the relay URL: its path is the credential. Two tests
cover this — one asserts the id reaches formatted output from a record that
names none of the fields itself, the other asserts the span exposes no url
field.
A browser cannot read the QUIC connection id, so the error id is the handle a
user quotes back. The uuid already reached the client — Display embeds it and
the wire reason is built from Display — but the line recording it lives in
serve/error.rs, which has no session in scope.

Rather than move that line, log the reason where the message goes out. The
REQUEST_ERROR and PUBLISH_DONE arms of log_control_message already carry
session_id, so adding `reason` puts the error id and the session id on one
record. The constructors now emit `error_id` as a field and the two join on the
uuid.

The id was being printed twice in those constructors, once as a field and again
interpolated into the message; the interpolation is gone.

Also closes a hole: SubscribedNamespace sends REQUEST_ERROR through its own
mpsc and writer, bypassing log_control_message entirely, so those errors
reached peers with no log record at all. Its Writer already carries the id.

Adds ServeError::error_id(), and a test pinning the id into the wire reason
phrase — if it ever stops being embedded there, a client still sees an error
but nobody can trace it to a session.

Not addressed: SessionError carries no uuid, and sends neither reason text nor
a code — code() has zero callers and no SESSION_CLOSE is ever constructed, so a
peer sees only an implicit QUIC close. That is a behaviour change rather than a
logging one.
Keep credential-bearing scope values out of session spans while retaining useful correlation metadata. Path-formatted scopes contribute only their first segment as relay_uid; non-path scopes remain hidden, and scope_present records whether a scope exists.

Preserve the public relay context and session construction APIs by adding run_with_context rather than storing new required fields. Accepted-session records run before that span, so attach session_id directly there.

Sanitize and quote peer-controlled reason phrases, emit their exact bytes as lowercase hex, and log messages received on dedicated SUBSCRIBE_NAMESPACE response streams.

Preserve the enabled-level root span so warning and error records retain session correlation under restrictive filters. Existing connection-path diagnostics remain unchanged.
@englishm
englishm force-pushed the session-correlation-id branch from 742beb6 to 069a5b4 Compare August 28, 2026 23:46
@englishm
englishm merged commit ded5cff into cloudflare:main Aug 28, 2026
2 checks passed
@itzmanish
itzmanish deleted the session-correlation-id branch August 31, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants