18 KiB
Observability Event Stream
Codex currently has three overlapping observability systems:
codex-rs/analyticsreduces runtime facts into remote product analytics.codex-rs/rollout-tracerecords rich local diagnostic traces and reduces them into a replay/debug graph.codex-rs/otelemits logs, trace-safe events, and metrics, while nativetracingspans 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.
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_eventorcodex.tool_resultinto the canonical taxonomy. - Do not replace native spans with
SpanStarted/SpanEndedobservations. - Do not require full rollout trace graph equivalence before the first version; rollout trace is experimental and can use lighter validation initially.
Shape
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.
Typed observation events are internal source types, not exported wire schemas. Their compatibility boundary is the reducer output: analytics event JSON, OTEL exports, rollout trace bundles, or any other persisted/shared destination schema. This keeps the event taxonomy easy to rename, split, merge, or reshape in the same PR that updates its reducers, while preserving compatibility where it matters.
If raw observations are ever persisted or consumed across process/version boundaries, they should be treated as versioned schemas instead.
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:
tracingfields 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
tracingdoes 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:
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:
use codex_observability::Observation;
#[derive(Observation)]
#[observation(name = "turn.started", uses = ["analytics"])]
struct TurnStarted<'a> {
#[obs(level = "basic", class = "identifier")]
thread_id: &'a str,
#[obs(level = "basic", class = "identifier")]
turn_id: &'a str,
#[obs(level = "basic", class = "operational")]
started_at: i64,
}
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:
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, orrollout_trace. - Detail level:
basic,detailed, ortrace. - Data class:
identifier,operational,environment,content, orsecret_risk.
Use markers are the allow marker: a sink should consume only fields explicitly marked for that sink. Detail level and data class are descriptive metadata for review, auditing, and sinks that need coarse verbosity or redaction modes. 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 behavior:
- Analytics consumes fields marked
analytics. Content-bearing analytics fields should be rare and called out on the field or projection that exports them. - 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 |
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 | app.mentioned, app.used, hook.run_completed, plugin.used, plugin.state_changed, 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.started with resolved config, turn.ended with token usage |
codex_turn_event |
turn.steer |
codex_turn_steer_event |
| compaction lifecycle observations | codex_compaction_event |
| skill/app/plugin/review observations | existing feature events |
review.completed is generic, but the current analytics projection only emits
the legacy guardian event for guardian reviewer responses. User reviewer
responses remain represented in the observation stream until a consumer needs
an analytics output for them.
The analytics crate should keep the observation-to-legacy-schema translation in a private projection module. The reducer then stays responsible for ingestion orchestration, batching, deduplication, connection metadata joins, and product event naming rather than becoming a mixed bag of mapping code.
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.
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 review 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.
-
Observation foundation
- Add
codex-observabilityandcodex-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.
- Add
-
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
TrackEventRequestJSON after stable normalization. - Run: targeted
cargo test -p codex-analytics.
-
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.
-
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.
-
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-corefurther? - 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?