夜雨聆风学习资料网

ARTICLE · 1324663

Codex 源码-遥测与分析——OTEL + Analytics

Codex 源码-遥测与分析——OTEL + Analytics

Codex 源码解析系列

第 44 讲:遥测与分析——OTEL + Analytics

基于 OpenAI Codex 源码 · 2026-11-07

💡 本讲一句话:Codex 的遥测不是一根管子,而是两条并行管线——otel crate 把日志/追踪/指标发到 OTLP 端点,analytics crate 把产品事件聚合后 POST 到 ChatGPT 后端。核心设计是"PII 隔离":用户 prompt、账号邮箱、工具输出只进 log_only 通道;trace_safe 通道只带计数和时长。本讲逐行拆解双目标事件宏、[REDACTED] 脱敏开关、字符边界截断、直方图桶边界、mTLS 导出,以及"队列满了宁可丢也不阻塞"的有界设计。

一、全景:一个仓库里的两条遥测管线

前几讲我们拆过 Codex 的执行核心——工具调用、沙箱、多智能体。但每次 codex 跑起来,背后都有两条数据流在悄悄工作:一条是标准的 OpenTelemetry(日志 + 追踪 + 指标),另一条是产品分析事件。它们分属两个 crate:codex-rs/otel(约 5.9K 行)和 codex-rs/analytics(约 7.4K 行)。为什么不用一套系统干两件事?先看对照:

otel vs analytics:两条管线对照

数据类型

OTEL 日志 / 追踪 span / 指标(OTLP 标准信号)Analytics 产品行为事件:turn、工具调用、插件、Guardian…

目的地

OTEL 可配置 OTLP gRPC/HTTP 端点;内置 Statsig(ab.chatgpt.com)Analytics ChatGPT 后端 POST /codex/analytics-events/events

PII 策略

OTEL 双通道:log_only 可带账号 ID/邮箱/prompt;trace_safe 只带计数与时长Analytics 事实(fact)是协议级元数据,不含 prompt 正文

状态性

OTEL 无状态,事件发生即上报Analytics reducer 把零散事实聚合成完整 turn 级事件再上报

默认开关

OTEL metrics_exporter=statsig 默认开;log/trace exporter 默认关Analytics 跟随 analytics.enabled(用户可整体关闭)

两条管线的分工逻辑很清晰:otel 面向"系统"——延迟、错误率、token 用量这类工程指标,走开放标准,企业可以指向自己的 OTLP collector;analytics 面向"产品"——用户用了什么功能、turn 成功还是失败,聚合后回传 OpenAI。正因为目的地和受众不同,PII 策略也必须分开设计——这就是本讲的核心。

二、双目标事件:log_only 与 trace_safe

otel crate 里所有遥测事件都通过 tracing::event! 发出,但每个事件必须声明自己属于哪个"目标(target)"。整个隔离机制的地基只有 11 行代码——otel/src/targets.rs:

📄 codex-rs/otel/src/targets.rs (第 1-11 行)

pub(crate) const OTEL_TARGET_PREFIX: &str = "codex_otel"; // 所有遥测目标共享这个前缀,导出器靠它判断事件归属pub(crate) const OTEL_LOG_ONLY_TARGET: &str = "codex_otel.log_only"; // log-only 通道:可含 PII(账号 ID、邮箱),只进日志导出器pub(crate) const OTEL_TRACE_SAFE_TARGET: &str = "codex_otel.trace_safe"; // trace-safe 通道:禁止 PII,可随追踪一起导出pub(crate) fn is_log_export_target(target: &str) -> bool { // 判断某事件目标是否属于日志导出器    target.starts_with(OTEL_TARGET_PREFIX) && !is_trace_safe_target(target) // 前缀匹配且不是 trace_safe——trace_safe 是前缀的子集,必须显式排除}pub(crate) fn is_trace_safe_target(target: &str) -> bool { // 判断某事件目标是否允许进追踪通道    target.starts_with(OTEL_TRACE_SAFE_TARGET) // 只有这个前缀的事件允许进追踪(trace_safe.summary 等子目标也算)}

