diff --git a/docs/designs/platform/compass-agent-loop-otel/deployer-contract.md b/docs/designs/platform/compass-agent-loop-otel/deployer-contract.md new file mode 100644 index 00000000..7ff1ca0c --- /dev/null +++ b/docs/designs/platform/compass-agent-loop-otel/deployer-contract.md @@ -0,0 +1,106 @@ +# Deployer contract — compass-agent whole-agent telemetry + +Maintainer-facing reference for anyone configuring OpenTelemetry on a +compass-agent deployment. It documents the env-var surface a deployer sets, the +off-by-default guarantee, and the two asymmetries the design record +(`design.md`, T2) requires a deployer to know before wiring a collector. + +Scope: this is an internal RigelBuild doc. It describes the contract as of the +loop-OTel activation (T1); the code cites are into the frozen design record and +the reused `@oh-my-pi/pi-coding-agent` module. + +## Two independent signals, one collector + +compass-agent emits two OpenTelemetry signal trees to the same collector +(Decision 2, ruling (b)): + +- the **loop** signal — OMP's native agent-loop tracing, reused via + `@oh-my-pi/pi-coding-agent/telemetry-export` and activated in + `cli.ts` `main()`; +- the **transport** signal — the frozen `src/transport/` layer's own OTel + provider. + +They are separate trace trees exported to one collector and correlated there by +`service.name` plus the shared `compass.session.id` resource attribute +(Decision 3a), not by parent/child links. + +## Environment variables + +| Variable | Effect | +| --- | --- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | The endpoint key to set for whole-agent telemetry. Turns on BOTH the loop and the transport signals. | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Traces-only endpoint override honored by the loop; see the F5 asymmetry below before using it. | +| `OTEL_SERVICE_NAME` | Overrides the loop signal's service name (defaults to `compass-agent` on the enabled path); see the F1 asymmetry below. | +| `OTEL_RESOURCE_ATTRIBUTES` | Deployer-set resource attributes. The activation APPENDS `compass.session.id=` (Decision 3a), never clobbering a deployer value. | +| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Opt-in for capturing GenAI message content. Off by default; see the sensitivity warning below. | + +### Setting the endpoint + +For whole-agent telemetry, set `OTEL_EXPORTER_OTLP_ENDPOINT`. That single key +turns on both signals. Do not reach for the `TRACES_` variant unless you have +read the F5 asymmetry below and specifically want a traces-only, loop-only +configuration. + +### Service name override + +On the enabled path with no deployer override, the loop signal defaults to +`service.name = compass-agent` +(`cli.ts` sets `process.env.OTEL_SERVICE_NAME ??= "compass-agent"`, Decision 3), +matching the transport, so both signals correlate under one name. A deployer +`OTEL_SERVICE_NAME` override does NOT rename both signals symmetrically; see the +F1 asymmetry below. + +### Content-capture opt-in and its sensitivity warning + +`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` is off by default; the +activation never sets it +(`pi-agent-core/src/telemetry.ts:58,326-333`). Enabling it makes the model +message content (prompts and completions) leave the container as span +attributes on the OTLP export. + +> **Warning — sensitivity.** Turning this on exports message content off-box to +> the collector. Treat it as a debug-only opt-in on a trusted collector, never a +> production default. + +## Off-by-default guarantee + +With no endpoint configured — neither `OTEL_EXPORTER_OTLP_ENDPOINT` nor +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` set — the activation block is skipped whole +(the `isTelemetryEndpointConfigured` gate at `cli.ts:838`): no provider is +registered, no `telemetry` session option is passed, `process.env` is unmutated, +and there is zero network egress (Global Constraints, "Off by default"). The +no-endpoint behavior is bit-identical to a build without this activation, and +the existing test suites stay green unmodified. + +## Asymmetries a deployer must know + +Both of these are documented rather than code-fixed (the transport is frozen; +ruling (b)). A deployer configuring a collector must know them. + +### F5 — endpoint-gate asymmetry + +The loop honors `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT` +(`telemetry-export.ts:62-63`). The transport gates ONLY on +`OTEL_EXPORTER_OTLP_ENDPOINT` (`otel-layer.ts:49-51`) and never reads the +`TRACES_` variant. + +Consequence: `OTEL_EXPORTER_OTLP_ENDPOINT` turns on BOTH signals, but +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` set alone lights the loop and leaves the +transport dark. + +Contract line: set `OTEL_EXPORTER_OTLP_ENDPOINT` for whole-agent telemetry. + +### F1 — service-name split + +The loop reads the service name env-first (`OTEL_SERVICE_NAME ?? "oh-my-pi"`, +`telemetry-export.ts:103`), while the transport tags `service.name` in code +(`otel-layer.ts:61`) and the code value WINS over env. + +Consequence: a deployer `OTEL_SERVICE_NAME` override renames the LOOP signal +only; the transport stays `compass-agent`. An override therefore splits the two +signals under different names. + +Because of that, the durable cross-signal join is NOT the service name. It is +the shared `compass.session.id` resource attribute carried in +`OTEL_RESOURCE_ATTRIBUTES` (Decision 3a), which both providers read natively and +stamp. Correlate on that, not on `service.name`. diff --git a/packages/compass-agent/src/cli.test.ts b/packages/compass-agent/src/cli.test.ts index 339af9bf..c1b3b488 100644 --- a/packages/compass-agent/src/cli.test.ts +++ b/packages/compass-agent/src/cli.test.ts @@ -1449,7 +1449,9 @@ describe("main sources $HOME/.compass/env into process.env", () => { // main's enabled-path telemetry activation writes these when an endpoint is // sourced (the OTEL-endpoint test below); save+restore so they never leak. "OTEL_SERVICE_NAME", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_RESOURCE_ATTRIBUTES", + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "COMPASS_FUTURE_VAR", "LITELLM_BASE_URL", "LITELLM_MCP_URL", @@ -1536,6 +1538,54 @@ describe("main sources $HOME/.compass/env into process.env", () => { expect(process.env.COMPASS_FUTURE_VAR).toBeUndefined(); }); + test("all four loop-OTel env keys from the Runner-materialized env file survive isReservedEnvKey filtering and reach process.env", async () => { + const home = process.env.HOME as string; + // Invariant (RIG-2508 T2): the four loop-OTel keys the Runner materializes + // into $HOME/.compass/env reach process.env BEFORE the telemetry + // registration point (cli.ts:838), because the env-merge loop + // (cli.ts:614-618) runs earlier — so a file-sourced OTEL_* key is in + // process.env by the time initTelemetryExport / the loop tracer reads it. + // None of the four is HOME or COMPASS_*-prefixed, so isReservedEnvKey + // (cli.ts:108-110) lets them all through. + // Non-vacuity: were any of these keys COMPASS_-prefixed or HOME, it would + // be dropped by isReservedEnvKey (as the neighboring test pins for + // COMPASS_FUTURE_VAR) → the assertion reds. So this pins that the loop + // keys are genuinely NOT reserved and survive the filter unaltered. + // (The TRACES_ endpoint fires T1's enabled-path activation, which APPENDS + // compass.session.id to OTEL_RESOURCE_ATTRIBUTES — never clobbering the + // deployer-set value, Decision 3a — so the file value survives as a prefix.) + // OTEL_SERVICE_NAME uses a DISTINCT sentinel (not "compass-agent") on + // purpose: the activation's `??=` default (cli.ts:839) is "compass-agent", + // so a file value equal to that default would keep the assertion green even + // if the key were wrongly dropped by isReservedEnvKey and re-defaulted. A + // distinct value only survives when the key genuinely reaches process.env + // AND the `??=` no-ops on the already-present value — pinning the invariant. + writeEnvFile( + home, + "OTEL_SERVICE_NAME=deployer-custom-name\n" + + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://collector.example:4318/v1/traces\n" + + "OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod\n" + + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true\n", + ); + await main( + { HOME: home }, + deps( + fakeSession(), + fakeCarrier(emptyLog(), { control: emptyControlStream }), + ), + ); + expect(process.env.OTEL_SERVICE_NAME).toBe("deployer-custom-name"); + expect(process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT).toBe( + "http://collector.example:4318/v1/traces", + ); + expect(process.env.OTEL_RESOURCE_ATTRIBUTES).toContain( + "deployment.environment=prod", + ); + expect(process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT).toBe( + "true", + ); + }); + test("derives LITELLM_MCP_URL from a delivered LITELLM_BASE_URL (RIG-2674)", async () => { const home = process.env.HOME as string; // The keyring delivers only the base URL (+ API key). Without the derive,