# 遥测与分析 · OpenTelemetry、埋点与反馈

> Codex 往外送数据的通道有四条：OpenTelemetry 的日志、追踪与指标，从 app-server 协议流量里归纳出的产品分析事件，只在用户执行 /feedback 并同意后才上传的诊断日志，以及只写本地的 rollout trace。本篇逐条讲它们采集什么、发到哪里、默认是否开启、用哪个配置关掉，以及代码里把“内容”和“计数”分开的几道闸。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/telemetry
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 遥测与分析 · OpenTelemetry、埋点与反馈

一个会读你代码、跑你命令的 agent，发出去哪些数据是信任问题。Codex 的相关代码分散在五个 crate 里：`codex-otel`（OpenTelemetry）、`codex-analytics`（产品分析）、`codex-feedback`（反馈上传）、`codex-rollout-trace`（本地排障）与小工具 `codex-response-debug-context`。它们的去向、默认值与开关各不相同，先看全貌：

| 通道 | 去向 | 默认 | 关闭方式 |
| --- | --- | --- | --- |
| OTLP 日志、追踪 | 你配置的 OTLP collector | 关 | 默认即关；`[otel]` 的 `exporter`、`trace_exporter` |
| OTLP 指标 | 内置的 Statsig 端点，或你的 collector | 发布版开 | `analytics.enabled = false` 或 `otel.metrics_exporter = "none"` |
| 产品分析事件 | ChatGPT 后端 `/codex/analytics-events/events` | 开 | `analytics.enabled = false` |
| 反馈 | Sentry | 用户执行 `/feedback` 时才发 | `feedback.enabled = false` |
| rollout trace | 本地目录 | 关 | 不设 `CODEX_ROLLOUT_TRACE_ROOT` 即可 |