注意 is_log_export_target 里那个 && !is_trace_safe_target(...):因为 codex_otel.trace_safe 本身也以 codex_otel 开头,如果不排除,trace-safe 事件会被日志导出器"顺路"再收一遍。这两个谓词在 provider.rs(第 337-350 行)被用作两个导出器的过滤器:log_export_filter 只放行 log 目标,trace_export_filter 对非 span 事件只放行 trace_safe 目标。同一条 tracing 事件流,按 target 前缀被劈成两路,分别去不同的端点——这就是"PII 隔离"的运行时机制。

事件侧由两个宏生成。看 otel/src/events/shared.rs(第 14-32 行)的 log_event!:

📄 codex-rs/otel/src/events/shared.rs (第 14-32 行)

macro_rules! log_event { // log-only 事件:允许携带 account_id / email 等 PII 字段    ($self:expr, $($fields:tt)*) => {{ // 宏参数:telemetry 实例 + 调用方字段列表,展开成 tracing::event!        tracing::event!(            target: $crate::targets::OTEL_LOG_ONLY_TARGET, // 钉死在 log_only 目标——这是与 trace_event! 的关键差异            tracing::Level::INFO, // 级别固定 INFO:遥测事件不参与日志级别过滤            $($fields)* // 展开调用方传入的自定义字段(如 event.name、prompt)            event.timestamp = %$crate::events::shared::timestamp(), // RFC3339 毫秒时间戳,下游解析格式统一            conversation.id = %$self.metadata.conversation_id, // 线程 ID:串联本会话所有事件的主键            app.version = %$self.metadata.app_version, // 客户端版本(编译期 CARGO_PKG_VERSION)            auth_mode = $self.metadata.auth_mode, // 认证模式已粗化为 ApiKey/Chatgpt——注意这里没有原始凭据            originator = %$self.metadata.originator, // 调用方标识(CLI/IDE/插件),构造时已做 sanitize            user.account_id = $self.metadata.account_id, // ⚠️ PII:账号 ID 只出现在日志通道            user.email = $self.metadata.account_email, // ⚠️ PII:邮箱也只出现在日志通道            terminal.type = %$self.metadata.terminal_type, // 终端类型(user-agent)            model = %$self.metadata.model, // 模型名            slug = %$self.metadata.slug, // 模型别名        );    }};}

为什么这样设计?对比同文件的 trace_event!(第 34-50 行):字段几乎一样,唯独少了 user.account_id 和 user.email 两行,target 换成 trace_safe。也就是说 Codex 不是"事后过滤 PII",而是在事件定义层面就把敏感字段排除在追踪通道之外——trace 数据通常流向更多系统(APM、跨服务关联),攻击面更大;日志则留在受控的日志后端。用宏而不是函数来生成,是因为 tracing::event! 本身是宏,字段列表必须在编译期展开。

三、user_prompt:[REDACTED] 脱敏开关

最能体现这套隔离机制的是用户输入事件。看 otel/src/events/session_telemetry.rs(第 1163-1204 行)的 user_prompt():

📄 codex-rs/otel/src/events/session_telemetry.rs (第 1163-1204 行)

pub fn user_prompt(&self, items: &[UserInput]) { // 用户输入上报入口,turn 开始时调用    let prompt = items // 把输入项里的文本拼成完整 prompt,用于长度统计        .iter() // 遍历所有输入项        .flat_map(|item| match item { // 逐项取出文本(其他类型跳过)            UserInput::Text { text, .. } => Some(text.as_str()), // 只收集文本片段            _ => None, // 图片/文件等其他类型不进 prompt 正文        })        .collect::<String>(); // 拼成完整字符串,用于长度统计    let text_input_count = items.iter().filter(|item| matches!(item, UserInput::Text { .. })).count(); // 文本输入条数——给 trace-safe 通道用    let image_input_count = items.iter().filter(|item| matches!(item, UserInput::Image { .. })).count(); // 远程图片条数    let local_image_input_count = items.iter().filter(|item| matches!(item, UserInput::LocalImage { .. })).count(); // 本地图片条数    let prompt_to_log = if self.metadata.log_user_prompts { // 配置开关 otel.log_user_prompt 决定是否记录正文(默认 false)        prompt.as_str() // 开关打开:记录完整正文    } else { // 开关关闭(默认):只留占位符        "[REDACTED]" // 默认:只留占位符,不泄露用户内容    };    log_event!( // 日志通道:opt-in 时可带完整 prompt        self, // telemetry 实例,宏内部取 metadata        event.name = "codex.user_prompt", // 事件名两通道一致,靠 target 区分归属        prompt_length = %prompt.chars().count(), // 长度永远上报——即使正文被脱敏也能做分布统计        prompt = %prompt_to_log, // 正文(或 [REDACTED])只进日志通道    );    trace_event!( // 追踪通道:只有计数,绝无内容        self, // telemetry 实例,宏内部取 metadata        event.name = "codex.user_prompt", // 事件名两通道一致,靠 target 区分归属        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.user_prompt,两个通道拿到的是不同形状的数据:日志侧可能有正文(且默认是 [REDACTED]),追踪侧只有长度和输入类型计数。注意 prompt.chars().count()——用字符数而不是字节数,因为下游做"用户 prompt 长度分布"统计时关心的是人读的长度。为什么默认脱敏?看 config/src/types.rs(第 695 行):log_user_prompt: false。用户 prompt 可能含密钥、内部文档,默认不记录是 fail-safe;企业想开就显式配置 otel.log_user_prompt = true。

围绕这条日志通道,OtelConfig(config/src/types.rs 第 676-690 行)一共暴露了五个内容类开关,默认值全部偏保守:

otel.* 内容类配置项(config/src/types.rs OtelConfig::default)

otel.log_user_prompt

默认值:false | 作用:用户 prompt 正文是否进日志通道,关闭时只报 [REDACTED] + 长度相关代码:session_telemetry.rs user_prompt()

otel.tool_result.max_bytes

默认值:2048 | 作用:工具结果预览的字节上限,按 UTF-8 字符边界截断相关代码:tool_result.rs telemetry_preview()

otel.log_sse_deltas

默认值:true | 作用:是否记录成功的 SSE .delta 事件;关闭后错误、完成事件和指标全部保留相关代码:session_telemetry.rs without_sse_delta_logs()

otel.log_agent_responses

默认值:false | 作用:opt-in 记录最终回答正文(64 KiB 上限、turn 内去重)相关代码:agent_response.rs AgentResponseLogger

otel.metrics_exporter

默认值:statsig | 作用:指标导出目的地;Statsig 跟随 analytics.enabled,自定义 OTLP 独立于它,none 关闭相关代码:config.rs resolve_exporter()

五个开关里只有 log_sse_deltas 默认开——因为它记录的是"流式事件到达的时序",不含用户内容;其余四个涉及正文的一律默认关。这是典型的 opt-in 设计:敏感数据要主动打开才上报,而不是默认全量。

四、工具结果预览:字符边界截断

工具输出往往比 prompt 更长(一次 exec 可能吐几千行)。日志通道只保留"预览",核心在 otel/src/tool_result.rs。先看事件发射(第 35-82 行节选):

📄 codex-rs/otel/src/tool_result.rs (第 35-82 行,节选)

pub(crate) fn emit_tool_result( // 发射诊断日志与 trace-safe 元数据,不改动工具调用指标    telemetry: &SessionTelemetry, // 会话遥测上下文(metadata + 开关)    limits: ToolResultLogConfig, // 预览字节上限(默认 2048)    event: ToolResultEvent<'_>, // 工具结果事件载荷(借用字段,零拷贝)) {    let preview = telemetry_preview(output, limits); // 先把 output 截成预览——日志通道只看前 N 字节    let tool_result_seq = next_tool_result_seq(); // 进程级单调递增序号(AtomicU64),跨会话也能排顺序    log_and_trace_event!( // 一次宏调用发两个事件:common 字段两边都有,各自还有私有字段        telemetry, // 传入遥测上下文,宏内部拆两路        common: { // 两通道共有:安全元数据            event.name = "codex.tool_result", // 事件名:工具结果(两通道共有)            tool_result_seq = tool_result_seq, // 进程级序号,跨会话可排序            tool_name = %tool_name.name, // 工具名(exec / apply_patch / mcp__...)            call_id = %call_id, // 调用 ID:与 tool_call 事件配对            duration_ms = %duration.as_millis(), // 执行耗时(毫秒)            success = %success, // 是否成功——错误率指标的基础            output_truncated = preview.truncated, // 是否被遥测上限截断(不是工具结果本身是否有损)        },        log: { // 仅日志通道:可能含内容            arguments = %arguments, // 工具入参(仅日志通道,可能含路径等敏感信息)            output = %preview.text, // 截断后的预览文本            mcp_server = %mcp_server, // MCP server 名(内置工具为空串)        },        trace: { // 仅追踪通道:只有长度和计数,绝无内容            arguments_length = arguments.len() as i64, // 入参字节长度——只有大小没有内容            output_length = output.len() as i64, // 输出字节长度            output_line_count = output.lines().count() as i64, // 输出行数:粗粒度规模信号            tool_origin = if mcp_server.is_empty() { "builtin" } else { "mcp" }, // 区分内置工具与 MCP 工具        },    );}

log_and_trace_event!(shared.rs 第 52-62 行)把 common/log/trace 三段字段分别拼进两个 tracing::event!——调用方只写一次事件定义,隔离规则由宏保证。注意 output_truncated 的注释:它标记的是"遥测上限截掉了文本",而不是"工具结果本身有损"——两个概念分开,下游分析才不会误判。

再看截断函数(第 91-111 行):

📄 codex-rs/otel/src/tool_result.rs (第 91-111 行)

pub(crate) fn telemetry_preview( // 把工具输出截成日志通道用的预览    content: &str, // 原始工具输出(可能很长)    limits: ToolResultLogConfig, // 截断上限配置) -> ToolResultPreview<'_> { // 返回预览文本 + 是否被截断    let prefix = take_bytes_at_char_boundary(content, limits.max_bytes); // 按 max_bytes 切,但回退到 UTF-8 字符边界——绝不把多字节字符劈成两半    if prefix.len() == content.len() { // 没超限 → 零拷贝借用原字符串,不分配内存        return ToolResultPreview { text: Cow::Borrowed(content), truncated: false }; // 直接借用原串,零分配返回    }    let separator = if prefix.is_empty() || prefix.ends_with('\n') { "" } else { "\n" }; // 若截在半行中间,先补换行,避免提示语粘在半个 token 后面    ToolResultPreview { // 超限分支:构造新字符串        text: Cow::Owned(format!("{prefix}{separator}[... telemetry preview truncated ...]")), // 追加截断标记:下游能区分"被遥测截断"和"输出本来就短"        truncated: true, // 标记"被遥测截断",供下游查询    }}

为什么这样设计?三个细节都是踩坑后的产物:(1)take_bytes_at_char_boundary——直接按字节切 UTF-8 字符串会产生非法序列,下游 JSON 序列化或日志系统会炸;(2)Cow::Borrowed——绝大多数工具输出短于 2 KiB,热路径零分配;(3) 显式的截断标记 + truncated 布尔位——让"数据不完整"本身成为可查询的元数据,而不是静默丢失。

五、指标:直方图桶边界是手工设计的

otel crate 的指标客户端(otel/src/metrics/client.rs,742 行)里最值得看的是直方图桶边界——它们不是自动生成的,而是按 Codex 的真实延迟分布手工排的(第 52-75 行节选):

📄 codex-rs/otel/src/metrics/client.rs (第 52-75 行,节选)

const MILLISECOND_DURATION_UNIT: &str = "ms"; // 时长指标的单位声明(OTLP 语义约定)const MILLISECOND_DURATION_BOUNDARIES: &[f64] = &[ // 直方图桶边界:预定义,控制基数(cardinality)    0.0, 5.0, 10.0, 25.0, 75.0, 100.0, 250.0, 500.0, 750.0, 1_000.0, // 毫秒级:覆盖绝大多数 API 调用 / 工具执行    1_250.0, 1_500.0, 1_750.0, 2_000.0, 3_000.0, 4_000.0, 5_000.0, // 秒级:慢请求    6_000.0, 7_500.0, 9_000.0, 12_000.0, 15_000.0, 20_000.0, // 十几秒:长工具调用    30_000.0, 60_000.0, 120_000.0, // 上限 2 分钟——再长的就该看 turn 级指标了];// 整轮(turn)可能累积数分钟的推理 + 工具执行 + 用户等待static TURN_CRITICAL_PATH_DURATION_BOUNDARIES: LazyLock<Vec<f64>> = LazyLock::new(|| { // turn 关键路径需要更长的桶,在基础边界上扩展    let mut bounds = MILLISECOND_DURATION_BOUNDARIES.to_vec(); // 复制基础边界作为起点    bounds.extend([180_000.0, 300_000.0, 600_000.0, 900_000.0, // 3/5/10/15 分钟:长 turn        1_800_000.0, 3_600_000.0, 7_200_000.0]); // 最长到 2 小时——turn 可能因用户审批而长时间挂起    bounds // 返回扩展后的完整边界列表});

为什么手工排桶?OTLP 直方图如果让后端自动分桶,不同环境会切出不同的边界,跨版本对比就废了;预定义边界保证"50ms-75ms 这个桶"在所有客户端上含义一致。而 turn 关键路径单独扩到 2 小时,是因为一个 turn 的生命周期里包含用户思考时间——等审批、等输入都可能挂起几十分钟,用 API 调用的边界去量它会把所有长 turn 挤进最后一个桶,分布信息全丢。另外 client.rs(第 138-150 行)的 counter 入口会先做 validate_metric_name,并拒绝负增量——指标名和 tag 值都过白名单校验,防止高基数垃圾数据把后端打爆。

六、OTLP 导出:mTLS 与"阻塞客户端的三种姿势"

指标和日志最终要发出去。企业端点常要求 mTLS(双向证书),otel/src/otlp.rs 里 build_http_client(第 74-92 行)处理了一个很隐蔽的问题——OTEL SDK 的导出器跑在专用 OS 线程上,不一定有 tokio runtime:

📄 codex-rs/otel/src/otlp.rs (第 74-92 行)

pub(crate) fn build_http_client( // 为 OTLP HTTP 导出器构建阻塞式客户端    tls: &OtelTlsConfig, // mTLS 配置(CA / 客户端证书)    timeout_var: &str, // 超时环境变量名) -> Result<reqwest::blocking::Client, Box<dyn Error>> { // 返回阻塞式 HTTP 客户端,失败带配置错误信息    if current_tokio_runtime_is_multi_thread() { // 情况1:在多线程 tokio runtime 里 → block_in_place 让出 worker 线程跑阻塞代码        tokio::task::block_in_place(|| build_http_client_inner(tls, timeout_var)) // 让出 worker 线程执行阻塞构建    } else if tokio::runtime::Handle::try_current().is_ok() { // 情况2:在 current-thread runtime 里(不能 block_in_place)→ 另起 OS 线程跑,join 取结果        let tls = tls.clone(); // 克隆配置移入新线程(Clone 廉价)        let timeout_var = timeout_var.to_string(); // 字符串化以便跨线程移动所有权        std::thread::spawn(move || build_http_client_inner(&tls, &timeout_var).map_err(|err| err.to_string())) // 新 OS 线程里构建,错误转 String 便于跨线程传递            .join() // 等子线程构建完成——客户端只建一次,这个等待发生在启动期            .map_err(|_| config_error("failed to join OTLP blocking HTTP client builder thread"))? // join 失败(线程 panic)→ 配置错误            .map_err(config_error) // 内层构建错误统一包装成配置错误    } else { // 情况3:完全不在 runtime 里 → 当前线程直接跑        build_http_client_inner(tls, timeout_var) // 无 runtime:当前线程直接构建    }}

为什么这样设计?reqwest::blocking 在 current-thread runtime 里直接调用会 panic(它内部要 block_on),而 block_in_place 又只能在多线程 runtime 用——所以必须分三种情况处理。同文件的 mTLS 配置(第 53-65、121-141 行)还强制"客户端证书和私钥必须成对出现",只给一个直接报错——半套证书在 TLS 握手里必然失败,不如启动时就 fail loud。

再看内置 Statsig 端点的解析(otel/src/config.rs 第 13-36 行):

📄 codex-rs/otel/src/config.rs (第 13-36 行)

pub(crate) fn resolve_exporter(exporter: &OtelExporter) -> OtelExporter { // 把逻辑导出器解析成具体端点配置    match exporter { // 按逻辑导出器分支处理        OtelExporter::Statsig => { // Statsig = OpenAI 内置摄取端点            if cfg!(debug_assertions) { // debug 构建(本地开发 / 测试运行):强制关闭                return OtelExporter::None; // 本地跑不发 best-effort 遥测流量,避免污染生产数据            }            OtelExporter::OtlpHttp { // Statsig 本质就是一个内置的 OTLP HTTP 端点 + client API key                endpoint: STATSIG_OTLP_HTTP_ENDPOINT.to_string(), // 内置端点:https://ab.chatgpt.com/otlp/v1/metrics                headers: HashMap::from([(STATSIG_API_KEY_HEADER, STATSIG_API_KEY)]), // 客户端公开密钥(源码常量),用于 Statsig 摄取鉴权                protocol: OtelHttpProtocol::Json, // HTTP + JSON 传输                tls: None, // 走默认系统 CA,无需自定义 mTLS            }        }        _ => exporter.clone(), // 自定义 OTLP gRPC/HTTP 配置原样透传    }}

两个值得注意的点:(1) "Statsig" 并不是什么私有协议,就是 OTLP HTTP + JSON——OpenAI 把自家摄取端点包装成一个枚举值,用户配置里看不到 URL;(2)cfg!(debug_assertions) 让本地开发构建默认零遥测——开发者跑测试不会往生产管道里灌脏数据,这个开关连配置都省了。同文件第 114-119 行还有个专门的单元测试锁死这个行为。

七、Analytics 管线:有界队列 + 单 worker reducer

analytics crate 的架构是"事实进、事件出":facts.rs(776 行)定义 AnalyticsFact 枚举——约 45 个变体,覆盖 Initialize、ClientRequest/Response、RealtimeSession*、Notification 等协议级事实,外加 CustomAnalyticsFact(Compaction、Goal、GuardianReview、PluginUsed…);reducer.rs(4405 行)是状态机,把零散事实聚合成 turn 级事件。消费端在 client.rs(第 183-246 行节选):

📄 codex-rs/analytics/src/client.rs (第 183-246 行,节选)

fn new(auth_manager: Arc<AuthManager>, destination: AnalyticsEventsDestination) -> Self { // 创建队列 + 单一消费任务    let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE); // 有界通道,容量 256——内存的第一道防线    tokio::spawn(async move { // 单 worker 任务:reducer 的独占所有者        let mut reducer = AnalyticsReducer::default(); // reducer 状态机独占在这个任务里,全程无需加锁        while let Some(input) = receiver.recv().await { // 逐条消费:Fact / ThreadProduct / Flush 三种消息            let input = match input { // 按消息类型分流处理                AnalyticsEventsQueueMessage::Fact(input) => *input, // 普通事实 → 交给下面的 reducer                AnalyticsEventsQueueMessage::ThreadProduct { .. } => continue, // 产品归属更新分支:登记线程 SKU,变化时先 flush 该线程挂起事件(此处省略)                AnalyticsEventsQueueMessage::Flush(done_tx) => { let mut e = Vec::new(); reducer.flush(&mut e); send_track_events(&auth_manager, &destination, e, &thread_products).await; let _ = done_tx.send(()); continue; } // Flush:排空所有未完成 turn 并上报,再通知调用方            };            let mut events = Vec::new(); // 收集本轮 reducer 产出的事件            reducer.ingest(input, &mut events).await; // fact → event:把零散协议消息聚合成完整 turn 级事件            send_track_events(&auth_manager, &destination, events, &thread_products).await; // 批量 POST 到 ChatGPT 后端 /codex/analytics-events/events        }    });}

生产端更简单——所有 track_*() 方法最终都走这一个入口(第 248-257 行):

📄 codex-rs/analytics/src/client.rs (第 248-257 行)

fn try_send(&self, input: AnalyticsFact) { // 所有 track_*() 调用都从这里入队    if self.sender.try_send(AnalyticsEventsQueueMessage::Fact(Box::new(input))).is_err() { // 非阻塞发送:队列满立即失败,绝不等待        tracing::warn!("dropping analytics events: queue is full"); // 丢弃并告警——遥测永远不阻塞主路径等空位    }}

为什么这样设计?这是整条管线里最"反直觉"的决策:队列满了就丢,而不是阻塞或扩容。遥测是 best-effort 数据——用户正在等模型回答的主路径绝不能因为"上报事件"卡住哪怕一毫秒;而 reducer 独占在单个 worker 任务里,意味着 turns、requests、threads 这些 HashMap 完全不需要锁(reducer.rs 第 215-228 行)。配套的边界常量把"丢数据"也做成了有界行为:

常量
值
作用
ANALYTICS_EVENTS_QUEUE_SIZE(client.rs:88)
256
mpsc 队列容量,满则丢弃并告警
MAX_PENDING_TURNS(reducer.rs:212)
256
reducer 保留的未完成 turn 上限,超出驱逐最旧(含其工具/审批状态)
ANALYTICS_EVENT_DEDUPE_MAX_KEYS(client.rs:92)
4096
app_used / plugin_used 去重集合上限,超了整体清空而非无限增长
MAX_RESPONSE_BYTES(agent_response.rs:18)
65536
agent response 日志正文上限,按字符边界截断 + turn 内去重

还有一个容易漏掉的细节——产品归属的"代际计数器"(client.rs 第 192-198、304-324 行):每个线程要登记自己的 product SKU;如果登记消息因为队列满被丢弃,worker 侧的 product_generation 计数器会自增,使当前和已排队的所有归属信息整体失效(fail-closed)。宁可少报产品归属,也不能把 A 线程的事件标成 B 产品的——归属错误比数据缺失更糟。

八、两条管线的全链路数据流

OTEL 通道(日志 / 追踪 / 指标)

① 事件发射:SessionTelemetry.user_prompt() / emit_tool_result()

宏按通道拆分字段,PII 只进 codex_otel.log_only。

▼

② 导出器路由:log_export_filter / trace_export_filter

按 target 前缀分流,日志与追踪去不同端点。

▼

③ 传输:OTLP gRPC / HTTP(JSON 或 protobuf)

🔹 mTLS:CA + 客户端证书必须成对,半套直接报错🔹 Statsig 内置端点 ab.chatgpt.com,debug 构建自动关闭

Analytics 通道(产品事件)

① 入队:track_*() → try_send(有界队列 256)

非阻塞发送,满则丢弃告警——主路径永不等遥测。

▼

② 聚合:单 worker 独占 AnalyticsReducer

facts → turn 级事件;挂起 turn ≤256,去重集 ≤4096。

▼

③ 上报:批量 POST /codex/analytics-events/events

🔹 产品归属用代际计数器,登记失败则整体失效(fail-closed)🔹 debug 构建可切到本地捕获文件,网络发送禁用

把两条管线放在一起看,Codex 的遥测哲学可以概括成三句话:PII 按通道隔离而不是事后过滤;敏感内容默认关、opt-in 才开;遥测永远 best-effort——宁可丢数据,不阻塞用户。这三条原则在 otel 和 analytics 两个 crate 里被一致地执行了。

📚 系列导航

← 第 43 讲:Responses WebSocket——模型流式输出的稳定通道

→ 第 45 讲:TUI 架构与 ChatWidget

关注公众号「AI技术推荐官」获取更多源码解析内容

📌 每天 30 条,挑出真能用的 AI 东西

UnitAI 每天从 GitHub / Hacker News / Hugging Face 抓一遍 AI 热榜,还收录了 50+ 个上过榜的项目,能按分类、星级、上榜天数筛。免费看,不用注册。

长按识别二维码打开 · 或点左下角「阅读原文」

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料