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

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

作者 David更新于 第 52 篇(共 57 篇)

遥测与分析 · 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

本页目录