Agent OTLP rides the agent socket to a host collector
Tracker: RIG-4611
Amendment record of the observability-architecture design
(../compass-observability-architecture/design.md,
§D3 and §T4). The parent stays frozen.
Ledger-impact: DL-515.
Problem / Intent
Section titled “Problem / Intent”Matt ruled (RIG-5144) that agents ship OTLP through a collector to an operator upstream. Parent T4 picks no route. This record decides the collector’s upstream export (C-a), the agent route (C-b) and dev wiring (C-c).
Rulings
Section titled “Rulings”Matt, RIG-5237 (2026-10-11 and 2026-10-12):
- OQ1 B: a collector on each runner host forwards upstream.
- OQ2 B, transport A: agents export OTLP/HTTP over the agent socket. The
Runner gateway serves
/v1/tracesand/v1/metrics(/v1/logs: Resolved 2) and relays to the host collector. No pasta, no OTLP egress entry. - The socket stays the agent’s only channel to its Runner.
Amended frozen texts:
docs/designs/repo/compass-agent-effect-otel/design.mdOpen Questions item 1 and Decision 3: lifted for agent telemetry.docs/designs/observability/compass-agent-loop-otel/design.mdDecision 3a: the join key is the gateway-stampedcompass.session.id; the SDK id moves tocompass.agent.sdk_session.id.- Both agent deployer contracts
(
compass-agent-effect-otel/otel-endpoint-deployer-contract.md,compass-agent-loop-otel/deployer-contract.md): env-file endpoints are ignored; the gateway setsservice.name. - Parent D3 “agent-side emission stays exactly as shipped”: superseded for the route. Off by default holds.
Global Constraints
Section titled “Global Constraints”- D3 holds: no export endpoint keeps today’s
nopconfig bytes; only config and headers files touch disk. - No secret in argv or the 0644 config. Upstream credentials live in the 0600 headers file and server or runner env, never in agents.
- Host-tier exception: agents share the Runner’s uid
(
go/internal/runtime/host_backend.go), so they can read its env and reach any session’s socket. Host-tier telemetry is one trust domain, not per-session evidence. - Only the gateway sets
compass.session.id,compass.telemetry.originand the agent’sservice.nameon relayed telemetry. - Every endpoint passes
otlpenv.ParseEndpoint(CA1): plaintext only to loopback. - Server and runner emit or relay only with an endpoint, and drop inherited
OTEL_*.DefaultCollectorImagestays 0.140.0. - Breaking (
!):--otel-externaltakes a URL; env-file agent endpoints are ignored; compass-runner refuses per-signal and TLSOTEL_EXPORTER_OTLP_*keys.
Approach
Section titled “Approach”C-a: the collector exports (CA1). collectorConfigYAML
(go/internal/stack/collector_container.go) hard-codes nop: {}. With
--otel-export-endpoint (fallback COMPASS_OTEL_EXPORT_ENDPOINT) it renders
otlphttp behind memory_limiter (check_interval: 1s, 256 MiB, 64 MiB
spike) and an 8 MiB receiver cap. --otel-export-headers-file is copied 0600
to /run/compass/otlp-headers.yaml on every Start.
C-b, host collector (CB2). The Runner relays agent OTLP to the endpoint its
own SDK reads, OTEL_EXPORTER_OTLP_ENDPOINT plus _HEADERS (setupOtel,
go/cmd/compass-runner/main.go). compass-stack sets it on server and runner;
any two of these flags is a usage error:
--otel-export-endpoint: the bundled collector,http://127.0.0.1:<CollectorHTTPPort>. Without it nothing is set (D3).--otel-external <URL>: the operator’s host collector, which owns batching, retry and upstream (ruling 1);--otel-export-headers-filerides it inEnv.--otel-off: nothing set, overridingCOMPASS_OTEL_EXPORT_ENDPOINT.
A remote runner host’s operator runs the collector (e.g. Grafana Alloy).
compass-runner refuses a bad endpoint and per-signal or TLS keys. The relay
appends /v1/<signal> by path.Join, as the Go SDK’s otlpconfig does.
C-b, gateway handlers (CB1). gateway.Serve
(go/internal/runner/gateway/gateway.go) mounts POST /v1/traces,
/v1/metrics and /v1/logs only when Deps.OTLPRelay is set. Each handler:
- Resolves
g.sessions.Session(g.containerName). Unbound is 503 withRetry-After: 1:agentHost.Startstarts the agent before recording its session. - Answers 415 unless
application/x-protobufwith identity encoding. - Takes one of 8 per-Gateway slots (a flush exports five signals) and 4 MiB
of a Runner-wide 128 MiB budget, else 503 with
Retry-After: 1, unread. One 8 s deadline (aswithBodyReadDeadline,go/server/network_door.go) covers read and upstream. - Reads at most 4 MiB (413). An allocation-free
protowirepre-scan counts messages and repeated-scalar elements, packed or unpacked; over 65,536 messages or depth 32 is 413. It reserves 416 B per message, 16 B per element (append doubling) and twice the body, else 503, then decodes withgo.opentelemetry.io/proto/otlp(direct; 400 if malformed). 4 MiB of empty attributes measured 244 MiB decoded. - Deletes
compass.session.idfrom every scope, span, event, link, data point, exemplar and log record. On each resource it setscompass.session.idto the bound id,compass.telemetry.origin=agentandservice.name=compass-agent. - Re-encodes (over 8 MiB is 413) and POSTs under
r.Context(). Upstream 2xx is 200; 429, 502, 503 or 504 is 503 with itsRetry-After; 413 stays; another 4xx is 400; else 502.
C-b, agent (CB3). When relaying, the Runner sets the reserved
COMPASS_AGENT_OTLP=1. After the env-file merge the agent deletes and logs
every OTEL_EXPORTER_OTLP_* key, and records the SDK session id as
compass.agent.sdk_session.id.
makeOtelLayer(packages/compass-agent/src/transport/otel-layer.ts) builds exporters withurl: "http://compass-agent/v1/<signal>"andhttpAgentOptions: { socketPath }.- The SDK loop’s exporters (
registerProviders,@oh-my-pi/pi-coding-agent) read env at construction. ABun.serveproxy on127.0.0.1:0checks a per-process token header, caps bodies at 4 MiB and forwards withfetch(…, { unix, signal: req.signal }). Endpoint and token are set only acrossawait telemetryHooks.init(); a subprocess spawned during its dynamic import gains only what its socket already gives. Egress acceptsoif lo(NftScript).
| Tier | Socket reaches the agent by | OTLP |
|---|---|---|
| podman | bind mount at /run/compass/agent.sock |
covered hermetically |
| microVM | guestd gatewayProxy splice to vsock |
covered |
| host | COMPASS_AGENT_SOCKET_PATH |
covered; one trust domain |
| apple-container | --volume over virtiofs |
none: DL-338’s slow-roll holds the agent channel |
C-c: dev wiring (CC1). devenv runs the same collector config.
Alternatives considered
Section titled “Alternatives considered”- Pasta forward (earlier draft): podman only, agent-asserted attribution; RIG-5249 becomes moot.
- Direct export through the egress allowlist: credentials in every agent, forgeable attribution.
CA1: collector upstream export
Section titled “CA1: collector upstream export”Files: go/internal/stack/{collector_container,config}.go,
go/internal/stack/adapters/collector_container.go,
go/cmd/compass-stack/main.go, new go/internal/otel/otlpenv.
Interfaces:
Config.CollectorExportEndpoint,Config.CollectorExportHeadersFile string.CollectorConfigYAML(p CollectorYAMLParams) string, withCollectorYAMLParams{GRPC, HTTP, Health, Export, HeadersPath string}.CollectorContainerSpec.ExportHeadersFile string;const CollectorHeadersMountPath = "/run/compass/otlp-headers.yaml".otlpenv.ParseEndpoint(raw string) (*url.URL, error);otlpenv.SignalURL(base *url.URL, signal string) *url.URL.
Red→green:
TestCollectorConfigYAMLExportsUpstream(red today):otlphttpandmemory_limiterwithcheck_intervalin all three pipelines, an 8388608 body cap, nonop.TestCollectorConfigYAMLRealizesD3Posture: container ports, emptyExportandHeadersPath, today’s bytes.TestParseEndpoint:httponly to127.0.0.1orlocalhost,httpsanywhere; refuses barehost:port,127.0.0.2,[::1],0.0.0.0, userinfo, query, fragment.TestSignalURL:https://h/otlpgives/otlp/v1/traces.TestCollectorStartReplacesHeadersCopy;TestCollectorRunArgsExportContract(no header value in argv or config).- Smoke:
otelcol validateon the pinned image;compass-stack updelivers a span and the header to a local receiver.
CB1: gateway OTLP relay
Section titled “CB1: gateway OTLP relay”Files: go/internal/runner/gateway/{gateway,otlp}.go,
go/internal/otel/{otlprelay,otlpscan}, go/go.mod.
Interfaces:
type OTLPRelay interface { Export(ctx context.Context, path string, body []byte) error };Deps.OTLPRelay(nil leaves the paths unmounted);Deps.OTLPBudget *semaphore.Weighted(golang.org/x/sync).otlprelay.New(endpoint *url.URL, headers map[string]string) *Relay; a non-2xx upstream returns*otlprelay.StatusError{Code int, RetryAfter string}.otlpscan.Bound(body []byte, signal string) (uint64, error).const maxOTLPBodyBytes = 4 << 20,maxOTLPInFlight = 8,maxOTLPMessages = 1 << 16,otlpRequestDeadline = 8 * time.Second.- Counter
compass.runner.otlp_relay.requests{signal,outcome}, nil-guarded, no session label.
Red→green (hermetic, fake relay):
TestOTLPStampOverridesAgentSession: forged ids on a resource, span, data point and log record, andservice.name=compass-server, arrive as the bound id andcompass-agent.TestOTLPUnboundContainerRetryable(503);TestOTLPBodyCap(413);TestOTLPRejectsCompressedBody(415).TestOTLPSaturationShedsWith503: a ninth request or empty budget, unread.TestOTLPDecodeBound: 70,000 empty attributes is 413;otlpscan.Boundcovers measured allocation, including two million unpackedBucketCounts.TestOTLPRelayFailure: 400 stays 400, 503 keepsRetry-After, transport error is 502.TestOTLPUnmountedWhenRelayNil(404);TestRelayPostsWithHeaders.- microVM e2e
TestVsockGateway_InGuestOTLPStampedagainst a fakeAgentHostConfig.OTLPRelay.
CB2: runner and stack wiring
Section titled “CB2: runner and stack wiring”Files: go/cmd/compass-runner/main.go, go/internal/runner/{host,agent_exec}.go,
go/internal/runtime/host_backend.go, go/internal/stack/{config,spec}.go,
go/internal/stack/adapters/process.go, go/cmd/compass-stack/main.go.
Interfaces:
AgentHostConfig.OTLPRelay gateway.OTLPRelay;(*agentHost).gatewayDeps() gateway.Depsreplaces bothhost.goliterals, sharing one budget.AgentEnv.Telemetry boolsetsCOMPASS_AGENT_OTLP=1.otlpenv.HeadersValue(map[string]string) (string, error);otlpenv.RunnerConfig(environ []string) (*url.URL, map[string]string, error).Config.OTLPOff bool;ExternalOTLPEndpointbecomes a URL;childEnv(inherited, overlay []string) []stringandenvSlicedrop inheritedOTEL_*.
Red→green:
TestServerAndRunnerSpecOTLPEndpoint: each mode; headers inEnv, neverArgs.TestStartDropsInheritedOTELKeys,TestEnvSliceDropsInheritedOTELKeys,TestExecSpecTelemetryFlag.TestResolveConfigOTelModes: two OTel flags are refused;--otel-offbeats the env fallback.TestRunnerConfig: refuses a bad endpoint and per-signal or certificate keys.- Migration to
--otel-off:go/e2e/fixture.go,TestContainerPostgresUpDown,TestExternalDatabaseUpDown,TestNatsUpDown,TestExternalNatsUpDown. To a URL:TestExternalOTLPUpDown,TestResolveConfigCollectorFlags,TestConfigValidate,TestExternalOTLPSkipsCollector.
CB3: agent exporters over the socket
Section titled “CB3: agent exporters over the socket”Files: packages/compass-agent/src/transport/{index,otel-layer,otlp-proxy}.ts,
packages/compass-agent/src/cli.ts.
Interfaces:
makeOtelLayer(socketPath: string): Layer.Layer<never>(Layer.emptywithout the flag), called bycreateUnixSocketTransport.startOtlpProxy(socketPath: string): Promise<{ endpoint: string; headers: string; close(): Promise<void> }>.scrubOtlpEnv(env: NodeJS.ProcessEnv): string[]returns the dropped keys;isTelemetryEndpointConfiguredkeys on the flag.
Red→green (bun:test, a Unix-socket server in a temp dir):
- A transport span reaches
/v1/tracesonly with the flag. - A no-argument
OTLPTraceExporterreaches the socket through the proxy; a tokenless request is refused; a client abort cancels the forward. - A child spawned after init sees no
OTEL_EXPORTER_OTLP_*. scrubOtlpEnvdrops and logs an env-file endpoint.- Migration to the flag:
cli.test.ts’s env-file endpoint, “all four loop-OTel env keys…”, “endpoint set ⇒ env defaults…” and “deployer-set OTEL_SERVICE_NAME…”;otel-layer.test.ts’s three endpoint tests.
CC1: devenv runs the collector
Section titled “CC1: devenv runs the collector”Interfaces: compass-stack collector-config --grpc --http --health [--otel-export-endpoint] [--otel-export-headers-file] prints
CollectorConfigYAML; usageError names five subcommands.
devenv processes.otel-collector runs otelcol-contrib when the gitignored
devenv.local.nix sets env.COMPASS_OTEL_EXPORT_ENDPOINT.
Red→green: TestRunCollectorConfig; devenv up with an endpoint delivers an
agent span upstream.
CD1: operator docs
Section titled “CD1: operator docs”Interfaces: none. docs/self-host.md gains an observability section (flags,
remote-runner OTEL_EXPORTER_OTLP_*); each deployer contract points there.
Red→green: none; rumdl and the ledger gate pass.
- CA1: collector export
- CB1: gateway relay
- CB2: runner and stack wiring
- CB3: agent socket exporters
- CC1: devenv collector
- CD1: operator docs
Resolved decisions
Section titled “Resolved decisions”- SDK-loop exporters use the token proxy, not an SDK patch: no fork.
Agent-owned providers would miss the SDK’s metric and log hooks, which
only its dynamically imported, unexported
telemetry-export-otlpregisters. /v1/logsis mounted with the same stamp: env-file endpoints enable SDK logs (resolveSignalConfig).- The remote host collector is operator-run; a runner-only mode is additive.