Design: compass-server + compass-runner OTel emission and end-to-end trace continuity (T4b, RIG-2685)
Sibling record of the frozen observability-architecture design
(../compass-observability-architecture/design.md,
§T4b at design.md:635-660), which permits T4b “in a sibling doc under this
design if it grows large” (design.md:641-643). Matt ratified the scope
2026-08-26: “otel thru all” + “full end to end tracing thru the system”
(design.md:641-646).
Ledger-impact: none — this is a docs/designs/platform/ record; the
design-ledger gate governs the GOVERNED_ROOTS buckets only
(tools/design-ledger-gate/index.ts:52-60: ui, agent, server, meta, infra, repo, product), and platform is not among them.
Problem / Intent
Section titled “Problem / Intent”Only the agent emits OTel today: go/ has zero OTel code and go/go.mod has
zero go.opentelemetry.io dependencies (grep-verified this session), so the T4
bundled fan-in collector (PR #672) has a single producer and a user turn is
observable only inside the agent process. T4b gives both Go binaries
(compass-server, compass-runner) OTel SDK trace AND metric emission to that
collector, and propagates W3C traceparent context across the
server → runner → agent process boundaries against the frozen #649 contract —
so one user turn is ONE connected trace that TERMINATES at the turn boundary
(replies branch into linked new traces, never one unbounded trace).
Approach
Section titled “Approach”Two legs, one record. Emission establishes the (currently nonexistent) Go
OTel convention as a single new bootstrap package; propagation stamps the
server’s active span context onto the steer/deliver control ops as a W3C
traceparent string, relayed verbatim by the runner and continued by the
agent (compass-agent #649, frozen at f468431e).
Emission: one bootstrap package, go/internal/otel
Section titled “Emission: one bootstrap package, go/internal/otel”A new go/internal/otel package — a flat one-concern-per-dir sibling of
auth/comms/delivery/appconfig, cleared with compass-server (no
file-zone collision). It is the ONLY place a TracerProvider is constructed;
both binaries wire it identically, so the repo keeps exactly one
tracer/exporter convention.
- Exporter: OTLP/http-protobuf
(
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp) — matches the T4 bundled collector and the agent’s posture (docs/designs/repo/compass-agent-effect-otel/otel-endpoint-deployer-contract.md:34-38: endpoint set ⇒ “OTLP-over-HTTP/protobuf export to that collector”). - Off by default, endpoint-gated: when
OTEL_EXPORTER_OTLP_ENDPOINTis unset,SetupTracerProviderinstalls NO provider and returns a no-op shutdown — zero overhead, no egress, mirroring the agent’sotel-layer.ts(otel-endpoint-deployer-contract.md:36-38). This is also the codebase config idiom (empty flag/env = feature off). - Config style: flag + env fallback via the existing helpers —
firstNonEmpty(server,go/cmd/compass-server/main.go:451) /orEnv(runner,go/cmd/compass-runner/main.go:211-216) — NOT the client-app TOML (appconfigis the client app only). The endpoint knob is the standardOTEL_EXPORTER_OTLP_ENDPOINTenv var (optionally mirrored by an--otel-endpointflag with the env as fallback, matching every other knob). - Resource attributes:
service.name=compass-server/compass-runner(binary names, distinct from the agent’scompass-agent);service.versionfrom each main’s ldflagsvar version(compass-server/main.go:28,compass-runner/main.go:31), passed through the config struct because the otel package cannot importmain.compass.session.idstays the cross-surface join key, carried as a SPAN attribute on the message-path spans (server/runner spans are not per-session processes, so it cannot be a process resource attribute here).deployment.environmentis OMITTED — there is no environment concept in the codebase today and this record does not introduce one. - Wire point: in each
run(), immediately afterslog.SetDefault(...)(servermain.go:75, runnermain.go:88) and BEFOREserver.Serve(main.go:94) /runner.Run(main.go:161), passing thesignal.NotifyContextctx eachrun()already creates (servermain.go:85, runnermain.go:158) — the process-rootcontext.Background()inside those tworun()s ismain’s exemption under the go-thread-context rule; the otel package itself NEVER re-roots. The shutdown func is deferred right there, before the drain, and flushes under a bounded timeout derived from the caller’s ctx (mirroring the agent’s 2s shutdown bound). - RPC spans via otelconnect — on the handlers that carry a delivery
origin.
connectrpc.com/otelconnect’s interceptor produces the server RPC span, but it must be mounted on the CommsService handler, which servesPostMessage/RespondToAsk— the origin RPCs. That handler is built TODAY with NO interceptor options (go/server/serve.go:458NewCommsServiceHandler(commsSvc)), and the same handler value is reused on both the shipped socket door (serve.go:634) and the dev door (serve.go:671); on the network door it isnetCommsHandler(network_door.go:269), built off the shared bearer/admin chain (network_door.go:263-267, applied to bothnetHandlerandnetCommsHandler). So T2 adds otelconnect (+ the traceresponse interceptor, below) to the CommsService construction atserve.go:458for the socket/dev doors and prepends it to thenetwork_door.go:263-267chain for the network door. It ALSO goes on the CompassService chains (serve.go:626socket,:662dev; the network CompassService rides the same:263-267chain) so the RPCs the UI calls (SubscribeEvents, etc.) carry the traceresponse header. The Secrets chains (serve.go:631/:668) carry no delivery origin and need no origin span. The runner’s outbound connect client gets the same interceptor for the enroll/Sessions dials. Interceptor ORDER: otelconnect goes FIRST (outermost) in each chain so the security-critical AdminGate→Ambient ordering (serve.go:642-658) is unchanged relative to itself. - Metrics (Matt ruled OQ1 = traces + metrics). The same otelconnect
interceptor emits RPC duration/count metrics once a
MeterProviderexists, so the metrics leg is the same wiring plus a second provider — no re-instrumentation.go/internal/otelgainsSetupMeterProvider(OTLP/http metrics exporter, same endpoint gate as the tracer). Beyond the free RPC metrics, T5 adds ONE hand-instrumented delivery counter keyed by op-kind (steer/deliver) only. Cardinality is bounded by hard rule: op-kind labels ONLY — NEVER per-session or per-channel labels (see §Global Constraints). - Build tags: both mains are
//go:build unix;go/internal/otelbuilds cross-platform (the OTel SDK pulls nothing unix-only), so it carries no tag.
Dependency / image impact (stated per the brief): this adds
go.opentelemetry.io/otel, .../sdk, .../sdk/metric,
.../exporters/otlp/otlptrace/otlptracehttp,
.../exporters/otlp/otlpmetric/otlpmetrichttp,
and connectrpc.com/otelconnect to go/go.mod (today: zero OTel deps), at an
OTel Go SDK floor ≥ v1.23.0 (the version that adds Span.AddLink, required
by the T5 move-6 cross-turn Link). New Go
module deps change the server/runner container images’ fixed-output-derivation
(FOD) hash — the same class of impact the agent loop-otel record flagged for
the agent image. compass-native (the stack/image lane) rebuilds; coordinate
at impl PR time.
Propagation: server-origin stamping, runner relay, agent continuation
Section titled “Propagation: server-origin stamping, runner relay, agent continuation”The #649 contract is FROZEN (merged f468431e): the server is the trace ORIGIN
(OQ1=(b)); it serializes its CURRENT active span context into an additive
proto3 string traceparent on the INTERNAL control ops at control-wrap time;
the runner RELAYS the string; the agent CONTINUES off it. Never mint a fresh
root ON THE DELIVERY PATH — a delivered message never resets its trace (a NEW
post, by contrast, IS a new turn and a fresh linked root; see §Trace lifetime
and termination). W3C grammar exactly
(00-<32hex traceid>-<16hex spanid>-<2hex flags>); EMPTY string when no
active span — trace machinery never blocks or fails a delivery (mirroring the
from_handle store-miss posture, go/internal/delivery/consumer.go:399-402).
Wire seam — four additive proto fields (verified field numbers, current source). Matt ruled at the freeze gate (2026-08-27) that the trace crosses onto the public observation surface AND that a proto change to carry cross-turn causality is in scope:
SteerControl(proto/compass/v1/agent.proto:196-206):message = 1,from_handle = 2→traceparent= field 3 (internal control op).DeliverControl(agent.proto:219-236):message = 1,topic_name = 2,from_handle = 3→traceparent= field 4 (internal control op).SessionInjection(proto/compass/v1/compass.proto:524-533; next-free afterop_kind = 1,message_id = 2,from_handle = 3) →traceparent= field 4 (PUBLIC observation surface; Matt ruled OQ2 = yes). A plainstringadd does NOT touch the gen-fenced enum (SEA-1267,compass.proto:535-539fencesSessionInjectionKind, not scalar fields), so it is a clean additive public-API field. The agent EMITS it on the injection observation from thetraceparentit decoded (#649 T3), giving public session-stream consumers a direct observation→trace join.CommsCallRequest(proto/compass/v1/agent_gateway.proto:106-115;call_id = 1+ thecalloneof2-6on current main) →trigger_traceparent= field 10 (INTERNAL agent-gateway leg). NOT field 7: the in-flight #628 (RIG-2673, held for the RIG-2751 handle-first reshape) already extends this SAME oneof withcreate_channel = 7,update_members = 8,create_channel_group = 9, and proto field numbers are message-wide (oneof members and scalar fields share one number space), so 7-9 are claimed. Field 10 lands clean regardless of #628/RIG-2751 merge order. compass-server — theagent_gateway.protofile-zone authority (they own #628, RIG-2751, and this T4 edit) — RATIFIED field 10 (independently source-verified; they sequence #628’s conflict-clear and this scalar together in their zone), so it is authoritative, not a placeholder. The delivered message’s traceparent the agent re-attaches on its outbound post, so the server can LINK the reply’s new trace to the message that triggered it — the cross-turn causal edge that keeps traces terminating (see §Trace lifetime and termination).
All four are buf-breaking-safe pure adds; moon run compass-proto:gen
regenerates both stacks.
The bus gap — the one genuinely new seam. The frozen contract requires an
ACTIVE span at control-wrap time, but the wrap site is the delivery consumer
(deliverOp/steerOp, consumer.go:374-395), whose goroutine is rooted on
the serve ctx (consumer.go:255 Run(ctx)), not on the PostMessage request
ctx: PostMessage publishes onto the in-process events bus
(comms.go:355-357 → publishMessagePosted, mapping.go:463-468), and
Bus.Publish (go/events/events.go:165-176) takes no ctx — the trace
context dies at the bus boundary. Without bridging it, the “origin” span and
the dispatch spans are two disconnected traces.
Bridge it in-process, in the vocabulary already there: thread the caller’s
ctx to the bus and stamp the serialized traceparent onto the existing
Stamped[P] envelope (events.go:171-176 already stamps Seq/AtUnixMS/
InstanceEpoch; this adds one string field). Rather than a full signature
cutover of Publish — which would ripple ctx into deliberately ctx-free
structural sinks (LifecycleSink.PublishSessionStatus, the presence
publisher, board.Projection, publishReady), see §Alternatives — add a
ctx-carrying PublishCtx(ctx, payload) used ONLY by the message-origin
publisher (publishMessagePosted, mapping.go:463-468). The consumer extracts
the traceparent back into its dispatch ctx in handleEvent
(consumer.go:360-366) before the wrap; a publisher with no active span
stamps the empty string, read downstream as “no active span” — the contract’s
empty-string posture. This is not a new abstraction: it extends the bus’s
existing stamped-envelope pattern.
Parenting is by SpanContext (trace-id/span-id/flags), NOT by a live span object — so a dispatch span is a valid child of the origin’s REMOTE span context even though the PostMessage handler span has already ENDED by the time the async consumer dispatches (the standard async/queue propagation pattern). Two consequences to expect: the origin span’s duration does not envelope its async children (some UIs render the parent “shorter than” its subtree — cosmetic), and the sampled flag rides the traceparent, so a downstream ParentBased sampler honors the origin’s decision (a not-sampled origin correctly suppresses the whole dispatch subtree).
Stamp and relay sites (all verified current source):
- Origin (human post):
CommsService.PostMessage(comms.go:334-359) — the otelconnect span on the CommsService handler (added by T2 atserve.go:458/network_door.go:263-269, above) IS the origin span; the handler addsmessage.idas a span attribute after the append. No hand-minted span.RespondToAsk(comms.go:368-397) rides the SAME handler, so its answer-message post gets an origin span from the same interceptor. (Agent-authored posts do NOT ride the CommsService RPC — they arrive via the unaryRelayCommsCalland callPostMessagein-process; their origin is Open Question 5.) - Control-wrap (stamp):
deliverOp/steerOp(consumer.go:374-395) gain atraceparent stringparameter, populated from the consumer’s (bus-extracted) ctx — the same site wherefrom_handleis resolved today (authorHandle,consumer.go:403-415). - Classification-hop span: the consumer’s
gatedDispatch(go/internal/delivery/dispatch.go:343-354) wrapsDispatchControl(ctx, sessionID, op)(:350) in a span with attributescompass.message.id,compass.op.kind(steer/deliver), andcompass.session.id— so a trace filters to one message. - Server→runner hop:
runnerhub.Hub.DispatchControl(go/internal/runnerhub/dispatch_control.go:38-54) pushes send-only down the long-lived Sessions stream, where per-message header propagation is impossible — the traceparent RIDES the op itself, which the runner relays verbatim (go/internal/runner/dispatch.go:457-472handsc.DeliverControl.GetOp()tohost.Deliverat:469unmodified;gateway.Sendclones, never rewrites, the op —go/internal/runner/gateway/control.go:100-104). No runner-side stamping code is needed for correctness; the runner leg is spans only. - Runner→agent hop span: the runner’s DeliverControl dispatch arm
(
runner/dispatch.go:457-472) starts a span parented off the op’straceparent(extracted, not ctx-inherited — the Sessions stream ctx is not the message’s trace), same attributes as the server hop. ThecontrolProducer.Sendpath (gateway/control.go:230-232) is queue-entry, not I/O, andSend(sessionID, op)carries no ctx; the dispatch-arm span (which has the caller’s ctx per go-thread-context) is the runner hop of record, ending whenhost.Deliverreturns “durably queued”. - Sweep dispatches (
go/internal/delivery/settle.go:193,:244, and the cursor sweep’sDispatchControlin the same file) have no originating request: each sweep pass roots its OWN span (a sweep-rooted trace), and the re-wrapped ops carry that sweep span’s traceparent — never a stale one from the original post. Empty when the consumer has no provider installed. - Held agent-authored deliver (
dispatch.go:81 hold→settle.go:257 fireHeld): an agent post whose author session is live is HELD until that session settles, then fired on the consumer LOOP ctx (which carries no span). The held registry therefore carries the ORIGIN traceparent beside the message id (T5 move 5) and restamps it at fire — otherwise the agent-reply-fans-out leg, the most interesting half of a turn, silently stamps empty. Its non-emptiness depends on Open Question 5 (agent origin). - Agent continuation: compass-agent’s T3 (RIG-2871) decodes the field; their parser rejects all-zero ids and no-ops on malformed/empty. Their work is stacked on the proto field task below.
Continuity boundary (stated explicitly). “One user turn is ONE connected trace” is a LIVE-dispatch guarantee — the live deliver plus the held agent-reply. Sweep, offline-recipient, pin, owed-mention, and overrun-window deliveries are fresh roots BY DESIGN: they re-read from the store, which carries no traceparent, and inventing durable trace storage to reconnect them to a possibly-dead prior-process trace is scope explosion for negative value. The held path is the one deferred delivery that stays connected — its traceparent is in-memory, not re-read from the store.
Trace lifetime and termination
Section titled “Trace lifetime and termination”The ratified goal is “one turn, one trace” — and the corollary Matt named at the freeze gate: a trace must TERMINATE. Agents relay to each other, so naively continuing the same trace onto every downstream reply (Alternative (c)) yields a single trace that never ends — agents ping-ponging for days accrue into one unbounded span tree, un-exportable and un-queryable. Termination here is structural, not a timeout:
- A trace spans exactly one turn. Origin (a human
PostMessageRPC span, or an agentRelayCommsCallRPC span under (a′)) → dispatch → runner/agent hop → the recipient’s turn, which the agent-side continuation (#649 T3) joins. The trace ends when that turn settles. There is no cross-turn PARENT edge. - A reply is a NEW trace, LINKED — never a child. When the recipient agent posts during its turn, that post is a fresh origin (a′ = fresh server-minted root), starting trace N+1. Trace N+1 carries an OTel span Link to the message that triggered it, expressing “caused by” WITHOUT lifetime nesting. Links do not compound: trace N+1 is O(one turn) however deep the causal chain.
- The causal chain is data, not a span tree. “Around and around for days” is a long chain of linked one-turn traces — each terminating, each independently sampled and exported, navigable as a causal graph (query: “what did this message transitively trigger?”) but never one eternal trace. No artificial hop cap is needed for correctness, because nothing accumulates in a single trace; a cap would be a query-side product choice, not a plumbing requirement.
- Consistent with frozen #649. The fresh root for an agent post is minted
SERVER-side at its
RelayCommsCallexecution — “the server is origin” reads naturally as “the server-side execution of the post is the origin.” #649’s “never mint a fresh root at PostMessage or the agent” governs the DELIVERY path (a delivered message never resets its trace); a NEW post is a new turn, which is exactly where a new trace begins.
The link seam. The trigger’s traceparent reaches the origin via
CommsCallRequest.trigger_traceparent (field 10, above): the agent re-attaches
the traceparent it decoded (#649 T3) onto its outbound post, which rides
RelayCommsCall to the server. The server’s RelayCommsCall origin span (a′)
adds a span Link from trigger_traceparent — a LINK, never a parent. The
Link is attached to the ALREADY-STARTED otelconnect span via Span.AddLink
(OTel Go SDK floor ≥ v1.23.0, which the fresh go.mod deps pull) — NOT at
span creation, because otelconnect owns the span factory and exposes no
link-at-creation hook. Empty trigger_traceparent (a human-seeded first turn,
or no active trigger) adds no link, per the never-block posture.
Fresh-root invariant (load-bearing for termination). The RelayCommsCall
origin span MUST be a fresh root with respect to its trigger. This holds because
causality crosses ONLY via the trigger_traceparent proto field + the explicit
Span.AddLink — NEVER via transport propagation. The corollary constraint: the
runner’s outbound RelayCommsCall dial must not run under an active span whose
context W3C-propagates as a traceparent REQUEST header, or otelconnect on the
server would PARENT the origin span on it and silently extend the trigger’s
trace instead of linking. The runner satisfies this today (it relays the op
verbatim and does not execute under the delivered message’s OTel ctx); T3’s
outbound client interceptor covers the enroll/Sessions dials, and the
RelayCommsCall dial must not inject a delivered-trace header (stated so an
implementer preserves it).
Agent-side attachment is compass-agent’s lane (a task beside their #649 T3 decode); the field and the server-side link are this record’s.
Response-boundary trace id (cross-plane, serves #656 T6/RIG-2874). The UI
has no client-side OTel tracer (browser RUM is a #656 named follow-up), so
the PostHog↔OTel correlation seam needs the server to be the trace-id source
at the compass.v1 RESPONSE boundary. A small unary interceptor in
go/internal/otel (appended after otelconnect on the same handlers,
serve.go:458/:626/:662 and the network_door.go:263-269 chain) sets a
traceresponse response header
(W3C trace-context response draft grammar, same 00-… shape) carrying the
handler span’s trace id. The UI reads the header off any RPC response and
hands the trace id to PostHog as the correlation key — the id is
server-minted, never client-minted. This is a produced surface of T2 below.
Alternatives considered
Section titled “Alternatives considered”- Bus bridge via span links instead of ctx threading (INTRA-turn) — the consumer starts an unparented span and LINKS to the origin by re-deriving it from the message id. Rejected FOR THE IN-PROCESS BUS HOP: links do not satisfy “one connected trace” WITHIN a turn (the ratified goal), and there is no stored span context to link to anyway. (Links ARE the right tool for the CROSS-turn causal edge, where a parent edge would never terminate — see §Trace lifetime and termination. Intra-turn: ctx threading; cross-turn: link.)
- Traceparent on the public
MessagePostedwire event — rejected: the bus payload is the publicSubscribeCommsResponseproto; trace plumbing on a public client-facing wire message to solve an in-process hop is scope leakage. TheStampedenvelope is in-process and already exists for exactly this kind of stamping. - Hand-written RPC middleware instead of otelconnect — rejected: reuse before inventing; otelconnect is the connect-go ecosystem’s maintained instrumentation and covers client+server, unary+stream.
- Full
Publish(ctx, payload)signature cutover across all callers — rejected in favor of the narrowerPublishCtx. A full cutover ripples a ctx parameter into structural sink interfaces (LifecycleSink.PublishSessionStatuset al.,hub.go:44-51) whose contracts deliberately keep them ctx-free (“must NOT store the caller’s ctx”), to carry a trace those lifecycle / presence / board events never participate in — polluting multiple interfaces for one concern, or forcingcontext.Background()at those sites (go-thread-context-forbidden outside main/tests).PublishCtxconfines ctx to the one message-origin publisher that actually originates a delivery trace; the two-method bus surface is the standard incremental-ctx idiom (cf.http.NewRequestWithContext), not a competing convention.
Global Constraints
Section titled “Global Constraints”- All-Go. This is Go server/runner code; no new runtime, no sidecars.
- Off by default, endpoint-gated. Unset
OTEL_EXPORTER_OTLP_ENDPOINT⇒ no provider installed, no-op API, zero egress (both binaries). - Never fail or block a delivery on trace machinery. Empty
traceparentwhen no active span; malformed input no-ops agent-side; a span error is never a dispatch error. - Frozen #649 wire contract. Field numbers exactly as verified above; W3C
grammar exactly
00-<32hex>-<16hex>-<2hex>; server is origin, runner relays, agent continues; never mint a fresh root ON THE DELIVERY PATH (a delivered message never resets its trace). A NEW post IS a new turn: its server-sideRelayCommsCall/PostMessageorigin is a fresh root, linked to its trigger — never parented on it (§Trace lifetime and termination). - ctx threading (go-thread-context). Every new API accepts the caller’s
ctx context.Contextfirst; nocontext.Background()/TODO()outside the two mains and_test.gofiles. Shutdown timeouts derive from the caller’s ctx. - One convention.
go/internal/otelis the only provider-construction site; no second tracer/exporter path may appear beside it. - Money/
micro-USDN/A. No spend events here — that is the T1/T4 usage plane, a different record. - Proto changes are additive only, buf-breaking-gate clean; regenerate
both stacks with
moon run compass-proto:gen. Scope (Matt ruled at the freeze gate): the internal control ops (agent.proto), the publicSessionInjectionobservation (compass.proto, OQ2 = yes), and the internalCommsCallRequest.trigger_traceparent(agent_gateway.proto). The publicSessionInjectionfield is a public-API change — its impl PR carries the appropriate ledger row at impl time (this platform record staysLedger-impact: none). - Metrics cardinality (hard rule). Delivery/dispatch metric labels are op-kind (steer/deliver) ONLY — NEVER per-session, per-channel, or per-account labels. Unbounded label sets are a metrics-backend DoS; op-kind is a two-value domain.
- Trace termination. One trace = one turn. A reply is a fresh server-minted
root LINKED to its trigger, never a child — so no trace grows without bound
however long agents relay. A missing/empty
trigger_traceparentadds no link and never blocks the post. - Interceptor order. otelconnect prepends; the AdminGate→AmbientIdentity
relative order (
serve.go:642-668) is untouched. otelconnect mints a span for a request AdminGate then rejects on the dev/network door — bounded, and only when the endpoint is set; acceptable. - Agent-post origin ctx. The agent-authored post path is the unary
RelayCommsCallRPC (relay_comms.go:250) →executeCall(relay_comms.go:409) →PostAsAccount(agent_caller.go:131-148, which callsPostMessagein-process underWithActor) →publishMessagePosted. BecauseRelayCommsCallis a UNARY RunnerService RPC (not multiplexed on the Sessions stream), its handler ctx is per-call — so an otelconnect span on the RunnerService handler is a per-post origin, with no stream-lifetime span to leak. The constraint is therefore simply that the publish ctx is this per-call RPC ctx, never a process/stream root. See Open Question 5. - Record hygiene. Markdownlint-clean; ships as its own PR with
Co-authored-by: Matt Wilkinson <matt@rigel.build>(driver-owned);Ledger-impact: none(platform bucket is ungoverned,tools/design-ledger-gate/index.ts:52-60). - Image/FOD. New
go.opentelemetry.io/*+connectrpc.com/otelconnectdeps change both container images’ FOD hash; compass-native rebuilds.
Cross-lane ownership
Section titled “Cross-lane ownership”- compass-obs (RIG-2685, this lane) owns the emission leg (traces + metrics), the server-side stamping / runner-relay / message-path-span / cross-turn-link LOGIC, and the e2e test (T1, T2, T3, T5, T6 below).
- compass-server owns the proto seam (T4 below):
traceparenton the internal control ops (agent.proto) AND on the publicSessionInjectionobservation (compass.proto, OQ2 = yes) ANDtrigger_traceparenton the internalCommsCallRequest(agent_gateway.proto) — a stacked-PR seam that unblocks compass-agent’s decode/emit. The touched Go file zones (comms.go,consumer.go, serve assembly,go/events, and — for the (a′) origin span and the T5 move-6 causal Link —go/internal/runnerhub(relay_comms.go,handler.go)) are compass-server’s; emission convention and wiring, INCLUDING the runnerhub interceptor + link edits, were cleared with them 2026-08-27 and are coordinated at impl time like thego/eventssurface add. - compass-agent owns the agent side: continuation off
traceparent(#649 T3), PLUS two additions this record introduces — emittingtraceparenton the publicSessionInjectionobservation, and re-attaching the decodedtraceparentastrigger_traceparenton outbound posts (the cross-turn link source). They regenerate once T4 lands. - compass-native owns the image rebuild the new deps force (trace + metric exporters).
T1 — go/internal/otel bootstrap package
Section titled “T1 — go/internal/otel bootstrap package”Owner: compass-obs.
The single Go OTel convention: config resolution, tracer AND meter provider
construction, traceparent serialization helpers, and the trace-response
interceptor. No provider (tracer or meter) when the endpoint is empty. Unit
tests cover: disabled path returns a no-op shutdown and installs no global
tracer/meter provider; enabled path installs providers whose resource carries
service.name/service.version; Traceparent(ctx) returns "" with no span
and a W3C-grammar string with one; ContextWithTraceparent round-trips;
malformed input yields an unchanged ctx.
Interfaces:
-
Consumes:
go.opentelemetry.io/otel,.../sdk/trace,.../sdk/metric,.../sdk/resource,.../exporters/otlp/otlptrace/otlptracehttp,.../exporters/otlp/otlpmetric/otlpmetrichttp,go.opentelemetry.io/otel/propagation;connectrpc.com/otelconnect(re-exported wiring only). -
Produces (package
otel, import pathgithub.com/RigelBuild/compass/go/internal/otel):// Config carries the per-binary identity and the endpoint gate.type Config struct {ServiceName string // "compass-server" | "compass-runner"ServiceVersion string // each main's ldflags var versionEndpoint string // OTEL_EXPORTER_OTLP_ENDPOINT; empty = disabled}// SetupTracerProvider installs the global TracerProvider + W3C propagator// when cfg.Endpoint is non-empty; otherwise it is a no-op. The returned// shutdown flushes and stops the provider, bounded by the ctx it is given// (callers derive a timeout from their own ctx — never Background()).func SetupTracerProvider(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error)// SetupMeterProvider installs the global MeterProvider (OTLP/http metrics// exporter) under the same endpoint gate; a no-op when cfg.Endpoint is empty.// With it installed, otelconnect emits RPC duration/count metrics from the// same interceptor. Returns a shutdown that flushes and stops the provider.func SetupMeterProvider(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error)// Traceparent serializes ctx's active span context to the W3C string// ("00-<32hex>-<16hex>-<2hex>"), or "" when there is no valid active span.func Traceparent(ctx context.Context) string// ContextWithTraceparent returns ctx carrying the remote span context// parsed from tp; malformed or empty tp returns ctx unchanged.func ContextWithTraceparent(ctx context.Context, tp string) context.Context// NewTraceResponseInterceptor sets the "traceresponse" response header// from the handler span's context on every unary response (the UI/PostHog// trace_id source; see §Response-boundary trace id).func NewTraceResponseInterceptor() connect.UnaryInterceptorFunc
T2 — Server emission wiring + PostMessage origin span + response trace id
Section titled “T2 — Server emission wiring + PostMessage origin span + response trace id”Owner: compass-obs.
Wire the provider into the server binary and put RPC spans on the handlers
that carry a delivery origin. --otel-endpoint flag with
OTEL_EXPORTER_OTLP_ENDPOINT fallback via firstNonEmpty
(compass-server/main.go:451), resolved in buildServeConfig into
ServeConfig; SetupTracerProvider AND SetupMeterProvider called in run()
after slog.SetDefault (main.go:81) with the signal.NotifyContext ctx
(main.go:91), both shutdowns deferred before server.Serve returns
(main.go:100).
The origin span is the linchpin, so the otelconnect interceptor +
NewTraceResponseInterceptor() mount on the CommsService handler that serves
PostMessage/RespondToAsk: it is built with NO options today
(serve.go:458 NewCommsServiceHandler(commsSvc), reused on socket :634
and dev :671), so T2 adds a connect.WithInterceptors(...) there; on the
network door the CommsService handler rides the shared chain
(network_door.go:263-269), so otelconnect prepends to THAT chain. Add the
same interceptors to the CompassService chains (serve.go:626 socket, :662
dev; network CompassService also rides :263-269) so the UI’s CompassService
RPCs carry the traceresponse header. PostMessage (comms.go:334-359) and
RespondToAsk (comms.go:368-397) add compass.message.id to the handler
span after the append. Test cycle: a serve-level test with an in-memory span
exporter asserts a PostMessage RPC ON THE SHIPPED SOCKET DOOR (not merely a
CompassService procedure) produces a span carrying the message-id attribute,
and that the response carries a traceresponse header whose trace id equals
that span’s; a disabled-endpoint test asserts zero spans and no header.
The traceresponse header must ALSO be added to both CORS builders’
ExposedHeaders: devCORS (serve.go:758, ExposedHeaders at :763) and
networkCORS (network_door.go:143) today expose only
connectcors.ExposedHeaders(), so a browser on the cross-origin network door
(exactly where PostHog correlation matters) cannot read a header absent from
Access-Control-Expose-Headers; the test asserts the header is READABLE
through the CORS’d door, not merely on the wire.
Interfaces:
-
Consumes: T1’s
otel.Config/SetupTracerProvider/NewTraceResponseInterceptor;otelconnect.NewInterceptor();firstNonEmpty(main.go:451). -
Produces:
// ServeConfig gains the endpoint knob (go/server/serve.go).type ServeConfig struct {// ...existing fields...OtelEndpoint string // empty = tracing off}plus server RPC spans on all three doors and the
traceresponseresponse header (the #656 T6/RIG-2874 correlation seam’s trace_id source).
T3 — Runner emission wiring
Section titled “T3 — Runner emission wiring”Owner: compass-obs.
Same shape in the runner binary: --otel-endpoint flag with env fallback via
orEnv (compass-runner/main.go:211-216), SetupTracerProvider +
SetupMeterProvider in run() after slog.SetDefault (main.go:88) with the
signal.NotifyContext ctx (main.go:158), both shutdowns deferred before
runner.Run (main.go:161);
otelconnect interceptor on the runner’s outbound connect client(s) so the
enroll/Sessions dials emit client spans. Test cycle: unit test on the flag
resolution; an in-memory-exporter test asserting a client span on an
outbound RPC when enabled and none when disabled.
Interfaces:
-
Consumes: T1;
orEnv(main.go:211-216); the runner’s connect client construction sites ingo/internal/runner. -
Produces:
// RunnerConfig gains the endpoint knob (go/internal/runner/runner.go).type RunnerConfig struct {// ...existing fields...OtelEndpoint string // empty = tracing off}
T4 — trace proto fields: control ops, public observation, causal link (stacked seam)
Section titled “T4 — trace proto fields: control ops, public observation, causal link (stacked seam)”Owner: compass-server (proto file zones; coordinated 2026-08-27). Its own task deliberately: it is the stacked-PR seam compass-agent’s decode/emit (T3, RIG-2871) is blocked on — landing it first, alone, unblocks that lane before any Go stamping code exists.
Additive proto3 fields, verified against current source. Per Matt’s freeze-gate rulings this spans the internal control ops, the public observation surface (OQ2 = yes), and the internal agent-gateway leg (the causal-link source):
// proto/compass/v1/agent.proto — internal control opsmessage SteerControl { Message message = 1; string from_handle = 2; // W3C traceparent ("00-<32hex>-<16hex>-<2hex>") of the server span this // steer was wrapped under; empty when the server had no active span. string traceparent = 3;}message DeliverControl { Message message = 1; string topic_name = 2; string from_handle = 3; string traceparent = 4; // same contract as SteerControl.traceparent}
// proto/compass/v1/compass.proto — PUBLIC observation (OQ2 = yes)message SessionInjection { SessionInjectionKind op_kind = 1; string message_id = 2; string from_handle = 3; // Trace the agent continued for this injection; lets a public // session-stream consumer join the observation to its trace. Scalar add — // does not touch the SEA-1267 gen-fenced enum. string traceparent = 4;}
// proto/compass/v1/agent_gateway.proto — INTERNAL agent-gateway legmessage CommsCallRequest { string call_id = 1; // oneof call { ... } occupies 2-6; #628 (held) claims 7-9 in the same oneof, // so 10 is the next collision-free scalar slot (ratified by compass-server, // the agent_gateway.proto file-zone authority). // The delivered message's traceparent the agent re-attaches on an outbound // post, so the server links the reply's new trace to its trigger. Empty on a // human-seeded first turn. string trigger_traceparent = 10;}Test cycle: moon run compass-proto:gen regenerates both stacks; buf-breaking
gate green (pure adds); generated Go accessors (GetTraceparent(),
GetTriggerTraceparent()) compile.
Interfaces:
- Consumes:
agent.proto:196-206,:219-236;compass.proto:524-533;agent_gateway.proto:106-115. - Produces:
SteerControl.Traceparent(3),DeliverControl.Traceparent(4),SessionInjection.Traceparent(4),CommsCallRequest.TriggerTraceparent(10) in both stacks — the wire seams T5 stamps/links and compass-agent decodes/emits.
T5 — Bus bridge + control-wrap stamping + message-path spans
Section titled “T5 — Bus bridge + control-wrap stamping + message-path spans”Owner: compass-obs. Depends on T1, T2, T4.
The propagation leg proper, in seven moves:
- Bus bridge (
PublishCtx, message-origin only). AddPublishCtx(ctx, payload)(go/events/events.go, beside the ctx-freePublishat:165) that stamps the serialized traceparent ontoStamped[P](besideSeq/AtUnixMS/InstanceEpoch,events.go:171-176). OnlypublishMessagePosted(mapping.go:463-468) switches toPublishCtx, threading ctx from BOTH its live-dispatch callers —PostMessage(comms.go:356) andRespondToAsk(comms.go:395, the answer message); every other publisher (boardissue_projection.go:90/:119,projection.go:100; presencepresence.go:216,activity.go:48;publishReadyserve.go:746; themapping.gometadata publishers) keeps the ctx-freePublishand leavesTraceparentempty. §Alternatives rejects the full signature cutover (it would ripple ctx into ctx-free structural sinks). - Consumer extraction.
handleEvent(consumer.go:360-366) derives its dispatch ctx viaotel.ContextWithTraceparent(ctx, event.Traceparent)beforeonMessagePosted. - Stamping at control-wrap.
deliverOp/steerOp(consumer.go:374-395) gain atraceparent stringparameter (populated withotel.Traceparent(ctx)at the call sites, beside the existingauthorHandleresolution); sweeps (settle.go:193,:244, and the cursor sweep) root their own per-pass span and stamp from it. - Hop spans.
gatedDispatch(dispatch.go:343-354) wraps theDispatchControlcall (:350) in a span; the runner’s DeliverControl dispatch arm (runner/dispatch.go:457-472) starts a span parented off the op’s traceparent aroundhost.Deliver(:469). Both carrycompass.message.id,compass.op.kind,compass.session.idattributes. The op itself is relayed VERBATIM (clone-only,gateway/control.go:100-104) — the runner never rewrites the traceparent. - Held-deliver continuity. The held registry (
dispatch.go:81-85 hold→settle.go:257-273 fireHeld) carries the origin traceparent beside the message id —heldbecomes[]heldEntry{messageID, traceparent}, captured athold()from the bus-extracted ctx and restamped viaotel.ContextWithTraceparentatfireHeldbeforefanOut. This keeps the agent-reply-fans-out leg connected across the settle edge (fireHeldruns on the ctx-free consumer loop, so without this the whole held set stamps empty); empty-in stays empty-out when the agent post had no origin span (Open Question 5). - Cross-turn causal link (a′ + termination). In
executeCall’s Post arm (relay_comms.go:416), after otelconnect has created theRelayCommsCallorigin span on the handler ctx, add a span Link built fromCommsCallRequest.trigger_traceparentvia the OTel link API (trace.SpanFromContext(ctx)+ a link from the parsed remote context) — a LINK, never a parent, so the reply’s fresh root references its trigger without nesting. Emptytrigger_traceparentadds no link. This is the mechanism that keeps traces terminating (§Trace lifetime and termination). - Op-kind delivery metric. A single Int64Counter
(
compass.delivery.dispatched) CREATED ONCE at meter setup / consumer construction and held as a field (never re-created inside the hot path — per-call instrument creation is an OTel anti-pattern: duplicate-instrument churn/warnings), incremented atgatedDispatch(dispatch.go:343-354) with the op-kind (steer/deliver) attribute ONLY — never per-session/per-channel (§Global Constraints cardinality rule). Op-kind is read from the*AgentControloneof, the SAME source as the move-4compass.op.kindspan attribute. RPC duration/count come free from otelconnect once the meter provider exists (T2/T3).
Test cycle: bus round-trip test (a PublishCtx under an active span yields a
Stamped event whose traceparent matches; no span ⇒ empty); consumer test
extending TestDeliverAndSteerCarryAuthorFromHandle’s harness
(delivery/mention_test.go:261) asserting the dispatched steer AND deliver
ops carry the publisher’s traceparent, and empty when no span; a held-then-
settle test asserting the FIRED held ops carry the origin traceparent across
the settle edge (empty-in ⇒ empty-out); a no-provider test asserting dispatch
is unaffected (never blocked); a runner dispatch-arm test asserting the
relayed op reaches host.Deliver with the traceparent unmodified; a
causal-link test asserting a RelayCommsCall with a non-empty
trigger_traceparent produces an origin span that is a fresh root (trace id ≠
the trigger’s) carrying a Link to the trigger’s context, and that an empty
trigger_traceparent produces a root with no link; a metric test asserting
compass.delivery.dispatched increments with the op-kind attribute and no
session/channel labels.
Interfaces:
-
Consumes: T1 helpers; T4 generated fields; the sites cited above.
-
Produces:
go/events/events.go func (b *Bus[P]) PublishCtx(ctx context.Context, payload P) uint64 // origin path// Publish(payload P) stays ctx-free for every non-message publisher.type Stamped[P any] struct {Seq uint64AtUnixMS int64InstanceEpoch uint64Traceparent string // W3C; "" when the publisher had no active spanPayload P}// go/internal/delivery/consumer.gofunc deliverOp(msg *compassv1.Message, fromHandle, traceparent string) *compassv1internal.AgentControlfunc steerOp(msg *compassv1.Message, fromHandle, traceparent string) *compassv1internal.AgentControl// go/internal/delivery/dispatch.go — held registry carries the origin traceparenttype heldEntry struct{ messageID, traceparent string }
T6 — E2E trace-continuity test: one turn, one trace
Section titled “T6 — E2E trace-continuity test: one turn, one trace”Owner: compass-obs. Depends on T2, T3, T5.
The proof of the ratified goal. An in-process end-to-end test (on the
pattern of the existing cross-process delivery tests, e.g.
go/server/offline_mention_e2e_pgtest_test.go) with an in-memory span
exporter (sdk/trace/tracetest.NewInMemoryExporter) installed on both the
server and (where the harness hosts it) the runner side: post a message via
PostMessage, drive it through the consumer to a bound session, capture the
op at the gateway/agent seam, and assert (a) the op’s traceparent parses
to the SAME trace id as the PostMessage handler span, (b) every recorded
server/runner hop span shares that trace id (one connected trace), (c) the
traceresponse header on the PostMessage response carries the same trace
id, and (d) with the endpoint unset the identical flow dispatches
successfully with empty traceparent and zero recorded spans. Agent-side
continuation is compass-agent’s test surface (RIG-2871), not re-tested here.
Additional cases pin the paths T5 adds and the continuity BOUNDARY: (e) a
held-then-settle agent post asserts the fired deliver carries the same trace
id as the agent post’s own origin (continuity across the hold edge, within the
reply turn’s trace); (f) a sweep-delivered message asserts its op’s traceparent
parses to a trace id DIFFERENT from the post’s, pinning fresh-root-by-design
(§Continuity boundary); (g) an ask-answer turn (RespondToAsk) asserts the
answer message’s deliver op carries the same trace id as the RespondToAsk
handler span; (h) an agent-authored post (a′) asserts its RelayCommsCall
origin span exists and is a fresh root; (i) TERMINATION — a reply carrying a
trigger_traceparent asserts a NEW trace id (≠ the trigger’s) with a Link back
to the trigger, proving replies branch rather than extend one unbounded trace;
(j) the compass.delivery.dispatched op-kind counter increments and carries no
per-session/per-channel label.
Interfaces:
- Consumes: T1/T2/T3/T5 outputs;
go.opentelemetry.io/otel/sdk/trace/tracetest; the delivery test harness (go/internal/delivery/helpers_test.go). - Produces: the e2e continuity test; the span/attribute names it locks in become the contract T5-dashboards (#656 T5) renders.
- T1 —
go/internal/otelbootstrap package + config + off-by-default gating (tracer AND meter provider) + traceparent helpers + trace-response interceptor + unit tests (Owner: compass-obs) - T2 — Server emission wiring (flag/env →
ServeConfig,run()tracer + meter setup, otelconnect on the delivery-origin handlers) + PostMessage origin-span attrs +traceresponseresponse header + both CORS builders’ExposedHeaders(Owner: compass-obs) - T3 — Runner emission wiring (flag/env →
RunnerConfig,run()tracer + meter setup, client interceptor on outbound dials) (Owner: compass-obs) - T4 — Additive proto fields:
traceparenton SteerControl (3), DeliverControl (4), public SessionInjection (4), andtrigger_traceparenton CommsCallRequest (10; #628 holds 7-9) + regen both stacks (Owner: compass-server; stacked seam — lands first, unblocks compass-agent decode/emit RIG-2871) - T5 — Bus ctx/traceparent bridge (
PublishCtx, message-origin only) + control-wrap stamping + sweep-rooted spans + held-deliver continuity + server/runner hop spans + cross-turn causal link + op-kind delivery metric + tests (Owner: compass-obs) - T6 — E2E one-turn-one-trace + termination (linked new trace) + metric-recorded test (Owner: compass-obs)
- compass-agent — emit
traceparenton the public SessionInjection observation + re-attach decodedtraceparentastrigger_traceparenton outbound posts (the cross-turn link source), beside #649 T3 decode (Owner: compass-agent; after T4) - Driver — coordinate compass-native image rebuild (FOD hash change) and compass-agent regen at T4/impl PR time
Open Questions
Section titled “Open Questions”- RESOLVED (Matt, freeze gate 2026-08-27): traces + metrics in T4b.
#656 D6/T4b says “traces/metrics” (parent record
compass-observability-architecture/design.md:658). Matt ruled the metrics leg ships now, not as a follow-up. Scope: otelconnect RPC duration/count (free from the same interceptor once a MeterProvider exists) + ONE delivery counter (compass.delivery.dispatched) keyed by op-kind ONLY — never per-session or per-channel labels (§Global Constraints cardinality rule). Metrics wiring is the same providers/exporters plusSetupMeterProvider, no re-instrumentation (folded into T1/T2/T3/T5). - RESOLVED (Matt, freeze gate 2026-08-27): YES — expose
traceparenton the publicSessionInjectionobservation (field 4).SessionInjection(proto/compass/v1/compass.proto:524-533; next-free field 4 afterop_kind=1, message_id=2, from_handle=3) is the publicSessionEvent/SubscribeAgentSessionpayload. Adding a scalarstring traceparentdoes NOT touch the gen-fenced enum (SEA-1267,compass.proto:535-539, fencesSessionInjectionKind), so it is a clean additive public-API field. The agent emits it from thetraceparentit decoded (#649 T3), giving public session-stream consumers a direct observation→trace join (#649 OQ3’s observation symmetry). Folded into T4 (proto) + compass-agent emission (cross-lane ownership). The impl PR touching the public surface carries its own ledger row at impl time. - Bus trace-context bridge shape. T5 bridges the trace context across the
in-process bus via a NEW
PublishCtx(ctx, payload)used only by the message-origin publisher, leaving the ctx-freePublish(payload)for every other publisher (§Alternatives rejects a full-signature cutover — it would ripple ctx into deliberately ctx-free structural sinks likeLifecycleSink). This is compass-server’s file zone (go/events,go/internal/comms). DECIDED (compass-obs; coordinate at impl): adopt thePublishCtxsplit as designed; coordinate the surface add with compass-server at impl (flagged here so the impl PR review isn’t the first they hear of it). traceresponseheader name. The W3C trace-context response header is still a draft. DECIDED (compass-obs): usetraceresponsewith the standard00-…grammar anyway — it is the emerging standard, costs nothing, and a rename is a one-line change in T1’s interceptor + the UI reader; a bespokex-compass-trace-idbuys nothing.- RESOLVED (Matt, freeze gate 2026-08-27): (a′) + cross-turn link. Agent
posts do not ride the
CommsServiceRPC — they ride the UNARYRelayCommsCall(runner.proto:95;Hub.RelayCommsCallrelay_comms.go:250) →Hub.executeCall(relay_comms.go:409, Post arm:416) →Comms.PostAsAccount(agent_caller.go:131-148) →PostMessagein-process. BecauseRelayCommsCallis unary (not on the bidi Sessions stream,runner.proto:70), its handler ctx is per-call — no stream-lifetime span to leak. (a′): add the otelconnect interceptor to the RunnerService handler (NewMountedHandler,handler.go:433-437), so everyRelayCommsCallgets a per-call origin span for free — symmetric to theCommsServicehandler, in-lane, no hand-minted span. This makes an agent post a fresh SERVER-minted root (the turn boundary), and T5’sPublishCtxthreading already carries it to the stamp. Matt also greenlit the proto change that turns “(a′) fresh root” into a terminating causal chain:trigger_traceparentonCommsCallRequest(T4) + a server-side span Link (T5 move 6), so the reply’s new trace references its trigger without a parent edge. This SUPERSEDES option (c) (continue the delivered context onto the reply) precisely because a parent edge would never terminate — the record’s answer to Matt’s “the trace needs to terminate somewhere” (§Trace lifetime and termination).