遥测与分析 · OpenTelemetry、埋点与反馈
Codex 往外送数据的通道有四条:OpenTelemetry 的日志、追踪与指标,从 app-server 协议流量里归纳出的产品分析事件,只在用户执行 /feedback 并同意后才上传的诊断日志,以及只写本地的 rollout trace。本篇逐条讲它们采集什么、发到哪里、默认是否开启、用哪个配置关掉,以及代码里把“内容”和“计数”分开的几道闸。
遥测与分析 · 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 即可 |
配置键的完整说明见手册的配置项速查。
OpenTelemetry:一套 tracing,三路出口
Codex 全程用 tracing crate 打点,codex-otel 的 OtelProvider 在订阅者上挂几层过滤器,决定哪些事件进哪条 OTLP 管道。约定靠 target 前缀:codex_otel.log_only 一类只进日志,codex_otel.trace_safe 一类只进追踪;追踪另外收下所有 span(排除 HTTP/2 库 h2 自己的 span,免得导出请求又产生导出)。会话级的埋点集中在 SessionTelemetry,它总是成对地发:日志一份、追踪一份,两份的公共字段不同。日志这份长这样:
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,追踪不带。内容字段再多一道闸,用户提示词就是例子:
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(自动审查的理由),两者还要求日志出口确实是 OTLP,每条截到 64 KiB。追踪上下文按 W3C 标准传播:进程启动时读 TRACEPARENT、TRACESTATE 环境变量接上父级追踪,对外请求与 exec-server 中继帧里也带着 traceparent。
指标:默认开着的那一路
日志与追踪出口默认都是 none,指标出口默认却是 statsig。它指向一个内置目的地:
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 时决定的:
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 怎么设,都按下面的过滤规则收一份诊断日志:
.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》对照
成本追踪那篇只在进程里累加 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 的遥测:那边默认档位是全关,企业外部遥测要双重开启;Codex 的发布版默认上报指标与产品分析事件,控制点收拢在 analytics.enabled 一个键上,内容类字段(提示词、回复、审查理由)则逐项默认关闭。
上一篇:实时语音 · 语音对话的实现 · 下一篇:Codex Cloud、代码审查与 worktree