Files
codex/docs/observability-event-stream-design.md
T
Albin Cassirer b615ce9291 [observability] Add explicit field use markers
Add FieldUse metadata so fields declare the exact projections intended to consume them. Detail level and data class remain guardrails enforced by FieldPolicy, while uses drives selection and prevents broad policies from accidentally collecting every safe-looking field.

Support struct-level default uses in the Observation derive, with field-level uses overriding the default for mixed events. AppUsed now declares analytics once at the event level instead of repeating it on every field.

Keep analytics conformance focused on final TrackEventRequest equality and move marker/policy filtering coverage into codex-observability tests. Update the design doc to describe the intent-versus-guardrail split.
2026-04-19 15:17:38 -07:00

429 lines
18 KiB
Markdown

# Observability Event Stream
Codex currently has three overlapping observability systems:
- `codex-rs/analytics` reduces runtime facts into remote product analytics.
- `codex-rs/rollout-trace` records rich local diagnostic traces and reduces
them into a replay/debug graph.
- `codex-rs/otel` emits logs, trace-safe events, and metrics, while native
`tracing` spans carry execution scope and W3C trace context.
The overlap is real: thread lifecycle, turns, model calls, tools, compaction,
subagents, transport, and feature usage are described more than once. Directly
merging the crates would couple incompatible outputs. The shared layer should
instead define the facts Codex emits; each destination should decide how those
facts are filtered, reduced, stored, or exported.
## Decision
Introduce a shared observation stream for semantic facts.
```text
Observations own semantic facts and metrics derived from facts.
tracing owns spans and trace context propagation.
OTEL is a consumer of observations, not a second event taxonomy.
```
This gives one rule for callers:
- Use observations when a thing happened.
- Use sinks/reducers for analytics, rollout trace, OTEL events, OTEL metrics,
local buffers, and feedback upload.
- Use `tracing::span!` when code needs active execution scope, parent/child
causality, span mutation, or W3C context propagation.
The target state should not be a long-lived mix where some facts use
observations and other facts call analytics or OTEL directly. Direct OTEL code
should remain for exporter setup, native spans, context propagation, and
low-level tracing plumbing.
## Goals
- Define canonical observation events that describe what Codex did, not where
the data is going.
- Keep analytics, rollout trace, OTEL logs, and OTEL metrics as projections.
- Apply filtering at field level so a single observation can contain both safe
aggregate fields and richer local-only evidence.
- Preserve existing analytics and OTEL output behavior while moving callsites
toward the shared stream.
- Support local rich retention for feedback/debug upload with explicit policy.
- Prove analytics equivalence with side-by-side conformance tests.
## Non-Goals
- Do not make analytics facts the source of truth for rollout trace.
- Do not make rollout trace raw events the source of truth for analytics.
- Do not encode downstream event names such as `codex_turn_event` or
`codex.tool_result` into the canonical taxonomy.
- Do not replace native spans with `SpanStarted` / `SpanEnded` observations.
- Do not require full rollout trace graph equivalence before the first version;
rollout trace is experimental and can use lighter validation initially.
## Shape
```text
codex-core / app-server
emit observations
codex-observability
observation traits, field metadata, sink policy, event definitions
codex-observability-derive
derive macro for field metadata and visitors
sinks/reducers
analytics projection -> TrackEventRequest
rollout trace projection -> local trace bundle + reduced graph
OTEL event projection -> current log and trace-safe event names
OTEL metric projection -> current counters and histograms
ringbuffer -> recent rich local observations
native tracing spans
remain direct tracing instrumentation
```
The derive crate exists because Rust does not expose struct field attributes at
runtime. The public API can still re-export the derive from
`codex-observability` so most callsites only import one crate.
## Why Not Just OTEL?
An alternative is to emit semantic facts as ordinary `tracing` events and let a
local subscriber, OTEL exporter, analytics reducer, or trace reducer all consume
the same event stream. That is attractive because Codex already uses
`tracing`, and local JSON event capture is easy to wire up.
We should still avoid making OTEL/tracing events the canonical schema:
- `tracing` fields are stringly typed at the reducer boundary. Renames or type
changes become runtime reducer failures instead of Rust compile errors.
- OTEL trace-safe export has a different privacy posture than local diagnostic
traces. Rich local fields such as tool output, prompts, model payloads, and
terminal output should not share a target with remotely exported span events.
- Field-level policy needs metadata that `tracing` does not preserve in a
structured way. We need to know both detail level and data class before a
sink serializes a field.
- Analytics conformance is easier from typed observations than from flattened
JSON logs, because reducers can match on Rust event types and fields.
- Spans and semantic facts have different lifecycles. Spans model active scope
and context propagation; observations model facts that happened.
OTEL should therefore consume observations for semantic events and derived
metrics, while native `tracing` remains the right tool for spans, context
propagation, and exporter plumbing. A local trace sink can still write JSONL
bundles; it should write observation envelopes rather than raw
`tracing_subscriber` event JSON.
## Observation API
The shared schema API can be small:
```rust
pub trait Observation {
const NAME: &'static str;
fn visit_fields<V: ObservationFieldVisitor>(&self, visitor: &mut V);
}
pub trait ObservationFieldVisitor {
fn field<T: serde::Serialize + ?Sized>(
&mut self,
name: &'static str,
meta: FieldMeta,
value: &T,
);
}
pub trait ObservationSink {
fn observe<E: Observation>(&self, event: &E);
}
```
Example:
```rust
use codex_observability::Observation;
#[derive(Observation)]
#[observation(name = "turn.config_resolved", uses = ["analytics"])]
struct TurnConfigResolved<'a> {
#[obs(level = "basic", class = "identifier")]
thread_id: &'a str,
#[obs(level = "basic", class = "identifier")]
turn_id: &'a str,
#[obs(level = "basic", class = "operational")]
model: &'a str,
}
```
The derive only exposes metadata. It should not implement analytics mapping,
trace persistence, redaction, schema generation, or export behavior.
## Callsite API
Product/runtime code should usually call semantic helper methods rather than
inline-constructing large observation structs. Direct struct construction is
fine in tests, very small leaf events, or helper internals, but it should not be
the normal instrumentation style.
The helper layer should do the tedious, failure-prone work near the callsite:
extract common IDs, join session/thread/turn context, compute durations,
normalize status and error fields, avoid trace-only allocations when no sink
can read them, and then emit the typed observation.
Example shape:
```rust
observability.turn().config_resolved(&session, &turn_context, &input).await;
observability.tool().ended(&invocation, status, &response);
observability.transport().api_request_completed(&request, &outcome);
```
This mirrors the useful part of the existing systems:
- rollout trace has `RolloutTraceRecorder::record_*` helpers
- analytics has `AnalyticsEventsClient::track_*` helpers
- OTEL has `SessionTelemetry::record_*` helpers
The helper layer must not become a second taxonomy. It is ergonomic glue over
canonical observation structs. Prefer small domain-specific helper groups such
as turn, tool, inference, transport, compaction, and feature usage over one
large recorder with every method in the system.
## Rich Payloads
Start simple. Ordinary observation fields should be eager values or borrowed
references. Sinks should reject fields by metadata before serializing them, so
analytics and OTEL metrics do not inspect trace/content fields.
Some trace fields point at large objects that usually already exist at the
callsite: model requests, model responses, tool invocations, tool results, or
terminal output. Prefer passing borrowed serializable views of those objects to
the observation helper rather than building `serde_json::Value` eagerly.
Do not introduce a generalized lazy-field system in the first implementation.
Closures such as `|| build_trace_payload(...)` are useful when constructing the
trace view itself is expensive, but they add API complexity. Treat them as a
later optimization for measured hot spots, not as the default observation
style.
## Field Policy
Filtering must be field-level. Event-level labels such as basic/detailed/trace
are too coarse because one event often contains both remote-safe counters and
local-only content.
Each field should carry at least:
- **Use markers**: exact projections intended to consume the field, for example
`analytics`, `otel`, or `rollout_trace`.
- **Detail level**: `basic`, `detailed`, or `trace`.
- **Data class**: `identifier`, `operational`, `environment`, `content`, or
`secret_risk`.
Use markers express intent. Detail level and data class are guardrails. A sink
must first select only fields explicitly marked for that sink, then enforce its
detail/class policy before serializing values.
Event structs may define default use markers when most fields feed the same
projection; field-level use markers override that default for mixed events.
Detail level is not privacy by itself. A tiny field can still be unsafe for
remote export, and a trace-level field can be trace-level because it is large
rather than sensitive. Data class is also not enough for selection: analytics
must not consume every basic operational field just because it would be safe.
Expected sink policies:
- Analytics selects only fields marked `analytics`, then allows basic
identifiers/operational fields plus selected environment fields; it denies
content and secret-risk fields even if they are marked accidentally.
- Rollout trace allows rich local fields, with explicit redaction rules for
secret-risk material.
- OTEL logs preserve today's log export behavior, including account/email only
where today's policy allows them.
- OTEL trace-safe events prefer lengths, counts, status, timing, and coarse
categories over content.
- OTEL metrics select exact metric dimensions and then apply the OTEL policy.
- Feedback upload applies an explicit user-approved policy over the ringbuffer.
## Event Taxonomy
The taxonomy should be designed from Codex workflows, not from existing
destination schemas. Names should describe facts that occurred in the system:
`turn.ended`, `tool_call.ended`, `transport.api_request_completed`.
Rules:
- Prefer stable domain facts over analytics, OTEL, or trace storage names.
- Model lifecycle facts explicitly when downstream reducers need ordering or
duration.
- Keep event count small until a consumer needs a distinct fact.
- Put richness in fields, not in parallel event variants.
- Add transport/runtime facts when they are first-class telemetry today, even
if analytics ignores them.
- Keep an escape hatch only for local experimental tracing, not remote export.
Initial workflow coverage should be chosen by conformance need:
| Workflow | Canonical examples | Primary consumers |
| --- | --- | --- |
| Session/thread | `session.config_resolved`, `thread.started`, `thread.ended` | analytics, OTEL, rollout |
| Turn lifecycle | `turn.requested`, `turn.started`, `turn.ended`, `turn.token_usage_observed` | analytics, OTEL, rollout |
| Turn timing | `turn.first_token_observed`, `turn.first_message_observed` | OTEL metrics, rollout |
| Model I/O | `inference.started`, `inference.sse_event_observed`, `inference.completed`, `inference.failed` | OTEL, rollout |
| Tools | `tool_call.started`, `tool_call.approval_resolved`, `tool_call.ended` | OTEL, rollout |
| Compaction | `compaction.started`, `compaction.installed`, `compaction.ended` | analytics, rollout |
| Agents | `agent.task_sent`, `agent.message_sent`, `agent.result_delivered`, `agent.closed` | rollout, analytics subset |
| Product features | `skill.invoked`, `app.used`, `plugin.state_changed`, `guardian.review_completed` | analytics |
| Transport/auth | `transport.api_request_completed`, `transport.websocket_request_completed`, `auth.recovery_step_completed` | OTEL |
This table is not a complete schema. Each event still needs a typed Rust
definition with field annotations before implementation.
## Projections
### Analytics
Analytics becomes a reducer over observations. Existing product events remain
output schemas.
| Observations | Analytics output |
| --- | --- |
| `thread.started` | `codex_thread_initialized` |
| turn config/lifecycle/token observations | `codex_turn_event` |
| `turn.steer_resolved` | `codex_turn_steer_event` |
| compaction lifecycle observations | `codex_compaction_event` |
| skill/app/plugin/guardian observations | existing feature events |
The analytics reducer continues to own batching, deduplication, connection
metadata joins, and product event naming.
### Rollout Trace
Rollout trace consumes observations and decides which fields become trace
payloads. Trace storage details such as `RawPayloadRef` should remain a trace
implementation detail, not part of the shared event definitions.
Example: `inference.completed` can include response material as a trace-level
field. The trace sink may write it to `payloads/*.json`; analytics and OTEL
metrics never read it.
### OTEL
OTEL consumes observations for every semantic fact and derived metric. The
projection preserves existing exported names while removing separate callsite
event definitions.
| Observations | OTEL output |
| --- | --- |
| session/thread observations | `codex.conversation_starts`, `codex.thread.started` |
| `turn.input_received` | `codex.user_prompt` |
| `tool_call.approval_resolved` | `codex.tool_decision` |
| `tool_call.ended` | `codex.tool_result`, tool count/duration metrics |
| transport observations | API/websocket events and count/duration metrics |
| `auth.recovery_step_completed` | `codex.auth_recovery` |
| inference/SSE observations | `codex.sse_event`, SSE duration, response timing metrics |
| turn timing/token observations | TTFT, TTFM, E2E duration, token usage metrics |
Log-only and trace-safe OTEL variants can be emitted from the same observation
by applying different field policies.
## Conformance Testing
The highest-value equivalence target is analytics because it is existing
product behavior.
```text
same E2E Codex run
legacy analytics facts -> current AnalyticsReducer -> TrackEventRequest JSON
observations -> analytics observation reducer -> TrackEventRequest JSON
assert exact equality after stable JSON normalization
```
At least one conformance path should apply exact analytics use markers plus the
analytics guardrail policy, so missing markers and unsafe annotations fail
tests. Recommended first scenarios:
- thread start
- normal turn
- failed or interrupted turn
- accepted and rejected turn steering
- compaction
- skill, app, plugin, and guardian events
- subagent thread start
Rollout trace can start with observation-to-trace unit tests plus one or two
end-to-end smoke tests that write and reduce a bundle. Full graph equivalence
is not necessary for the initial experimental rollout feature.
OTEL should get projection tests that compare exported event names, fields,
metric names, and tags for representative observations. Native span tests
should remain in the tracing layer.
## Implementation Stages
Build the proof of life on a fresh `main` branch, but keep commits separable so
the working branch can later be split into reviewable PRs.
Each stage should leave a small, documented API surface and a passing local
verification gate. Non-essential behavior should stay as a TODO until a later
stage needs it.
1. **Observation foundation**
- Add `codex-observability` and `codex-observability-derive`.
- Verify with crate-local tests that derived observations expose field
metadata and reject missing annotations at compile time.
- Run: `cargo test -p codex-observability`.
2. **Analytics conformance slice**
- Add the first typed observations and an analytics projection for one
existing analytics flow.
- Verify legacy analytics facts and observation-derived facts produce
identical `TrackEventRequest` JSON after stable normalization.
- Run: targeted `cargo test -p codex-analytics`.
3. **E2E analytics shadowing**
- Capture legacy facts and observations from the same core/app-server test
run for selected scenarios.
- Verify exact analytics output equality for thread start, normal turn,
failed/interrupted turn, steering, compaction, and one feature event.
- Run the targeted core/app-server integration tests added for each
scenario.
4. **Rollout trace projection**
- Add a rollout trace sink that consumes the same observations for
thread/turn, inference, and tool lifecycle.
- Verify with observation-to-trace unit tests and one smoke test that writes
and reduces a local bundle.
5. **OTEL projection proof**
- Add a small OTEL projection for a high-value event such as tool result.
- Verify exported event names, field policy, metric names, and tags match
the current behavior.
## Migration
Start with a shadow observation stream and conformance tests. Once analytics
equivalence is reliable, move callsites from direct analytics facts to
observations.
Rollout trace can adopt observations directly because it is experimental.
OTEL should be migrated by category rather than left permanently mixed:
tool/user/turn events, transport events, auth events, and derived metrics can
move behind the OTEL projection. Native spans and trace context stay where they
are.
## Open Questions
- What is the smallest useful field-class set?
- Should timestamps and sequence numbers live in the observation envelope or be
supplied by sinks?
- Where should fanout live so callsites do not grow `codex-core` further?
- How should borrowed trace-level payloads avoid hot-path allocations while
still allowing sinks to serialize when policy permits?
- Which direct OTEL counters are first-class observations, and which are
metrics derived from broader observations?