配置键的完整说明见手册的[配置项速查](https://daiw.net/manual/codex/config-reference)。

```mermaid
flowchart LR
  EV[各 crate 的 tracing 事件与 span] --> LG[OTLP 日志<br/>默认关闭]
  EV --> TR[OTLP 追踪<br/>默认关闭]
  EV --> RING[反馈环形缓冲<br/>4 MiB 内存]
  RING -- 执行 /feedback 并同意 --> SENTRY[Sentry]
  ST[SessionTelemetry 计数与耗时] --> MT[OTLP 指标<br/>默认发往 Statsig]
  AS[app-server 协议流量] --> RED[AnalyticsReducer] --> AN[ChatGPT 后端<br/>产品分析事件]
  ENV[设了 CODEX_ROLLOUT_TRACE_ROOT] --> TB[本地 trace bundle]
```

## OpenTelemetry：一套 tracing，三路出口

Codex 全程用 `tracing` crate 打点，`codex-otel` 的 `OtelProvider` 在订阅者上挂几层过滤器，决定哪些事件进哪条 OTLP 管道。约定靠 target 前缀：`codex_otel.log_only` 一类只进日志，`codex_otel.trace_safe` 一类只进追踪；追踪另外收下所有 span（排除 HTTP/2 库 `h2` 自己的 span，免得导出请求又产生导出）。会话级的埋点集中在 `SessionTelemetry`，它总是成对地发：日志一份、追踪一份，两份的公共字段不同。日志这份长这样：

```rust
macro_rules! log_event {
    ($self:expr, $($fields:tt)*) => {{
        tracing::event!(
            target: $crate::targets::OTEL_LOG_ONLY_TARGET,
            tracing::Level::INFO,
            $($fields)*
            event.timestamp = %$crate::events::shared::timestamp(),
            conversation.id = %$self.metadata.conversation_id,
            app.version = %$self.metadata.app_version,
            auth_mode = $self.metadata.auth_mode,
            originator = %$self.metadata.originator,
            user.account_id = $self.metadata.account_id,
            user.email = $self.metadata.account_email,
            terminal.type = %$self.metadata.terminal_type,
            model = %$self.metadata.model,
            slug = %$self.metadata.slug,
        );
    }};
}
```

（`codex-rs/otel/src/events/shared.rs:14`）

紧接着的 `trace_event!` 换成 `OTEL_TRACE_SAFE_TARGET`，并且去掉了 `user.account_id` 与 `user.email` 两个字段。资源属性也是这个思路：日志带主机名 `host.name`，追踪不带。内容字段再多一道闸，用户提示词就是例子：

```rust
        let prompt_to_log = if self.metadata.log_user_prompts {
            prompt.as_str()
        } else {
            "[REDACTED]"
        };

        log_event!(
            self,
            event.name = "codex.user_prompt",
            prompt_length = %prompt.chars().count(),
            prompt = %prompt_to_log,
        );
        trace_event!(
            self,
            event.name = "codex.user_prompt",
            prompt_length = %prompt.chars().count(),
            text_input_count = text_input_count as i64,
            image_input_count = image_input_count as i64,
            local_image_input_count = local_image_input_count as i64,
        );
```

（`codex-rs/otel/src/events/session_telemetry.rs:1144`）

提示词原文只可能出现在日志里，而且要 `otel.log_user_prompt = true`；追踪里永远只有长度与条数。同样需要显式打开的还有 `log_agent_responses`（主代理与子代理的最终回复）和 `log_guardian_assessments`（[自动审查](https://daiw.net/manual/codex-source/guardian)的理由），两者还要求日志出口确实是 OTLP，每条截到 64 KiB。追踪上下文按 W3C 标准传播：进程启动时读 `TRACEPARENT`、`TRACESTATE` 环境变量接上父级追踪，对外请求与 [exec-server](https://daiw.net/manual/codex-source/exec-server) 中继帧里也带着 `traceparent`。

## 指标：默认开着的那一路

日志与追踪出口默认都是 `none`，指标出口默认却是 `statsig`。它指向一个内置目的地：

```rust
pub(crate) fn resolve_exporter(exporter: &OtelExporter) -> OtelExporter {
    match exporter {
        OtelExporter::Statsig => {
            // Keep the built-in Statsig default off in debug builds so
            // incremental local development and test runs do not emit
            // best-effort OTEL traffic unless a test or binary opts into an
            // explicit exporter configuration.
            if cfg!(debug_assertions) {
                return OtelExporter::None;
            }

            OtelExporter::OtlpHttp {
                endpoint: STATSIG_OTLP_HTTP_ENDPOINT.to_string(),
                headers: HashMap::from([(
                    STATSIG_API_KEY_HEADER.to_string(),
                    STATSIG_API_KEY.to_string(),
                )]),
                protocol: OtelHttpProtocol::Json,
                tls: None,
            }
        }
        _ => exporter.clone(),
    }
}
```

（`codex-rs/otel/src/config.rs:13`）

也就是发布版里，指标以 OTLP/HTTP JSON 发往 `https://ab.chatgpt.com/otlp/v1/metrics`；自己编译的 debug 版不发。指标出口还受 `analytics.enabled` 管，这是在内核组装 `OtelSettings` 时决定的：

```rust
    let exporter = to_otel_exporter(&config.otel.exporter);
    let trace_exporter = to_otel_exporter(&config.otel.trace_exporter);
    let metrics_exporter = if config
        .analytics_enabled
        .unwrap_or(default_analytics_enabled)
    {
        to_otel_exporter(&config.otel.metrics_exporter)
    } else {
        OtelExporter::None
    };
```

（`codex-rs/core/src/otel_init.rs:68`）

`default_analytics_enabled` 由入口决定：终端界面与 `codex exec` 传 `true`，`codex app-server` 默认 `false`、由宿主加 `--analytics-default-enabled` 打开，exec-server 传 `false`。

指标本身都是计数与耗时：`codex.turn.e2e_duration_ms`、`codex.turn.ttft.duration_ms`、`codex.tool.call`、`codex.hooks.run` 等名字集中在 `codex-rs/otel/src/metrics/names.rs`。标签只有认证方式、会话来源、`originator`、服务名、模型、版本几项，其中 `originator` 不在已知名单里就记成 `other`，避免基数失控；资源属性是服务名、版本、环境以及操作系统类型与版本。还有一份 `STATSIG_DISABLED_METRICS` 名单，API 调用次数、工具调用次数与耗时、token 用量、费用等指标不走内置的 Statsig 出口，但照样发给你自己配置的 OTLP collector。

## 产品分析：从协议流量里归纳事件

第二条通道是 `codex-analytics`，它的主体不是散落各处的打点，而是站在 app-server 上看协议流量。`AnalyticsEventsClient` 把 `initialize`、客户端请求与响应、服务端通知，以及协议里没有、由内核与 app-server 专门上报的少量“事实”（技能调用、插件安装、钩子运行、压缩等）塞进一个容量 256 的队列；后台任务里的 `AnalyticsReducer` 把这些零散事实归纳成产品事件——一轮对话的汇总、一次命令执行、一次文件修改、一次 MCP 调用……再批量 POST 到 `{chatgpt_base_url}/codex/analytics-events/events`，超时 10 秒。队列满了就丢弃并记一条警告，不阻塞主流程。

发送前还要看凭据：没有登录就不发；用 API key 登录时只保留带插件 ID 的少数几类事件（`can_send_with_api_key_auth`）；ChatGPT 这类走 Codex 后端的认证才发全部。事件里放的是配置、计数、耗时与 token 数。以命令执行为例，记录的是退出码、耗时、审批次数，以及读文件、列目录、搜索等动作各有几个，不含命令原文。“采纳的代码行”事件只报新增、删除的行数，外加仓库指纹 `repo_hash`——对 origin 远端 URL 规范化后做的 SHA-1；结构体里为兼容下游保留着 `line_fingerprints` 字段，但注释写明它恒为空，逐行指纹已经不再生成（`codex-rs/analytics/src/events.rs:189`）。

注意两个开关的差别：各入口的默认值只作用于指标；产品分析事件只看配置，没有写 `analytics.enabled = false` 就开着。

## 反馈：只在你按下 /feedback 时上传

`codex-feedback` 在内存里维护一个 4 MiB 的环形缓冲（`DEFAULT_MAX_BYTES`），不管 `RUST_LOG` 怎么设，都按下面的过滤规则收一份诊断日志：

```rust
            .with_filter(
                Targets::new()
                    .with_default(Level::TRACE)
                    // Opted-in content belongs to the configured OTLP destination, not feedback.
                    .with_target("codex_otel.log_only", LevelFilter::OFF)
                    .with_target("codex_http_client::transport", LevelFilter::DEBUG)
                    .with_target("codex_api::sse", LevelFilter::DEBUG)
                    // `tracing-log` checks legacy log records against their original
                    // target before re-emitting them as `log`; tungstenite TRACE
                    // includes full websocket frames and authenticated handshakes.
                    .with_target("tungstenite", LevelFilter::DEBUG)
                    .with_target("codex_api::responses_websocket_timing", LevelFilter::OFF)
                    .with_target("codex_core::post_sampling_token_estimate", LevelFilter::OFF),
            )
```

（`codex-rs/feedback/src/lib.rs:250`）

OTLP 日志那一路的内容字段不进缓冲；HTTP 传输、SSE、WebSocket 帧这些会带请求体与握手凭据的目标被压到 DEBUG。缓冲只在内存里，直到用户执行 `/feedback`：app-server 的 `feedback/upload` 先检查 `feedback.enabled`，同时最多 3 个上传；选择附带日志时，除了这份缓冲，还会附上所报线程及其子代理的 rollout 文件、自动审查的 rollout、本地日志库里的相关日志，Windows 上还有沙箱日志——这些文件里有完整的对话内容。上传走 Sentry 的 envelope 接口，`FeedbackUploadOptions` 的注释特意说明，是否附带日志由调用方在征得用户同意后决定，这个类型只负责按决定上传。

出错时的上下文由 `codex-response-debug-context` 提供：它从失败响应的头里取 `x-request-id`（或 `x-oai-request-id`）、`cf-ray`、`x-openai-authorization-error`，并解码 `x-error-json` 里的错误码，作为反馈标签和遥测字段；写进遥测的错误信息则只到“http 401”“rate limit exceeded”这种粒度。

## 本地排障：rollout trace

最后一条通道不出本机。`codex-rollout-trace` 的 README 开头就声明它不是遥测：只有设置了 `CODEX_ROLLOUT_TRACE_ROOT`，Codex 才在那个目录下写 trace bundle，而且从不上传。bundle 由 `manifest.json`、按序追加的 `trace.jsonl` 和存放大块原始数据的 `payloads/` 组成，里面有提示词、模型回复、工具输入输出和终端输出，要当敏感数据对待。设计思路是“先观察，后解释”：热路径只写有序的原始事件，离线的 `replay_bundle` 再把它们归约成 `state.json`——一张包含线程、模型实际看到的对话、推理调用、工具调用、终端与代理间交互的图。隐藏命令 `codex debug trace-reduce` 就是做这一步的。

## 和《从 LLM 到 Coding Agent》对照

[成本追踪](https://daiw.net/manual/llm-to-agent/cost-tracking)那篇只在进程里累加 token 与费用。Codex 的指标同样有 `codex.turn.token_usage` 与 `codex.turn.cost_microusd`（后者的金额不在本地计算，而是 app-server 的 `TurnCostWorker` 每 150 秒向后端的 `analytics/codex/turn-costs` 查询得来，见 `codex-rs/app-server/src/turn_cost_worker.rs:25`），但它们属于一整套对外通道，于是多出了“发给谁、默认开不开、内容与计数怎么分开”这些问题。对照 [Grok Build 的遥测](https://daiw.net/manual/grok-build/telemetry-usage)：那边默认档位是全关，企业外部遥测要双重开启；Codex 的发布版默认上报指标与产品分析事件，控制点收拢在 `analytics.enabled` 一个键上，内容类字段（提示词、回复、审查理由）则逐项默认关闭。

---

上一篇：[实时语音 · 语音对话的实现](https://daiw.net/manual/codex-source/realtime-voice) · 下一篇：[Codex Cloud、代码审查与 worktree](https://daiw.net/manual/codex-source/cloud-and-review)
