Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions docs/designs/platform/compass-agent-loop-otel/deployer-contract.md
Original file line number Diff line number Diff line change
@@ -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=<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`.
50 changes: 50 additions & 0 deletions packages/compass-agent/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Loading