Design: trace context in every Go context and every log line (RIG-5186)
Sibling record of the T4b emission record
(../compass-server-runner-otel/design.md),
which built go/internal/otel. This record does not edit it. It adds the log
half that T4b left out.
Ledger-impact: none — internal logging, lint config, and one additive
internal-only proto field (SessionsResponse.traceparent, the internal
runner wire). No public wire or decision-ledger row changes.
Problem / Intent
Section titled “Problem / Intent”compass-server and compass-runner emit OTel spans, but no log line carries a
trace id, so a log line cannot be joined to its trace. Many log calls also drop
the caller’s ctx, so even a trace-aware handler could not see the span. Matt
asked, on the noctx PR (#2167): “Can we also require OTel
traceparent/tracing/spans through all of the contexts? and make sure those are
always part of the logs?” This record makes three things true: every request
ctx in the two binaries carries a span, every log call passes its ctx, and
the log handler writes trace_id and span_id whenever that ctx has a valid
span.
Approach
Section titled “Approach”Three legs. Each leg is needed. A handler with no span sees nothing. A span with a ctx-less log call is invisible. A ctx-carrying call with no handler writes no ids.
Leg 1 — a trace-aware slog.Handler in go/internal/otel
Section titled “Leg 1 — a trace-aware slog.Handler in go/internal/otel”otelx.TraceHandler wraps the existing text handler. In Handle it reads
trace.SpanContextFromContext(ctx). When the span context is valid, it
adds trace_id and span_id to the record. Otherwise it changes nothing.
WithAttrs and WithGroup re-wrap, so a logger derived with With still
stamps. compass-server and compass-runner install it at their one
slog.SetDefault call (go/cmd/compass-server/main.go,
go/cmd/compass-runner/main.go; today both are
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))).
T1 fixes the interface shown in its Plan entry. A review of that interface found no flaw that blocks it. It found three gaps, and T1 closes them:
- Missing cases in the tests. The base tests cover a sampled SDK span, a
derived
WithAttrslogger, a group, and a bare ctx. They do not cover the provider-off path. With no provider installed, the global tracer returns anonRecordingSpanthat wraps the parent’s span context (go.opentelemetry.io/otel/internal/globaltracer.newSpan:s := nonRecordingSpan{sc: trace.SpanContextFromContext(ctx), tracer: t}). With no parent, that context is invalid, so no ids are written. T1 pins this, because it is the default runtime state. The base tests also do not cover an unsampled span. The SDK makes ids before it samples (sdk/tracetracer.newSpancallsidGenerator.NewIDs), so an unsampled span still stamps. T1 pins that too, so adding a sampler later cannot silently strip the ids. - Reserved keys are not enforced.
trace_id/span_idare reserved, but nothing rejects a caller that passes them. No non-test call does today. T1 adds them to sloglintforbidden-keys. That gate can fail. WithGroupnests the ids. AfterWithGroup("request")the ids land atrequest.trace_id, and a query ontrace_idmisses them. This follows theslog.Handlercontract, and a T1 test pins it. No non-testWithGrouporslog.Groupcall exists, so the gap is latent. See Open Question 4.
Leg 2 — a span on every request ctx
Section titled “Leg 2 — a span on every request ctx”“All contexts” in this record means every ctx that does work for one request or one unit of work. Matt’s ask is about joining logs to traces. A ctx with no request behind it has no trace to join.
| ctx class | Span today | This record |
|---|---|---|
| Unary inbound Connect RPC, server (all services, all doors) | yes, except SecretsService on the socket and dev doors — buildDoors (go/server/serve.go) mounts it with only auth.AmbientIdentity(adminID), auth.AmbientStreamInterceptor(adminID) (dev door also auth.NewAdminGate). Every other mount lists otelIC first by hand. |
T2 routes every server mount through one helper, otelx.HandlerInterceptors, that always puts otelIC first |
Long-lived inbound streams: RunnerService Sessions and PublishEvents; SubscribeEvents, SubscribeAgentSession, SubscribeComms; runner AgentGateway Publish and Control (after T3) |
one otelconnect span for the stream’s whole life. WrapStreamingHandler in connectrpc.com/otelconnect v0.9.0 starts it and ends it only when the handler returns. |
keep that span for RPC metrics; strip it from the work ctx at handler entry; per-unit spans or restores where a unit carries a parent (Open Question 1). T2 (server), T3 (AgentGateway), T8 (runnerhub). |
Short server streams FetchAgentConfig, FetchSessionBlobs |
per call; ends with the fetch | no change |
| Inbound HTTP: GitHub webhook, Linear webhook, Linear session link | no — plain netMux.Handle in buildNetworkServer (go/server/network_door.go) |
T2 wraps with otelhttp |
| Inbound Connect RPC, runner AgentGateway | no — agentGatewayHandlerOptions (go/internal/runner/gateway/ack_bound_codec.go) sets only the read limit and codecs |
T3 |
Queued unit from an HTTP ingress: GitHub webhook → ingest.NotifyWebhookArm and ingest.BoardWebhookArm queues; Linear webhook → linearagent.Dispatcher queue |
no — Enqueue(_ context.Context, ev forge.ForgeEvent) drops the ctx; Dispatcher.Enqueue(ev *SessionEvent) error takes none; the work runs on the arm’s loop ctx |
T5 class (b′): the queue entry carries the enqueuer’s traceparent |
| Queued unit, shipped: fabric delivery, held deliver | yes — the fabric restores the publisher’s traceparent (go/internal/fabric/event_fabric.go); heldEntry carries traceparent: otelx.Traceparent(ctx) (go/internal/delivery/dispatch.go) |
no change; never re-rooted |
| Runner command from the Sessions stream | no — ServerLink.RunSessions passes the caller’s ctx; otelconnect WrapStreamingClient starts its span on an internal ctx, so dispatcher.handle/execute see no span |
T4 |
Lifetime work spawned by a runner command: stderr drains and reaper in ServerLink.StartAgent, agentHost.retireOnExit goroutines |
inherits the Start command’s ctx | T5 class (c): strip the span at the spawn |
Stored lifetime ctx: Gateway.baseCtx, set by gateway.Serve from the Provision command’s ctx; the shared PublishEvents client stream opens on it |
inherits the command ctx for the socket’s life | T5 class (c): strip in Serve |
| Outbound runner→server calls | yes — otelconnect client interceptor in runner.Dial |
no change |
| Background unit of work (delivery sweeps) | yes — delivery.sweep.* roots in go/internal/delivery/settle.go |
no change |
| Other background goroutines (40 sites, T5 census) | mixed | T5 classifies each one |
| Boot, shutdown, loop start/stop | no | no change — these lines carry no ids by design |
Rules for a ctx with no span:
- The handler writes the line without ids. It never mints a span to have something to log.
- A span starts only where a request or unit of work starts. A long-lived loop or stream never gives its work one span for its whole life. That span ends and exports only when the loop ends (never, on a crash), and every line in the loop would share one id.
- A stream handler keeps the
otelconnectstream span for metrics, but clears it from its work ctx at entry withotelx.WithoutSpan(T2). This is the idiom the fabric already uses:trace.ContextWithSpanContext(context.WithoutCancel(ctx), trace.SpanContext{})thenotelx.ContextWithTraceparent(go/internal/fabric/event_fabric.go). - A detached goroutine that continues a request takes the request ctx, or
context.WithoutCancel(ctx). Both keep the span, becauseWithoutCancelkeeps values. A goroutine that outlives the request (class (c)) clears the span. Nevercontext.Background()mid-tree (the go-thread-context rule). trace.WithNewRoot()is only for a unit with no carried parent. It is never used on a path that restores a carried traceparent: the fabric, held deliver, and the (b′) queues.
Runner command spans (T4). The Sessions stream is one long-lived stream, so
per-message headers cannot carry the server’s trace. T4b solved the same
problem for control ops by putting traceparent in the payload (DL-303). T4
adds the same field one level up, on the SessionsResponse envelope, so every
command can carry it, not only deliver_control. The server stamps it from
the ctx it holds when it builds the command. The runner starts its command
span as a child of it.
For deliver_control the op’s own traceparent wins (SteerControl field 3,
DeliverControl field 4, in agent.proto). The two values differ. The server
stamps the op before gatedDispatch starts delivery.dispatch, so the op
holds the publisher’s span id and the envelope holds the delivery.dispatch
span id. With the op as parent, the runner hop and the agent turn hang off the
same span. This is T4b’s §Hop spans step 4 (“parented off the op’s
traceparent”), so T4 implements that step and does not supersede it. The
envelope is the parent only for arms with no op traceparent, or an empty one.
That step is not in the code today: no file under go/internal/runner reads a
traceparent, and the DeliverControl arm of dispatcher.execute calls
d.host.Deliver with no span. The op itself is still relayed verbatim to the
agent.
Leg 3 — every log call passes its ctx, enforced by sloglint
Section titled “Leg 3 — every log call passes its ctx, enforced by sloglint”golangci-lint v2.14.0 pins sloglint v0.12.0. Its settings struct
(SloglintSettings in pkg/config/linters_settings.go) has
a Context field with tag mapstructure:"context", and sloglint’s contextOnly
accepts all and scope. With context: all, any Debug/Info/Warn/
Error call on slog or a *slog.Logger is a finding. scope flags a call
only when the enclosing function has a context.Context or
*http.Request parameter.
Measured on bd9a75d2, default build tags:
context: all: 140 findings in 35 files. 2 are ininternal/envelope/envelope_test.go, so there are 138 non-test sites in 34 files.context: scope: 76 findings. These are mechanical:slog.Info("m")becomesslog.InfoContext(ctx, "m"). The other 62 have no ctx in scope. Each one needs ctx threaded in as the first parameter of its function.
| Package | all |
scope |
|---|---|---|
internal/runner (incl. gateway) |
36 | 22 |
server |
25 | 20 |
internal/runnerhub |
19 | 9 |
internal/fabric |
9 | 0 |
cmd/compass-postgres |
8 | 7 |
cmd/compass-mint-runner-token |
6 | 5 |
internal/stack |
5 | 2 |
internal/runtime |
5 | 1 |
internal/ingest, internal/embedded, internal/appconfig, cmd/compass-server, cmd/compass-gen-cert |
3 each | 3, 2, 0, 0, 0 |
internal/guestd, internal/forge, cmd/compass-runner |
2 each | 2, 2, 1 |
internal/delivery, internal/comms, cmd/compass-stack, cmd/compass-app |
1 each | 0 |
Lint also runs a second pass with tags pgtest,podman,microvm,livegithub, keyring_integration (go/moon.yml), and these counts do not include it.
Each migration task re-counts under both passes.
context: all is repo-wide, so it also covers binaries that do not install
the handler. That is correct: the gate is about ctx threading, and a ctx costs
nothing there. See Open Question 5.
sloglint checks only the method name. Its contextOnly matches
case "Debug", "Info", "Warn", "Error": and never looks at the argument
(go-simpler.org/sloglint v0.12.0 function_checks.go). So
slog.InfoContext(context.Background(), …) passes it. A forbidigo pattern
closes that gap: forbidigo matches the context.Background and
context.TODO identifiers, outside _test.go files and cmd/*/main.go (T1).
A pattern on the call text Context(context.Background() would not work,
because forbidigo matches identifiers, not argument lists. At bd9a75d2 the
pattern hits two sites: pgshare.IsolatedSchemaDSN
(go/internal/pgshare/pgshare.go, a test helper in a non-test file) and the
shutdown drainTimeout ctx in go/server/serve.go.
No non-test file imports the stdlib log package. Third-party libraries’
own loggers are out of scope.
Cost and sampling
Section titled “Cost and sampling”Handleadds one ctx value lookup and, under a valid span, two hex encodings and aRecord.Clone. That is small next to the text formatting it already does.- New spans: per AgentGateway unary RPC and per AgentGateway stream (T3), per
webhook delivery (T2), per runner command (T4), per dequeued ingest unit
(T5). There are no per-frame spans on
PublishEventsorPublish: those carry agent frames at a high rate, and their lines carry no ids. otelconnect also adds per-message events on streams, so T3 setsotelconnect.WithoutTraceEvents()on the AgentGateway. SetupTracerProvidersets no sampler, so the SDK defaultParentBased(AlwaysSample)applies. No change.
Alternatives considered
Section titled “Alternatives considered”The library-adoption threshold used here: at least 1000 known importers on pkg.go.dev, and a release in the last year.
otelslogbridge (go.opentelemetry.io/contrib/bridges/otelslog) instead of a wrapper. It sends records over OTLP as log records with the trace ids set. Its latest release is v0.21.0 (2026-10-02), but pkg.go.dev lists only 271 known importers, so it is below the threshold. It also adds a logs pipeline, which Matt did not ask for. “Part of the logs” means the ids are in the lines we already write. Rejected. Log export is Open Question 3.- sloglint
context: scopeinstead ofall. It only flags the 76 calls that have a ctx in hand, and it never fires on the 62 that need ctx threaded in, so a new ctx-less helper would pass. It does not meet “require”. Rejected. - A root span for any ctx that has none, minted inside the handler. That gives every line an id, but the ids join to nothing: a one-line trace has no other spans. It also hides real gaps. Rejected.
- Hand-rolled HTTP span middleware instead of
otelhttp. About 15 lines ingo/internal/otel, no new dependency.otelhttphas 2,776 known importers on pkg.go.dev, and v0.71.0 was released 2026-08-26. It writes standard HTTP semconv attributes and metrics. v0.71.0 requiresgo.opentelemetry.io/otel v1.46.0, the version pinned ingo/go.mod, so its version moves with that pin. Chosen:otelhttp. Open Question 6.
Global Constraints
Section titled “Global Constraints”- All-Go, one convention. All span and log-trace code lives in
go/internal/otelor uses it. No second provider, exporter, or handler type. - Import names. In this record
otelx.is the internal packagegithub.com/RigelBuild/compass/go/internal/otel(Go package nameotel), imported asotelxwherever the upstream package is also in scope, asgo/internal/deliveryalready does.otel.is upstreamgo.opentelemetry.io/otel(for exampleotel.Tracer). - Off by default, per process. A process with an unset
OTEL_EXPORTER_OTLP_ENDPOINTinstalls no provider and sends nothing. Its lines carry ids only when its ctx holds a valid remote span context from an inboundtraceparent, for example a runner command whose envelope carries the server’s. Then a line carries the remote trace id and the remote parent span id, because the provider-off global tracer wraps the parent’s span context. With no inbound traceparent, a line carries no ids. - Never fail or block on trace machinery. A missing or malformed
traceparentgives a fresh root span. It never gives a command error. The handler never returns an error of its own. - ctx threading (go-thread-context). ctx is the first parameter. No
context.Background()/TODO()outside the top ofmainand_test.go; forbidigo enforces this from T1. Detached work usescontext.WithoutCancel(ctx). - Reserved log keys.
trace_idandspan_idare written only byotelx.TraceHandler. Lowercase hex, asTraceID.String()andSpanID.String()write them. - Span lifetime. One span per request or unit of work. A long-lived
stream or loop never lends its span to its work: stream handlers clear it
with
otelx.WithoutSpan, and class (c) spawns clear it at the spawn.trace.WithNewRoot()only where no parent is carried. - One interceptor helper. Every Connect handler mount uses
otelx.HandlerInterceptors. A rawconnect.WithInterceptorson a handler mount is a review reject. - Span attributes stay bounded. Command kind, op kind, and session id only, as T4b already uses. Never a message body or a secret. Metric labels stay op-kind only (T4b constraint).
- Proto changes are additive and internal-only.
runner.prototag 16, buf-breaking clean, regenerate withmoon run compass-proto:gen. - Lint posture (F1). No central exclusion for sloglint. A per-site
suppression is
//nolint:sloglint // <reason>(nolintlintrequire-explanation,require-specific). - No inert gate.
context: allturns on in the same PR as the last migration, never before and never behind a flag. - Record hygiene. Markdownlint-clean. Each task is one stacked PR in a linear stack.
Stack order: T1, T2, T3, T6, T5, T4, T7, T8, T9, in one linear stack.
- The handler goes live in T2, the same PR that clears the stream span from server stream handlers. No build has the handler stamping a stream span.
- T6 lands before T5 and T4. Rule: every log line that a T2–T5 test reads
already passes its ctx at the commit where the test lands. At
bd9a75d2the runner lines T5 pins (logDrainLine,flushDropped,logUnexpectedExitingo/internal/runner/agent_exec.go) take no ctx. T6 is safe this early: before T4, no runner ctx carries a span except the AgentGateway unary requests from T3. - T5 lands before T4. T4 puts a span on every command ctx, and T5’s class (c) strips must already keep that span out of lifetime work.
T1 — otelx.TraceHandler and the lint gates
Section titled “T1 — otelx.TraceHandler and the lint gates”Owner: compass-ux.
Add go/internal/otel/loghandler.go with the interface below, and its
tests. The tests cover seven cases:
- the four base cases: a sampled SDK span, a derived
WithAttrslogger, aWithGrouplogger (the ids sit inside the group), and a bare ctx; - provider-off with no parent: no ids;
- provider-off with a remote parent from
otelx.ContextWithTraceparent: the remote trace id and parent span id; - unsampled (
sdktrace.WithSampler(sdktrace.NeverSample())): both ids.
Add forbidden-keys: [trace_id, span_id] to the sloglint block in
go/.golangci.yml. sloglint v0.12.0 checks every log/slog.String key
against forbidden-keys, so the handler’s own slog.String("trace_id", …) and
slog.String("span_id", …) will be flagged. Each carries
//nolint:sloglint // TraceHandler is the one writer of the reserved trace keys.
Add the forbidigo pattern ^context\.(Background|TODO)$. Its message cites
go-thread-context. Add an exclusion rule scoped to forbidigo, that text, and
path (_test\.go|cmd/[^/]+/main\.go)$. Fix the two sites it hits:
pgshare.IsolatedSchemaDSN(go/internal/pgshare/pgshare.go). Usectx := t.Context()for the create path only.t.Context()is canceled before Cleanup functions run, so thet.Cleanupclosure must usecctx := context.WithoutCancel(ctx)for itspgx.ConnectandDROP SCHEMA. Today a failed cleanup connect returns silently (return // best-effort teardown), so without this every pgtest would leak its schema and nothing would fail.drainDoors(go/server/serve.go). It takes a ctx and derives its bound fromcontext.WithTimeout(context.WithoutCancel(ctx), drainTimeout). Its three callers change: the production caller ingo/server/serve.go,go/server/drain_test.go, andgo/server/serve_seed_pgtest_test.go. The production caller’s//nolint:contextcheck // drainDoors deliberately takes no ctx …is removed. nolintlint runs withallow-unused: false, so a stale directive would be a finding.
T1 does not change the two mains. T2 wires the handler.
Interfaces:
-
Consumes:
log/slog,go.opentelemetry.io/otel/trace. -
Produces (package
otel, import pathgithub.com/RigelBuild/compass/go/internal/otel, writtenotelx.here):// TraceHandler adds trace_id and span_id from the record's ctx to every// log line written inside a valid span.type TraceHandler struct{ inner slog.Handler }func NewTraceHandler(inner slog.Handler) *TraceHandler// Enabled defers to inner.func (h *TraceHandler) Enabled(ctx context.Context, level slog.Level) bool// Handle clones r and adds the two ids when ctx holds a valid span// context, then calls inner.func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error// WithAttrs and WithGroup re-wrap the derived inner handler.func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handlerfunc (h *TraceHandler) WithGroup(name string) slog.Handler -
Changes (package
server, unexported):func drainDoors(ctx context.Context, d drainSet) error
Test cycle:
TestTraceHandlerStampsActiveSpanfails before, becauseNewTraceHandlerdoes not exist. After, all seven cases pass.- An
IsolatedSchemaDSNpgtest runs a subtest that calls the helper. After the subtest ends and its cleanup has run, the parent queriesinformation_schema.schematafor that schema and asserts zero rows. If onlyt.Context()were used, the schema would remain and this would fail. - The lint gates have no fixture harness. They are proven by the gate itself,
in the PR. First commit: the
forbidden-keysand forbidigo config, with no nolint and no fixes. Its redcompass-go:lintoutput names the two handlerslog.Stringsites and the twocontext.Background()sites. The PR body quotes that output. The next commit adds the nolints and the fixes, and lint goes green.
Acceptance: the handler stamps exactly when ctx holds a valid span context.
The linter rejects a caller-written trace_id and a mid-tree
context.Background(). pgtest schemas are dropped.
T2 — Server: one interceptor helper, stream span strip, HTTP ingress, wiring
Section titled “T2 — Server: one interceptor helper, stream span strip, HTTP ingress, wiring”Owner: compass-ux.
-
Helper. Add
otelx.HandlerInterceptors. It always putsicfirst, thenNewTraceResponseInterceptor(), then the caller’s interceptors in order. Every server handler mount uses it:buildDoors(go/server/serve.go): socket and dev doors, all four services. This closes the SecretsService gap.buildNetworkServer(go/server/network_door.go).mountGatewayServices(go/server/network_door.go).runnerhub.NewMountedHandler(go/internal/runnerhub/handler.go).
The auth order stays as it is. For example the network door becomes
otelx.HandlerInterceptors(otelIC, otelx.NewSessionIDInterceptor(), auth.BearerInterceptor(st), auth.BearerStreamInterceptor(st), auth.NewAdminGate(adminID)). -
Stream strip. Add
otelx.WithoutSpan. The long-lived stream handlers callctx = otelx.WithoutSpan(ctx)on entry, so their work ctx carries no span. Theotelconnectstream span stays, so stream RPC metrics are kept. The handlers are:service.SubscribeEventsandservice.SubscribeAgentSession(go/server/service.go);Comms.SubscribeComms(go/internal/comms/subscribe.go);Handler.SessionsandHandler.PublishEvents(go/internal/runnerhub/handler.go).
A publish under the stripped ctx stamps an empty traceparent (
Bus.PublishCtxstampsotelx.Traceparent(ctx),go/events/events.go). That is the shipped “empty when no active span” rule. It is not a new path. -
HTTP ingress. Wrap the GitHub webhook, Linear webhook, and Linear session-link handlers in
tracedIngress. It callsotelhttp.NewHandlerwithWithPublicEndpointFnalways returning true. These endpoints face the internet, so an inboundtraceparentbecomes a link, never a parent. Addgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0togo/go.mod. Its version is tied to the otel v1.46.0 pin. -
Wiring. Set
slog.SetDefault(slog.New(otelx.NewTraceHandler(slog.NewTextHandler(os.Stderr, nil))))ingo/cmd/compass-server/main.goandgo/cmd/compass-runner/main.go.
Interfaces:
-
Consumes:
connectrpc.com/otelconnectv0.9.0,otelhttpv0.71.0. -
Produces (package
otel, writtenotelx.):// HandlerInterceptors is the one interceptor option for a Connect handler// mount: ic first (it must be non-nil), then NewTraceResponseInterceptor,// then rest in order.func HandlerInterceptors(ic *otelconnect.Interceptor, rest ...connect.Interceptor) connect.HandlerOption// WithoutSpan returns ctx with no active span. It keeps ctx's values and// cancellation.func WithoutSpan(ctx context.Context) context.Context -
Produces (package
server, unexported):// tracedIngress wraps an internet-facing HTTP handler in a server span that// links, never parents, any inbound traceparent.func tracedIngress(h http.Handler, operation string) http.Handler
Test cycle:
-
Default lane (
go test -race ./..., mooncompass-go:test):- A unit test of
HandlerInterceptorsasserts the order (span, then traceresponse, thenrest). - A
WithoutSpantest asserts the span context is invalid and a ctx value survives. - A
tracedIngresstest sends a request with an inboundtraceparent. It asserts one server span with no parent and one link.
- A unit test of
-
Proof over the doors (build tag
pgtest, CI stepgo test -tags pgtest -racein.github/workflows/ci.yml;buildDoorsneeds a store). The test extends thecors_pgtest_test.goharness. It sends one unary RPC per service per door, with an in-memory span recorder. An auth rejection is fine, because the span interceptor is outermost. It asserts oneSpanKindServerspan per call. The services are:- socket and dev doors: CompassService, CommsService, SecretsService, UsageService;
- network door: those four, plus RunnerService, GatewayCredentials, GatewayRegistry, GatewayAuth.
AgentGateway (runner, T3) and GuestControl (guest binary) are not server mounts. Before T2: the socket and dev SecretsService rows fail.
-
Stream strip proof. Each test reads the ctx at a seam that already receives the handler’s ctx. Each fails without the strip, because the seam then sees the stream span.
- Default lane,
go/internal/runnerhub. MountHandlerwithotelx.HandlerInterceptorsand a span recorder. Give the hub a fakeSessionBindingStorethat records the ctx of each call.PublishEvents: an enrolled runner sends one session frame for a session id the hub has not cached.Hub.DeliverreachesResolveSessionBinding(ctx, …)throughframeSessionandlookupSessionBinding. The recorded ctx has an invalid span context.Sessions: answer a deliver with NotFound, aslost_session_test.godoes. ThesetSessionUnknownhook callsdropLostSessionDetached(ctx, …), which reachesSessionBindingTenant. The recorded ctx has an invalid span context.- The recorder holds one stream span per RPC after close.
- pgtest lane. Open the store with
store.OpenTracedand apgx.QueryTracerthat records the ctx of each query, keyed by sqlc query name aspgtest.SQLCQueryCounterdoes. Queries from the auth interceptors run before handler entry and still carry the stream span, so the test reads only the handler body’s queries: the subscriber check inSubscribeAgentSessionandMessagesHeadSeqinSubscribeComms. SubscribeEventshas no seam. Its body passes ctx only toforward, which only selects onctx.Done(). Its strip follows the Leg 2 rule and no test pins it, because no test could fail.
- Default lane,
Acceptance:
- Every server handler mount goes through
HandlerInterceptors. - Every unary server RPC and every HTTP ingress records a server span.
- No stream handler’s work ctx carries the stream span.
- Both binaries write ids.
T3 — Runner: AgentGateway spans, with the stream span stripped
Section titled “T3 — Runner: AgentGateway spans, with the stream span stripped”Owner: compass-ux.
gateway.Serve builds an otelconnect interceptor with
otelconnect.WithoutTraceEvents(). It passes the interceptor to
agentGatewayHandlerOptions, which puts otelx.HandlerInterceptors(ic)
first. A construction error returns from Serve before the socket exists.
Gateway.Publish (go/internal/runner/gateway/publish.go) and
Gateway.Control (go/internal/runner/gateway/control.go) call
otelx.WithoutSpan(ctx) on entry.
Interfaces:
-
Consumes:
otelx.HandlerInterceptors,otelx.WithoutSpan. -
Produces (package
gateway, unexported):func agentGatewayHandlerOptions(ic *otelconnect.Interceptor) []connect.HandlerOption
Test cycle: a test calls Serve on a temp socket path with an in-memory
span recorder. Unary Comms gives one server span, and the outbound
RelayCommsCall client span is its child. A streaming Control RPC gives
one server span when it closes. Before T3: no spans.
The strip in Publish and Control has no seam at bd9a75d2. Control
passes ctx to controlProducer.serve, which only selects on ctx.Done().
Publish does not read ctx. acquirePublisher opens on Gateway.baseCtx
(T5), not on the handler ctx. The strip follows the Leg 2 rule, and no test
pins it, because no test could fail.
Acceptance:
- Every AgentGateway RPC records a span.
- No AgentGateway stream lends its span to its work.
T5 — Background work: classify every goroutine site
Section titled “T5 — Background work: classify every goroutine site”Owner: compass-ux. Lands after T6 and before T4.
The census has two predicates.
Goroutine sites: a non-test go statement, or a .Go( call
(errgroup.Group.Go, sync.WaitGroup.Go), under these roots:
go/server;go/internal/runner,runnerhub,fabric,delivery,comms,ingest,linearagent;go/cmd/compass-server,go/cmd/compass-runner.
Command, run from the repo root:
rg -n --type go -g '!*_test.go' '^\s*go [A-Za-z_(]|\.Go\(' go/server go/internal/runner go/internal/runnerhub go/internal/fabric go/internal/delivery go/internal/comms go/internal/ingest go/internal/linearagent go/cmd/compass-server go/cmd/compass-runnerAt bd9a75d2 it prints 40 sites:
| File | Sites |
|---|---|
server/sinks.go |
8 |
server/serve.go |
4 |
internal/runner/agent_exec.go |
4 |
internal/runner/host.go |
3 |
internal/runner/dispatch.go |
3 |
internal/fabric/event_fabric.go |
3 |
server/linear_responder.go |
2 |
internal/runnerhub/relay_comms.go |
2 |
server/git_credential.go, internal/runnerhub/router.go, internal/runnerhub/hub.go, internal/runner/run.go, internal/runner/gateway/socket.go, internal/runner/gateway/session_state.go, internal/fabric/runner_fabric.go, internal/fabric/routing_fabric.go, internal/fabric/fabric.go, internal/delivery/settle.go, cmd/compass-server/main.go |
1 each |
The PR body re-runs the command and gives every site a class:
- (a) continues a request. It takes the request ctx, or
context.WithoutCancelof it. Fix any site that does not. - (b) unit of work with no carried parent (a tick, a sweep, a wake):
start a root span per unit with
trace.WithNewRoot(), named<pkg>.<unit>, likedelivery.sweep.*. - (b′) queued unit that carries its enqueuer’s trace. The queue entry
stores
otelx.Traceparent(ctx)at enqueue, asheldEntrydoes ingo/internal/delivery/dispatch.go. At dequeue the worker buildsotelx.ContextWithTraceparent(otelx.WithoutSpan(loopCtx), tp)and starts a span from it. An emptytpgives a root. NeverWithNewRoothere.ingest.NotifyWebhookArm:Enqueueuses its ctx (today_). Each dequeued event’s span is a child of the webhook’sotelhttpspan.ingest.BoardWebhookArm:Enqueueuses its ctx.drainBatchandretryPendingcan merge several events into one pass, so each pass is a root with one link per carried traceparent.linearagent.Dispatcher:Enqueuegainsctxas its first parameter, and so does the session-sink interface ingo/server/linear_webhook.go. The dequeued event’s span is a child of the Linear webhook span.- The fabric (
go/internal/fabric/event_fabric.go) and held deliver (fireHeld,go/internal/delivery/settle.go) are shipped (b′) paths. They are listed here and not changed.
- (c) loop or lifetime work. No span. When it is spawned from a ctx that
holds a span, it calls
otelx.WithoutSpan(ctx)at the spawn. This covers:- the two pipe drains and the reaper started in
ServerLink.StartAgent(go/internal/runner/agent_exec.go).StartAgentbuildslifeCtx := otelx.WithoutSpan(ctx)afterengine.ExecStreaming, thendrainCtx, stopDrains := context.WithCancel(lifeCtx). The reaper useslifeCtxforshouldLogExitandlogUnexpectedExit; - the two
agentHost.retireOnExitgoroutines (go/internal/runner/host.go), which passotelx.WithoutSpan(ctx); - sender pumps, watchers, and signal handlers.
- the two pipe drains and the reaper started in
Stored lifetime ctxs: a non-test struct field that holds a
context.Context. containedctx is on (default: all), so each one carries
a nolint:containedctx. Command, run from the repo root:
rg -n --type go -g '!*_test.go' 'nolint:containedctx' goAt bd9a75d2 it prints four fields:
| Field | Set from | Class |
|---|---|---|
Gateway.baseCtx (go/internal/runner/gateway/gateway.go) |
gateway.Serve(ctx, …) builds socketCtx, socketCancel := context.WithCancel(ctx). Its callers in go/internal/runner/host.go pass the Provision command ctx. acquirePublisher opens the shared PublishEvents client stream on it. |
(c): Serve builds socketCtx from otelx.WithoutSpan(ctx). Without this, after T4 every shared stream is parented on one Provision span for the socket’s life. |
sessionPublisher.ctx (go/internal/runner/gateway/publisher.go) |
g.baseCtx (shared publisher), or the sendCtx in SocketListener.PublishSessionState |
First source: covered, because it is stripped in Serve. Second source: agentHost.markErrored has two kinds of caller. From retireOnExit, the ctx is already stripped. From reloadLocked (three calls), the ctx is the Reload command ctx: class (a). The publish is bounded by stateSendTimeout and closed in the same call, so its stream span is a short child of the command, not a lifetime leak. |
GuestStream.ctx (go/internal/runtime/microvm/exec.go) |
GuestExec.ExecStream(ctx, …), from StartAgent’s ctx |
(c): stored as otelx.WithoutSpan(ctx). No pin: the guest control client has no otelconnect interceptor and the file has no log call, so no test could fail. |
supervisor.serveCtx (go/internal/guestd/supervisor.go) |
guest binary | out of scope: the guest binary installs no provider |
Interfaces:
-
Consumes:
otelx.Traceparent,otelx.ContextWithTraceparent,otelx.WithoutSpan,otel.Tracer(<package instrumentation scope>). -
Produces:
// package linearagentfunc (d *Dispatcher) Enqueue(ctx context.Context, ev *SessionEvent) errorInside
ingestandlinearagent, each queue element becomes a struct that holds the event and atraceparent string. That type is unexported.
Test cycle:
- Per (b) site: one unit under a span recorder gives one root span. Before: zero spans.
- Per (b′) arm:
Enqueueunder a known span, then dequeue. The notify and Linear spans have that span as parent. The board pass links to it. Before: no span, or an unrelated root. - Regression pins for (c). Each reads a log line that T6 already gave a ctx.
Each runs under a known span and a
TraceHandlerover a buffer.StartAgentwith a fake engine whose stderr writes one line. After T6,logDrainLinepassesdrainCtx. The line has notrace_id. With the strip removed, it carries the known span’s id.retireOnExit, throughnewHostGatewayFixture(go/internal/runner/host_gateway_test.go) with a relay whosePublishEventsreturns an error. After T6,markErroredlogs “reporting agent exit to server” with its ctx. The line has notrace_id. With the strip removed, it carries the span’s id.Serve, in packagegateway.Deps.Eventsis a wrapperEventRelaythat records the ctx passed toPublishEventsand then delegates to a real client. CallServeunder the span, then open the shared publisher. The recorded ctx has an invalid span context. With the strip removed, it holds the span.
Acceptance:
- Every site in the census has a class.
- Webhook work joins its ingress trace.
- Lifetime work carries no request ids.
- The fabric and held-deliver traces are unchanged.
T4 — Runner: one span per Sessions command, parented by the server
Section titled “T4 — Runner: one span per Sessions command, parented by the server”Owner: compass-ux. Lands after T6 and T5.
Add string traceparent = 16; to SessionsResponse in
proto/compass/v1/runner.proto, with a comment in the style of the T4b
fields, and regenerate.
The server stamps it with otelx.Traceparent(ctx) at each place in
go/internal/runnerhub that builds a SessionsResponse:
Hub.relay, which coversProvision,Start,Stop,StartResume, and the other relay callers;Hub.DispatchControl, which today discards its ctx with_ = ctx;Hub.SignalConfigVersion(config_signal.go) andHub.SignalSecretsVersion(secrets_signal.go). Today neither takes a ctx. Both gain one:- the
configSignalerinterface (go/server/agent_config_service.go) and its callerservice.signalConfigVersion(ctx, version); - the
secretsSignalerinterface (go/server/secrets_service.go) and its callersecretsService.bumpSecretsVersion(ctx); - the
gitCredentialBroker.signalfield (go/server/git_credential.go) becomesfunc(context.Context) error. The wiringsignal: hub.SignalSecretsVersionis unchanged. Its two callers pass their ctx.credential(ctx, …)runs under the git-credential RPC span.refreshSnapshot(ctx, …)runs on the background pathstartGitCredentialRefresh→group.Go→run→refreshDue. T5 gives thatgroup.Gosite class (b), so each tick has its own root span and the signal envelope carries it. - every test fake of the two interfaces, which the compiler lists.
- the
In the runner, commandSpan starts span runner.sessions.command. It sets
compass.runner.command to the oneof arm name, a bounded set. Both
dispatcher.handle and the inline ConfigVersion path in runSessions use
it.
Parent precedence:
- For
deliver_control, the parent is the op’s own traceparent: field 3 ofSteerControl, field 4 ofDeliverControl. This is T4b §Hop spans step 4.compass.op.kindandcompass.session.idare also set. - Otherwise, or when the op’s traceparent is empty, the parent is the
envelope’s
traceparent. - When both are empty or malformed, the span is a fresh root.
Interfaces:
-
Consumes:
otelx.Traceparent,otelx.ContextWithTraceparent,otelx.WithoutSpan. -
Produces: proto field
SessionsResponse.traceparent(tag 16, internal) and generatedfunc (x *SessionsResponse) GetTraceparent() string. -
Produces (package
runnerhub, changed signatures):func (h *Hub) SignalConfigVersion(ctx context.Context, version string) errorfunc (h *Hub) SignalSecretsVersion(ctx context.Context) error -
Produces (package
server, unexported, changed):type configSignaler interface {SignalConfigVersion(ctx context.Context, version string) error}type secretsSignaler interface {SignalSecretsVersion(ctx context.Context) error}// gitCredentialBroker fieldsignal func(context.Context) error -
Produces (package
runner, unexported):// commandSpan starts the per-command span on otelx.WithoutSpan(ctx). The// parent is the op traceparent for deliver_control, else the envelope// traceparent; empty or malformed gives a fresh root.func commandSpan(ctx context.Context, cmd *compassv1internal.SessionsResponse) (context.Context, trace.Span)
Test cycle: a runner test drives runSessions with a fake sessionStream,
an in-memory span recorder, and a TraceHandler over a buffer. The fake
SessionHost returns an error from Start, so dispatcher.errorResult
logs “session command failed” with d.log.Log(ctx, …). That line passes its
ctx at bd9a75d2, so the test does not depend on T6.
- A
startcommand with envelope traceparentE: the recorder’s command span has parentE, and the “session command failed” line carriesE’s trace id and the command span’s id. - A
deliver_controlwith op traceparentOand envelopeE: the parent isO, neverE. - Provider-off case: with no SDK provider, the same line still carries
E’s trace id.
Before: no span and no ids. A runnerhub test asserts that Hub.relay,
Hub.DispatchControl, and the two signal methods stamp their ctx’s
traceparent on the envelope.
Acceptance:
- Each runner command runs in the server request’s trace.
- The deliver hop is parented on the op.
- An empty envelope gives a fresh root and never an error.
T6–T9 — Thread ctx into every log call; turn on context: all
Section titled “T6–T9 — Thread ctx into every log call; turn on context: all”Owner: compass-ux.
Four PRs, split by package so each has about 30–40 sites. T6 lands right after T3, before T5 and T4, so the runner lines their tests read already pass ctx. T7–T9 land after T4.
- Where a ctx is in scope, use the
scopefix (XContext(ctx, …)). - Otherwise add
ctx context.Contextas the first parameter of the enclosing function and pass it from every caller. - Never write
context.Background(); T1’s forbidigo rule rejects it. - Each PR re-runs sloglint in
allmode under both build-tag passes and lists any tagged-file sites it found.
The PRs:
-
T6:
internal/runner(36) andcmd/compass-runner(2). This includeslogDrainLine,flushDropped, andlogUnexpectedExit(go/internal/runner/agent_exec.go), which gain a leading ctx, and themarkErrorederror line ingo/internal/runner/host.go. -
T7:
server(25) andcmd/compass-server(3). -
T8:
internal/runnerhub(19),internal/fabric(9),internal/ingest(3),internal/delivery(1),internal/comms(1). Logs reached fromHandler.SessionsandHandler.PublishEventsuse the stripped ctx from T2. T8’s test pins two cases:- a
Hub.Deliverlog for aPublishEventsframe has notrace_id; - a
RelayCommsCalllog carries that RPC’s span.
- a
-
T9: the rest:
cmd/compass-postgres(8),cmd/compass-mint-runner-token(6),cmd/compass-gen-cert(3),cmd/compass-stack(1),cmd/compass-app(1);internal/stack(5),internal/runtime(5),internal/embedded(3),internal/appconfig(3),internal/guestd(2),internal/forge(2);- the 2 test sites in
internal/envelope/envelope_test.go.
T9 also sets
context: allin thesloglintblock ofgo/.golangci.yml.
Interfaces:
- Consumes:
otelx.WithoutSpan(T8). - Produces: changed signatures only, each with a new leading
ctx context.Context. Each PR body lists every changed signature.
Test cycle: before T9, golangci-lint run with context: all reports the
remaining sites. After T9, both lint passes are clean, and a new
slog.Info("x") anywhere in the module fails lint.
Acceptance: no Debug/Info/Warn/Error call without ctx remains, and
the linter rejects a new one.
Stack order: T1, T2, T3, T6, T5, T4, T7, T8, T9.
- T1 —
otelx.TraceHandler(seven cases), sloglintforbidden-keys, forbidigocontext.Background/TODO,pgsharecleanup ctx,drainDoors(ctx, …)(Owner: compass-ux) - T2 —
otelx.HandlerInterceptorson every server mount,otelx.WithoutSpanin server stream handlers,otelhttpon three HTTP ingresses, handler wiring in both mains (Owner: compass-ux) - T3 — runner AgentGateway spans; stream span stripped; a test that covers a streaming RPC (Owner: compass-ux)
- T6 — ctx into log calls:
internal/runner,cmd/compass-runner(Owner: compass-ux) - T5 — classify the 40 goroutine sites and the 4 stored lifetime
ctxs; (b′) traceparent on the webhook and Linear queues; (c) strips in
StartAgent,retireOnExit, andgateway.Serve, with regression pins (Owner: compass-ux) - T4 —
SessionsResponse.traceparent(tag 16), server stamping, ctx on the two signal methods, runner per-command span, op-first parent fordeliver_control(Owner: compass-ux) - T7 — ctx into log calls:
server,cmd/compass-server(Owner: compass-ux) - T8 — ctx into log calls:
runnerhub,fabric,ingest,delivery,comms(Owner: compass-ux) - T9 — ctx into the remaining log calls; turn on sloglint
context: all(Owner: compass-ux)
Open Questions
Section titled “Open Questions”-
What should a long-lived stream do with its span? Load-bearing: yes. This affects T2, T3, and T8, and changes how T4b’s mounts behave. Affected streams: RunnerService
Sessions/PublishEvents;SubscribeEvents,SubscribeAgentSession,SubscribeComms; AgentGatewayPublish/Control. For each,otelconnectholds one span for the life of the stream.- (a)
otelconnect.WithFilterskips those procedures, and the handlers start their own per-unit spans. Cost:WrapStreamingHandlerchecks the filter and returnsnext(ctx, conn)before it builds any stream state, so these RPCs also lose their duration and count metrics. - (b) Keep the stream span, and clear it from the work ctx at handler
entry with
otelx.WithoutSpan, the idiom ingo/internal/fabric/event_fabric.go. Add per-unit spans where a unit exists (T4 commands, T5 queues). Cost: one long span per stream in the backend. Work lines on the stream itself carry no ids. - (c) Accept stream-lifetime ids as a stated exception to the Span lifetime constraint. Cost: thousands of lines share one span id. That span exports only when the stream closes, and never after a crash.
- Recommendation: (b). It keeps the RPC metrics that (a) would lose, and (c) breaks the constraint. The design assumes (b).
- (a)
-
Should runner commands join the server’s trace through the envelope field, or start a fresh root per command? Load-bearing: yes. This sets T4’s scope and needs the proto change.
- (a) Add
SessionsResponse.traceparent. Runner command logs share the server request’s trace id. Fordeliver_controlthe op wins, as T4b planned. Cost: one internal proto field, stamped at four builders. - (b) Fresh root per command, with no proto change. Every runner line still has ids, but they join only runner spans.
- Recommendation: (a). Matt asked for traceparent through all contexts. (b) meets the letter but breaks the join across the hop. The design assumes (a).
- (a) Add
-
Should logs be exported over OTLP? Load-bearing: no. Recommendation: not in this record.
otelsloghas 271 known importers, below the 1000-importer threshold. The ids in the stderr lines already let a reader join a log line to its trace. If export is wanted later, a collectorfilelogreceiver can parsetrace_idfrom these lines. -
WithGroupputs the ids inside the group. Load-bearing: no.- (a) Keep this. It follows the
slog.Handlercontract, and no caller groups today. - (b) Track groups in
TraceHandlerand write the ids at the top level. The handler would have to rebuild grouped attrs, which is much more code. - Recommendation: (a). Revisit when the first non-test
WithGrouplands.
- (a) Keep this. It follows the
-
context: allcovers binaries outside server and runner (postgres, gen-cert, mint-runner-token, stack, app). Load-bearing: no.- (a) Migrate them too: T9, about 25 sites. The gate stays module-wide, with no exclusions.
- (b) Add a path-class exclusion for those
cmd/dirs. This is smaller, but it is a hole in a gate that is meant to be total. - Recommendation: (a).
-
Should the ingress span use
otelhttp, or a hand-rolled one? Load-bearing: no. Recommendation:otelhttpv0.71.0.- It has 2,776 known importers and was released 2026-08-26.
- Its version is tied to the otel v1.46.0 pin.
- The new dependency changes the container images’ FOD hash, as the T4b deps did.
-
What counts as “require”? Load-bearing: no. The gates are:
- sloglint
context: all, which fails on any log call without ctx; - sloglint
forbidden-keys, which fails on a caller-writtentrace_id; - forbidigo on
context.Background/TODOoutside mains and tests; - the T2 pgtest door test, which fails when a server mount has no span.
sloglint checks only the method name, so
InfoContext(context.Background(), …)passes it. The forbidigo rule closes that gap. No gate proves that every goroutine runs under a span: class (c) work has none on purpose, so such a gate would be wrong or could never fail. The ban on a rawconnect.WithInterceptorsat a handler mount is enforced by review only. forbidigo cannot tell a handler option from a client option. Recommendation: accept these gates. - sloglint