Codex OTel 与 Grafana 本机审计部署:从“看见事件”到可比较的 token 实验
ℹ️ 读者定位
这篇记录面向在 Windows + Codex Desktop 上做本机学习、验证或对照实验的人。前提是 Docker Desktop 已可用或有linux服务器。完成后,可以在 Grafana 中按轮查看可见输入、最终回答和 token 统计。
📄 要解决的不是计费,而是学习
目标是把每次 Codex 会话中可获得的任务 ID、项目名、会话名、最新用户问题、开始/结束时间、时长、token 与过程计数放到同一条轻量审计记录中,方便观察 Codex 如何调用模型。codex_input 与 codex_output 会保留在 Loki;超长时改为本机文件路径。完整回调原文仍留在 turns.jsonl。
0. 先看效果
0.1. Codex原生查看Token消耗

1_0先看效果
0.2. 使用Codex OTel之后的Token消耗查询效果

1_1先看效果
1_2先看效果
1_3先看效果
1_4先看效果
1_5先看效果
1. 最终方案:默认关闭,双击一次开关

Codex 本机审计最终整体架构
日常使用保持关闭。需要做实验时,只做两件事:
- 双击 D:\Software\docker\codex-observability\Codex 审计开关。cmd。
- 完全退出并重新打开 Codex Desktop。
脚本会在“开启”和“关闭”之间切换,并保留一份 C:\Users\xiaocai.codex\config.toml.audit-toggle.bak 配置备份。做实验时需要启动 Docker 容器;日常关闭审计后可以停止容器以释放本机资源。
| 状态 | 会发生什么 | 是否保存输入与回答 |
|---|---|---|
| 关闭(默认) | OTel 导出设为 none;通知包装器只转发原有桌面通知 | 不保存新的审计文本或 token |
| 开启 | OTel 上报 token;notify 回调备份可见输入和最终回答 | Loki 保存轻量索引与 codex_input/output;JSONL 保存回调文本 |
⚠️ 为什么仍要重启 Codex
Codex 在启动时读取 OTel 配置。双击脚本后,正在进行的会话不要强行结束;完成当前工作后,完全退出并重新打开 Codex,再开始新的实验轮次。
开关文件只修改 [otel] 中的 log_user_prompt 和 exporter,并用一个本机标记控制 notify 包装器是否写审计记录。它不会改写 Codex Desktop 自动维护的 notify / --previous-notify 通知链,所以原有桌面通知不会因为关闭审计而消失。
1.1. 实现原理

Codex 本机审计实现原理
要得到“每轮任务只有一条、同时含任务标识、token、耗时与过程计数”的轻量记录,不能只依赖单一来源。Codex 暴露的是两条互补事件流:
- 原生 OTel 事件流:随任务运行持续到达,包含用户输入开始、模型
response.completed的 token、工具决策、工具参数和工具结果。它适合还原过程,却不保证提供完整最终回答。 - 任务完成 notify 回调:任务真正结束时到达,包含对用户可见的输入消息和最终助手回答。它适合补齐文本,却不含完整 token 与逐步工具事件。
codex-audit-proxy 同时接收这两条流。每当看到 codex.user_prompt,代理就在同一 thread_id / conversation_id 下创建一个唯一的 task_id,并把后续原生事件暂存在内存中;notify 完成回调到达后,代理按同一会话的顺序取回该 task,等待约 8 秒让迟到的 response.completed token 抵达,再将标识、时间、token 与计数合成一条 codex.turn_completed 日志写入独立 Loki。可见输入和最终回答则由 notify 保存在本机 JSONL 备份。
Codex Desktop
├─ 原生 OTel:输入开始 / token / 工具步骤 ─┐
└─ notify:可见输入 / 最终回答 ─────────────┼─> Audit Proxy(按 task_id 缓存、合并)
├─> turns.jsonl:完整回调文本
└─> Loki:一轮一条轻量 completed 日志(含 codex_input/output 或文件路径)
└─> Grafana:按字段筛选、展开与比较
这也是以下几个设计选择的原因:
- 延迟约 8 秒才落库:优先等 token 与最后工具事件到齐,避免出现“有输入输出但 token 为空”的半条日志。
- 一轮只写一次 completed 记录:Loki 是追加式日志库,不适合不断更新同一行;在内存中聚合、任务结束后写入一次,查询才不会充满 started / completed 重复行。
task_id与thread_id分开:前者是一轮问答,后者是侧栏长会话;同一会话可包含很多 task。- 大文本外置、步骤 JSON 不写入:
conversation_visible_input与步骤 JSON 不写入 Loki;codex_input/codex_output短文本直接写入,任一超过 12,000 字符时,两个字段改为指向log\<task_id>\下的文本文件。
⚠️ 可观察边界
这是对 Codex 已公开事件和完成回调的关联,不是网络抓包。它无法得到模型隐藏推理、内部完整请求体或完整流式中间回答;字段的精确范围见第 6.1 节。
1.2. 开关与通知桥接:三个本机文件分别做什么
这一套审计不是由 Skill 或 MCP 主动触发,而是挂在 Codex 的两个生命周期入口上:启动时读取的 [otel] 配置,以及任务结束时执行的 notify 回调。三个文件的关系如下:
双击「Codex 审计开关.cmd」
└─> codex-audit-toggle.cjs
├─> 修改 C:\Users\xiaocai\.codex\config.toml 的 [otel]
└─> 创建或删除 audit\enabled.flag
Codex 任务结束
└─> codex-audit-notify.cjs
├─> 先转发 Codex 原有桌面通知
└─> 仅在 enabled.flag 存在时,写入 turns.jsonl 并提交给本机 Audit Proxy
文件一:Codex 审计开关.cmd——给人双击的入口
路径:D:\Software\docker\codex-observability\Codex 审计开关.cmd。
它本身不处理审计逻辑,只做三件事:切换到 UTF-8 代码页、调用随 Codex Desktop 安装的 Node.js、暂停窗口以便看到“已开启/已关闭”的结果。
@echo off
setlocal
chcp 65001 >nul
"C:\Users\xiaocai\AppData\Local\OpenAI\Codex\runtimes\cua_node\03b1cdac8af3a530\bin\node.exe" "C:\Users\xiaocai\.codex\codex-audit-toggle.cjs"
echo.
pause
⚠️ Warning
这条命令使用当前安装版本的 Codex 内置 Node 路径。若 Codex Desktop 升级后该运行时目录变化,双击窗口提示“找不到文件”时,只需把这里的 node.exe 路径改成新版本路径;审计逻辑文件不需要改。
文件二:codex-audit-toggle.cjs——真正的开启/关闭控制器
路径:C:\Users\xiaocai\.codex\codex-audit-toggle.cjs。
它不接受“on/off”参数,而是每执行一次就翻转当前状态。当前状态同时由两处判断:enabled.flag 是否存在且内容为 enabled,以及 [otel] 是否已经指向 http://127.0.0.1:4318/v1/logs。

审计开关控制器:备份配置、更新 OTel 与同步 enabled.flag
| 开关后的状态 | config.toml 的 [otel] |
enabled.flag |
实际效果 |
|---|---|---|---|
| 开启 | log_user_prompt = true,exporter = { otlp-http = ... } |
创建并写入 enabled |
重启 Codex 后导出原生事件;notify 开始保留可见文本 |
| 关闭 | log_user_prompt = false,exporter = "none" |
删除 | 重启 Codex 后不再导出,也不再写审计文本 |
关键实现如下。getOtelTableRange 和 setTableKey 保证只更新 [otel] 表,而不触碰模型、MCP 或原有通知配置;每次写入前会创建 config.toml.audit-toggle.bak 备份。
function writeOtelSettings(text, enabled) {
const range = getOtelTableRange(text);
const logUserPrompt = enabled ? 'true' : 'false';
const exporter = enabled ? OTLP_EXPORTER : '"none"';
// 只替换或补充 [otel] 中的 log_user_prompt / exporter
}
function setWrapperState(enabled) {
if (enabled) {
fs.writeFileSync(AUDIT_ENABLED_FILE, 'enabled\r\n', 'utf8');
} else if (fs.existsSync(AUDIT_ENABLED_FILE)) {
fs.unlinkSync(AUDIT_ENABLED_FILE);
}
}
const nextEnabled = !(stateFileSaysEnabled() || otelConfigSaysEnabled(current));
fs.copyFileSync(CONFIG_FILE, CONFIG_FILE + '.audit-toggle.bak');
fs.writeFileSync(CONFIG_FILE, writeOtelSettings(current, nextEnabled), 'utf8');
setWrapperState(nextEnabled);
它的运行原理不是“启动一个后台服务”,而是一次性的配置事务:先读出当前配置,再计算反状态,备份原文件,写回 [otel],最后让 enabled.flag 与配置同步。这样必须完全重启 Codex 的原因是:已经启动的 Codex 不会重新读取 OTel exporter;而 enabled.flag 是 notify 包装器每次回调即时读取的第二道闸门,避免关闭后仍把最终回答写入本机文件。
以下是当前 C:\Users\xiaocai\.codex\codex-audit-toggle.cjs 的完整源码(2026-07-19 核对)。以本机文件为最终准则;若以后修改脚本,应同步更新这一节。
展开:codex-audit-toggle.cjs 完整代码
const fs = require('node:fs');
const path = require('node:path');
const CONFIG_FILE = 'C:\\Users\\xiaocai\\.codex\\config.toml';
const AUDIT_ENABLED_FILE = 'D:\\Software\\docker\\codex-observability\\audit\\enabled.flag';
const OTLP_EXPORTER = '{ otlp-http = { endpoint = "http://127.0.0.1:4318/v1/logs", protocol = "json" } }';
function getOtelTableRange(text) {
const heading = /^\[otel\][^\r\n]*\r?$/m.exec(text);
if (!heading) return null;
const start = heading.index;
const afterHeading = start + heading[0].length;
const remainder = text.slice(afterHeading);
const nextHeading = /^\[[^\r\n]+\][^\r\n]*\r?$/m.exec(remainder);
return { start, end: nextHeading ? afterHeading + nextHeading.index : text.length };
}
function setTableKey(table, key, value) {
const expression = new RegExp('^\\s*' + key + '\\s*=.*$', 'm');
const replacement = key + ' = ' + value;
if (expression.test(table)) return table.replace(expression, replacement);
return table.replace(/\s*$/, '') + '\r\n' + replacement + '\r\n';
}
function writeOtelSettings(text, enabled) {
const range = getOtelTableRange(text);
const logUserPrompt = enabled ? 'true' : 'false';
const exporter = enabled ? OTLP_EXPORTER : '"none"';
if (!range) {
return text.replace(/\s*$/, '') + '\r\n\r\n[otel]\r\n'
+ 'log_user_prompt = ' + logUserPrompt + '\r\n'
+ 'environment = "personal"\r\n'
+ 'exporter = ' + exporter + '\r\n';
}
let table = text.slice(range.start, range.end);
table = setTableKey(table, 'log_user_prompt', logUserPrompt);
table = setTableKey(table, 'exporter', exporter);
return text.slice(0, range.start) + table + text.slice(range.end);
}
function stateFileSaysEnabled() {
try {
return fs.existsSync(AUDIT_ENABLED_FILE)
&& fs.readFileSync(AUDIT_ENABLED_FILE, 'utf8').trim() === 'enabled';
} catch {
return false;
}
}
function otelConfigSaysEnabled(text) {
const range = getOtelTableRange(text);
if (!range) return false;
const table = text.slice(range.start, range.end);
return /^\s*log_user_prompt\s*=\s*true\s*$/m.test(table)
&& table.includes('http://127.0.0.1:4318/v1/logs');
}
function setWrapperState(enabled) {
fs.mkdirSync(path.dirname(AUDIT_ENABLED_FILE), { recursive: true });
if (enabled) {
fs.writeFileSync(AUDIT_ENABLED_FILE, 'enabled\r\n', 'utf8');
} else if (fs.existsSync(AUDIT_ENABLED_FILE)) {
fs.unlinkSync(AUDIT_ENABLED_FILE);
}
}
function main() {
const current = fs.readFileSync(CONFIG_FILE, 'utf8');
const nextEnabled = !(stateFileSaysEnabled() || otelConfigSaysEnabled(current));
const updated = writeOtelSettings(current, nextEnabled);
fs.copyFileSync(CONFIG_FILE, CONFIG_FILE + '.audit-toggle.bak');
fs.writeFileSync(CONFIG_FILE, updated, 'utf8');
setWrapperState(nextEnabled);
console.log(nextEnabled ? '审计已开启:下一次完全重启 Codex 后开始采集。' : '审计已关闭:下一次完全重启 Codex 后停止采集。');
console.log('Docker 无需操作;原有桌面通知链已保留。');
console.log('已备份配置:' + CONFIG_FILE + '.audit-toggle.bak');
}
main();
文件三:codex-audit-notify.cjs——任务完成时的文本桥接器
路径:C:\Users\xiaocai\.codex\codex-audit-notify.cjs。它由 config.toml 的 notify 链调用;配置中把 Codex 原有的桌面通知程序作为第一个参数传入,因此包装器可以保持原通知体验。

notify 文本桥接器:先转发桌面通知,再按开关旁路审计
执行顺序很重要:先转发原通知,再决定是否审计。即使本机 Docker 没启动、OTLP 提交失败或审计关闭,原来的任务完成通知仍会继续工作。
async function main() {
forwardLegacyNotification(); // 始终执行,保留 Codex 原通知链
if (!auditIsEnabled()) return; // enabled.flag 不存在则不留任何新审计文本
if (!payloadText) return;
let payload;
try {
payload = JSON.parse(payloadText);
} catch {
return;
}
await writeAuditEvent(payload); // JSONL 副本 + OTLP/HTTP 发给 127.0.0.1:4318
}
main().catch(() => process.exit(0)); // 审计失败不影响 Codex 的完成回调
它的运行原理是一个不改变原有通知行为的旁路:Codex 调用包装器时,会将旧通知程序路径、旧通知参数和任务结束 JSON 一起传入。包装器把旧程序及原参数原样 spawn 出去,然后才检查审计状态。开启时,它从 JSON 中拿到可见输入和最后一条助手回答,先保存 JSONL 副本,再以 OTLP/HTTP 报给本机代理;关闭时,它在转发旧通知后立即结束。
这份脚本不能看到模型的隐藏推理或内部完整请求体:它只处理 Codex 在 notify 回调中公开的 input-messages 与 last-assistant-message。token、工具参数、工具输出和折叠执行步骤来自另一条原生 OTel 事件流,最终由 codex-audit-proxy 关联。main().catch(() => process.exit(0)) 则保证审计写入失败不会影响 Codex 的任务完成通知。
当 Docker 部署在 Linux 服务器、Codex Desktop 仍运行在 Windows 时,侧栏标题不能依赖 Linux 容器挂载 Windows 的 session_index.jsonl。因此当前 notify 脚本会优先读取本机 C:\Users\xiaocai\.codex\session_index.jsonl:用回调的 thread-id 查找对应 thread_name,并将它作为 session_name_candidate 连同完成回调发送到服务器。远程代理会采用该候选标题;读取失败才退回回调标题字段或首条输入。
以下是当前 C:\Users\xiaocai\.codex\codex-audit-notify.cjs 的完整源码(2026-07-20 核对)。
展开:codex-audit-notify.cjs 完整代码
const { appendFileSync, existsSync, mkdirSync, readFileSync } = require('node:fs');
const { dirname, win32 } = require('node:path');
const { spawn } = require('node:child_process');
const OTLP_ENDPOINT = 'http://127.0.0.1:4318/v1/logs';
const BACKUP_FILE = 'D:\\Software\\docker\\codex-observability\\audit\\turns.jsonl';
const AUDIT_ENABLED_FILE = 'D:\\Software\\docker\\codex-observability\\audit\\enabled.flag';
const CODEX_SESSION_INDEX_FILE = process.env.CODEX_SESSION_INDEX_FILE || 'C:\\Users\\xiaocai\\.codex\\session_index.jsonl';
const [legacyExecutable, ...notificationArgs] = process.argv.slice(2);
const payloadText = notificationArgs.at(-1);
const legacyArgs = notificationArgs.slice(0, -1);
function attribute(key, value) {
return { key, value: { stringValue: String(value ?? '') } };
}
function forwardLegacyNotification() {
if (!legacyExecutable) return;
const child = spawn(legacyExecutable, [...legacyArgs, payloadText], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
}
function auditIsEnabled() {
try {
return existsSync(AUDIT_ENABLED_FILE)
&& readFileSync(AUDIT_ENABLED_FILE, 'utf8').trim() === 'enabled';
} catch {
return false;
}
}
function compactName(value, fallback) {
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
return normalized.slice(0, 120);
}
function projectName(cwd) {
const normalized = String(cwd ?? '').replace(/[\\/]+$/, '');
return win32.basename(normalized) || normalized || 'unknown';
}
function sessionTitleFromCodexIndex(threadId) {
try {
const lines = readFileSync(CODEX_SESSION_INDEX_FILE, 'utf8').split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (!lines[index].trim()) continue;
const entry = JSON.parse(lines[index]);
if (entry.id === threadId && typeof entry.thread_name === 'string' && entry.thread_name.trim()) {
return entry.thread_name.trim();
}
}
} catch {
// Session titles are optional; retain the callback and input fallbacks.
}
return '';
}
function callbackSessionTitle(payload) {
const indexedTitle = sessionTitleFromCodexIndex(payload['thread-id']);
if (indexedTitle) return { value: indexedTitle, source: 'codex_session_index' };
const keys = [
'thread-title', 'thread_name', 'thread-name',
'task-title', 'task_name', 'task-name',
'conversation-title', 'conversation_name', 'conversation-name',
'title',
];
for (const key of keys) {
const value = payload[key];
if (typeof value === 'string' && value.trim()) return { value: value.trim(), source: key };
}
return { value: '', source: 'fallback_input' };
}
async function writeAuditEvent(payload) {
const inputMessages = Array.isArray(payload['input-messages']) ? payload['input-messages'] : [];
const input = inputMessages.join('\n\n');
const output = payload['last-assistant-message'] ?? '';
const timestamp = new Date().toISOString();
const timestampNanos = String(BigInt(Date.now()) * 1_000_000n);
const taskId = payload['thread-id'] ?? '';
const cwd = payload.cwd ?? '';
const currentInput = inputMessages.at(-1) ?? '';
const callbackTitle = callbackSessionTitle(payload);
const sessionName = compactName(callbackTitle.value || currentInput, taskId || 'unnamed-session');
const currentProjectName = projectName(cwd);
const backup = {
timestamp,
task_id: taskId,
status: 'completed',
project_name: currentProjectName,
session_name: sessionName,
session_name_source: callbackTitle.source,
callback_payload_keys: Object.keys(payload).sort(),
thread_id: taskId,
turn_id: payload['turn-id'] ?? '',
cwd,
input_messages: inputMessages,
last_assistant_message: output,
};
mkdirSync(dirname(BACKUP_FILE), { recursive: true });
appendFileSync(BACKUP_FILE, `${JSON.stringify(backup)}\n`, 'utf8');
const attributes = [
attribute('event_name', 'codex.turn_completed'),
attribute('codex_input', input),
attribute('codex_output', output),
attribute('conversation_visible_input', input),
attribute('turn_user_input', currentInput),
attribute('turn_final_answer', output),
attribute('content_capture_scope', 'visible_input_messages_and_final_assistant_message'),
attribute('input_message_count', inputMessages.length),
attribute('task_id', taskId),
attribute('status', 'completed'),
attribute('project_name', currentProjectName),
attribute('session_name', sessionName),
attribute('session_name_candidate', callbackTitle.value),
attribute('session_name_candidate_source', callbackTitle.source),
attribute('thread_id', backup.thread_id),
attribute('turn_id', backup.turn_id),
attribute('cwd', backup.cwd),
];
const otlp = {
resourceLogs: [{
resource: { attributes: [attribute('service.name', 'codex-turn-audit'), attribute('env', 'personal')] },
scopeLogs: [{
scope: { name: 'codex-turn-audit', version: '1.0.0' },
logRecords: [{
timeUnixNano: timestampNanos,
observedTimeUnixNano: timestampNanos,
severityNumber: 9,
severityText: 'INFO',
body: { stringValue: 'Codex task audit [thread_id=' + taskId + ']' },
attributes,
}],
}],
}],
};
await fetch(OTLP_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(otlp),
signal: AbortSignal.timeout(3000),
});
}
async function main() {
forwardLegacyNotification();
if (!auditIsEnabled()) return;
if (!payloadText) return;
let payload;
try {
payload = JSON.parse(payloadText);
} catch {
return;
}
await writeAuditEvent(payload);
}
main().catch(() => process.exit(0));
writeAuditEvent 从完成回调提取的关键内容与用途如下:
| 回调字段 / 派生字段 | 写入字段 | 用途 |
|---|---|---|
input-messages |
conversation_visible_input、codex_input |
当前侧栏中可见的输入上下文 |
最后一条 input-messages |
turn_user_input |
本轮真正的新提问 |
last-assistant-message |
turn_final_answer、codex_output |
本轮最终回答,不是模型隐藏推理 |
thread-id、turn-id |
thread_id、turn_id |
与 Codex 会话/回调交叉核对 |
cwd |
cwd、project_name |
推导项目名称 |
| 回调标题候选 | session_name_candidate |
供代理补全会话名称;实际优先使用 session_index.jsonl 中的侧栏标题 |
包装器还会把回调原文的最小备份追加到 D:\Software\docker\codex-observability\audit\turns.jsonl,并向 http://127.0.0.1:4318/v1/logs 发送一条 codex.turn_completed 事件。Docker 中的 codex-audit-proxy 再把它与原生 OTel 的 token、工具事件和过程步骤合并为最终的一条任务审计记录。
ℹ️ Info
notify 收到的 thread-id 是整个侧栏会话的 ID,不是每轮唯一的 task_id。最终记录中的唯一 task_id 由 Audit Proxy 在捕获 codex.user_prompt 时生成,并在完成回调到来时回填;不要直接把包装器的临时 task_id 当成每轮任务 ID。
文件四:audit-proxy.cjs——原生 OTel 与完成回调的合并器
路径:D:\Software\docker\codex-observability\audit-proxy\audit-proxy.cjs。这是整套方案中真正负责日志收集、关联、清洗和写入的核心代码;Docker Compose 将其以只读方式挂载到 node:22-alpine 容器的 /app/audit-proxy.cjs,并暴露本机入口 http://127.0.0.1:4318/v1/logs。
它同时接收两类 JSON OTLP 日志:
- Codex 原生 OTel:
codex.user_prompt、codex.api_request、codex.sse_event/response.completed、codex.tool_decision、codex.tool_result等;其中response.completed提供 token 统计。 codex-audit-notify.cjs的完成回调:携带codex_input、codex_output、thread_id、工作目录和会话标题候选。
代理按 conversation.id/thread_id 建立会话缓存;看到每个 codex.user_prompt 时生成唯一 task_id,缓存这一轮的步骤和 token。完成回调到达后不立即写 Loki,而是等待 8 秒;这样晚到的 response.completed 可以和本轮文本正确配对。到期后它清除不应保存的长上下文/步骤 JSON,保留汇总数字和 codex_input、codex_output,最后写入 codex-loki:3100。
// 关键运行参数:容器内接收 4318,转发到独立 codex-loki。
const UPSTREAM = 'http://codex-loki:3100/otlp/v1/logs';
const PENDING_DELAY_MS = 8000;
const CACHE_TTL_MS = 120000;
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const MAX_AUDIT_TEXT_CHARS = 12000;
const AUDIT_LOG_DIR = '/audit-log';
const AUDIT_LOG_HOST_DIR = 'D:\\Software\\docker\\codex-observability\\log';
const tokensByConversation = new Map();
const sessionsByConversation = new Map();
const pendingAudits = [];
4.1 它如何保证一轮只有一个 task_id
createTurn 在 codex.user_prompt 到达时递增会话内序号,并拼接随机后缀;pendingTurns 是先进先出队列,所以完成回调会取回同一会话中最早尚未完成的一轮,而不是误用整个会话的 thread_id。
function createTurn(session, startedAt, prompt) {
session.nextTurnSequence += 1;
const taskId = session.conversationId + '-turn-'
+ String(session.nextTurnSequence).padStart(4, '0')
+ '-' + randomUUID().slice(0, 8);
const turn = {
taskId, sequence: session.nextTurnSequence, startedAt, prompt,
stepCount: 0, toolCallCount: 0, commandCount: 0,
modelRequestCount: 0, modelInteractionCount: 0, steps: [],
};
session.pendingTurns.push(turn);
session.currentTurn = turn;
return turn;
}
function takeTurnForCompletion(session, endedAt) {
const turn = session.pendingTurns.shift() ?? fallbackTurn(session, endedAt);
session.currentTurn = session.pendingTurns.at(-1) ?? null;
return turn;
}
4.2 它如何收集步骤、命令次数和模型交互次数
每条原生事件都会经过 enrichProcessRecord。它不再把详细步骤 JSON 写入最终 Loki 行,而是在内存中计数:
| 原生事件 | 代理判定 | 累加字段 |
|---|---|---|
codex.api_request |
一次向模型发起请求 | task_model_request_count |
codex.sse_event + response.completed |
一次模型响应完成,且可携带 token | task_model_interaction_count |
codex.tool_result |
一次工具调用完成 | task_tool_call_count |
| 工具名匹配 shell/command/powershell/cmd | 一次命令执行 | task_command_count |
| 任意归属当前 task 的过程事件 | 一步处理 | task_step_count |
if (eventName === 'codex.tool_result') {
session.sessionToolCallCount += 1;
turn.toolCallCount += 1;
if (/(shell|command|powershell|cmd)/i.test(name)) {
session.sessionCommandCount += 1;
turn.commandCount += 1;
}
}
if (eventName === 'codex.api_request') {
session.sessionModelRequestCount += 1;
turn.modelRequestCount += 1;
}
if (eventName === 'codex.sse_event' && eventKind === 'response.completed') {
session.sessionModelInteractionCount += 1;
turn.modelInteractionCount += 1;
}
4.3 token 与文本如何合并,并避免 Loki 记录过大
rememberTokens 把原生 response.completed 的 input_token_count、output_token_count、cached_token_count、reasoning_token_count、tool_token_count 放进会话队列。flushAudits 每秒检查完成回调队列;满 8 秒后取出同一会话的一组 token 和一轮 task,调用 addCompletionAttributes 写入最终字段。
codex_input、codex_output 两者都不超过 12,000 字符时直接写 Loki;任一超长时,两个原文会分别写入 D:\Software\docker\codex-observability\log\<task_id>\codex_input.txt 和 codex_output.txt,Loki 字段改为这两个 Windows 文件路径。
const auditText = auditTextAttributes(turn.taskId, rawCodexInput, rawCodexOutput);
removeAttributes(attributes, [
'conversation_visible_input', 'turn_final_answer',
'process_steps_json', 'codex_collapsed_activity_json',
'codex_collapsed_activity_text', 'process_steps_capture',
'codex_collapsed_activity_count',
]);
if (auditText.codexInput) {
setAttribute(attributes, 'codex_input', { stringValue: auditText.codexInput });
}
if (auditText.codexOutput) {
setAttribute(attributes, 'codex_output', { stringValue: auditText.codexOutput });
}
setAttribute(attributes, 'codex_text_storage', { stringValue: auditText.storage });
4.4 最终写入 Loki 的主循环
下面是代理的关键主循环。它说明了为什么 Grafana 中最终是一轮一条 completed 日志:原生过程事件只在内存中用于关联和计数;notify 的完成事件进入 pendingAudits;延迟到期后才单独转发最终记录。
async function flushAudits() {
const now = Date.now();
const current = pendingAudits.splice(0);
const due = current.filter((entry) => now - entry.receivedAt >= PENDING_DELAY_MS);
const waiting = current.filter((entry) => now - entry.receivedAt < PENDING_DELAY_MS);
pendingAudits.push(...waiting);
for (const entry of due) {
const attributes = entry.record.attributes ?? (entry.record.attributes = []);
const map = attributeMap(entry.resource.attributes, attributes);
const conversationId = text(map.get('thread_id'));
const endedAt = timestampMillis(entry.record);
const session = ensureSession(conversationId, endedAt, text(map.get('cwd')), '');
const turn = takeTurnForCompletion(session, endedAt);
const token = takeTokens(conversationId);
addCompletionAttributes(attributes, entry.record, session, turn, token);
boundRecordAttributes(attributes);
await forward({ resourceLogs: [{ resource: entry.resource, scopeLogs: [{
scope: entry.scope, logRecords: [entry.record],
}] }] });
}
pruneCache();
}
setInterval(() => flushAudits().catch((error) => console.error(error.message)), 1000).unref();
⚠️ 运行源码的唯一位置
文档中的代码块用于解释关键逻辑;Docker 实际执行的完整 539 行源码始终是 D:\Software\docker\codex-observability\audit-proxy\audit-proxy.cjs。修改后应执行 docker compose restart codex-audit-proxy,并以 docker logs --tail 50 codex-audit-proxy 验证;不要只改笔记中的示例代码。
💡 跨机器 token 关联调试
Compose 当前设置 AUDIT_DEBUG_EVENT_SCHEMA: "1"。代理会输出 AUDIT_DEBUG received_event、token_queued、token_dequeued,只包含事件名、属性键名、会话关联 ID 和 token 字段是否存在,不写入输入、输出、工具参数或工具结果。对比两台机器的 conversation_id_dot 与 completion_thread_id,以及 is_token_event 是否为 true。完成排查后将该环境变量改为 "0" 并重启代理。
日常操作与 Docker 的关系
- 只想停止内容采集:双击
Codex 审计开关.cmd关闭,然后完全重启 Codex;Docker 是否运行不改变已关闭的 OTel 配置。 - 还想释放本机资源:在
D:\Software\docker\codex-observability执行docker compose stop;这会停止 Grafana 和代理,但保留容器、配置与历史数据。 - 恢复实验:先
docker compose start,再双击开关使其开启,并完全重启 Codex。
2. 这套方案是怎样演变出来的

Codex 本机审计方案演变过程
第一步只启用 Codex 原生 OTel。它已经能提供请求事件、工具事件和 response.completed 中的 token 计数,但看不到最终回答正文。
第二步加入本机 notify 包装器。回调能够拿到 input-messages、last-assistant-message、thread-id 和 turn-id,因此能记录用户可见输入和最终回答;但回调本身没有 token。
第三步加入 Docker 中的合并代理。它在内存中缓存原生 response.completed token、工具输入输出和过程步骤;收到 notify 的完成回调后最多等待约 8 秒,再将全部信息合并为一条 codex.turn_completed 审计记录。于是 Grafana 的同一行中同时有文本、token、步骤明细和汇总。

Codex 本机审计合并流程图
图是方案概念图,不是 Grafana 的真实截图;真实数据应以 Grafana Explore 查询结果为准。
3. 最终架构与可观察边界
Codex Desktop
├─ 原生 OTel token 事件 ─┐
└─ notify 回调(输入 + 最终回答)─┼─> Docker:Codex Audit Proxy
└─> Grafana / Collector / Loki
└─> D:\Software\docker\codex-observability\data
| 组件 | 负责什么 | 得到什么 |
|---|---|---|
| Codex 原生 OTel | 导出过程事件 | input_token_count、output_token_count、reasoning_token_count、耗时、工具事件 |
| notify 包装器 | 在一轮结束时读取回调载荷 | conversation_visible_input、turn_user_input、turn_final_answer、thread_id、turn_id |
| Codex Audit Proxy | 关联 token、时间与原生过程事件 | 在任务完成时写入一条聚合日志,包含过程步骤 JSON 与每轮汇总计数 |
| Grafana + Loki | 查询、筛选、展示 | 用于观察与实验对比的审计视图 |
| turns.jsonl | 本机兜底副本 | Grafana 暂不可用时核对回调记录 |
这不是 HTTP 抓包工具,边界要说清楚:
- 能记录的是用户可见输入、最终回答和 Codex 已导出的 token 统计。
- 旧版的 modelinteraction_input / model_interaction_output 与 codex_input / codex_output 是同一份回调数据的重复命名;新记录不写 model_interaction*,但保留 codex_input / codex_output。
- conversation_visible_input 是回调中的完整可见会话输入;turn_user_input 是其中最新一条用户输入;turn_final_answer 是本轮最终回答。Grafana 不写 conversation_visible_input / turn_final_answer;codex_input / codex_output 在短文本时直接显示,超长时显示文件路径。
- 原生过程日志可记录 prompt、工具的 arguments、工具的 output 和 response.completed 的 token;内部模型请求正文与逐段流式回答正文没有导出,因此会明确标记为未导出,而不会伪造数据。
- 不承诺得到系统提示词、隐式上下文、内部工具协议的全部正文,或每一条模型流式片段。
- notify 的 input-messages 可能包含本轮可见上下文,因此不能把单条文本长度直接当成模型完整请求体。
- 合并代理按会话 / 线程信息进行匹配。刷新 Grafana 前等待约 10 秒;token_match_status = matched 才表示 token 已成功合并。not_found 表示这轮只保留了文本,不能拿它做 token 对比。
4. 为什么 Skill 和 MCP 不适合承担采集
| 方案 | 为什么不适合“每一轮自动采集” | 可以承担的辅助角色 |
|---|---|---|
| Skill | Skill 是按需调用的工作流说明,不会在每轮对话开始、流式响应完成时自动执行,也没有模型请求生命周期的拦截点。 | 写实验规范、生成分析报告、提醒检查对照条件。 |
| MCP | MCP 是由模型自行决定是否调用的工具。它不会保证每一轮都被调用,还会额外增加工具调用和 token,且看不到 Codex 的完整内部请求。 | 做一个只读查询工具,例如按 thread_id 读取已采集记录。 |
| OTel + notify + 合并代理 | 两条数据源分别处在 Codex 的原生遥测事件与回调完成点,能被动记录,不依赖模型“记得调用工具”。 | 当前采用的采集主链路。 |
所以,Skill 或 MCP 不是“不能用”,而是放错了位置。采集必须在模型调用之外被动发生;Skill/MCP 更适合在采集完成后帮助查询、归档或写实验结论。
5. 在 Grafana 中怎样看一轮完整记录
- 打开 http://localhost:3000,进入 Explore,选择 Loki。
- 查询本机审计服务:
{service_name="codex-turn-audit"} |= "Codex task audit"
- 切换到表格视图,添加以下当前版本的推荐字段。这也是建议保存为 Explore 默认列的顺序:
| 必看字段 | 用途 |
|---|---|
| Line | 日志正文;用于快速确认这是一条 Codex task audit 完成记录。 |
| task_id | 每次用户输入到回答完成的一轮任务的唯一 ID。首次用户输入时在代理内生成,并在任务完成时写入唯一的一条审计记录;它不等于 thread_id。 |
| project_name、session_name | 当前项目与 Codex 侧栏任务标题;先用于定位任务。 |
| codex_input、codex_output | 本轮可见输入与最终回答。短文本直接显示;超长时显示 log\<task_id>\codex_input.txt、codex_output.txt 文件路径。 |
| codex_text_storage | inline=两段文本直接显示;files=两段文本已经外置为文件路径;inline_fallback_after_file_write_error=外置失败后的截断兜底。 |
| task_start_time、task_end_time、task_duration_ms | 当前 task 从原生 codex.user_prompt 到完成回调的开始、结束与时长 |
| input_token_count、output_token_count、reasoning_token_count | 输入、输出、推理 token;总 token 建议自行计算输入 + 输出。 |
| cached_token_count、tool_token_count | 原生事件提供时才有的细分统计;tool_token_count 不应作为稳定总 token。 |
| token_match_status、timing_match_status | 必须优先确认 matched / matched_native_user_prompt,否则不要用于精确对比。 |
| task_step_count、task_tool_call_count、task_command_count、task_model_request_count、task_model_interaction_count | 完成记录中的当前 task 汇总。request 是请求尝试次数;interaction 是 response.completed 成功完成次数。 |
| session_step_count、session_tool_call_count、session_command_count、session_model_request_count、session_model_interaction_count | 当前侧栏会话累计汇总。 |
用于实验的总 token 建议自行计算为 input_token_count + output_token_count。不同版本的 tool_token_count 语义可能不同,不能把它当作稳定的“总 token”字段。
如果看不到 completed 审计记录,检查是否在开启审计后重新启动了 Codex;token 列为空时,等 10 秒再刷新,并确认 token_match_status 是否为 matched。
每次用户输入都会生成一个新的 task_id,但不会立即写入 Loki。代理在内存中累计该 task 的过程,回答完成后只写入一条审计记录。thread_id / conversation_id 则把多轮 task 串回同一个侧栏会话。表格中添加 task_id、thread_id、project_name、session_name,即可按任务或整个会话查看。
ℹ️ 会话标题来源
当前 notify 回调不包含 Codex Desktop 侧栏标题。代理会以只读方式读取 C:\Users\xiaocai.codex\session_index.jsonl,并按 thread_id 自动取得 thread_name;其次才使用 audit-proxy/session-title-overrides.json 中的人工覆盖、回调标题字段或首条输入。当前线程会自动解析为“查找 Codex 请求分析工具”。
5.1. 查看过程规模
每个 task 在 Loki 中只有完成后的一条轻量记录。逐步 JSON、工具参数与工具输出不写入 Grafana;请用 task_step_count、task_tool_call_count、task_command_count、task_model_request_count 与 task_model_interaction_count 观察过程规模。codex_input / codex_output 保留;超长时显示其本机文件路径,完整回调原文仍保存在 D:\Software\docker\codex-observability\audit\turns.jsonl。
6. 这套审计可以做什么
它的目的不是核对 OpenAI 的账单,也不是取得模型的隐藏推理,而是把一轮 Codex 任务的标识、最新问题、token、耗时与过程计数放在同一条可检索记录里。适合在本机做下面几类学习和验证。
| 可以做的事 | 在 Grafana 中看什么 | 能回答的问题 |
|---|---|---|
| 定位一次任务 | task_id、turn_user_input、task_duration_ms |
这是哪一轮任务,用户问了什么,任务花了多久? |
| 诊断“为什么慢” | task_duration_ms、task_model_request_count、task_tool_call_count |
模型往返与工具/命令调用次数是否异常偏高? |
| 对比两种工作流 | 固定任务后比较 input/output token、时长、轮数、工具数与结果质量 | 例如启用/关闭 CodeGraph、不同 MCP、不同提示词策略,是否真的更有效? |
| 观察输入规模与 token | turn_user_input、input_token_count、output_token_count |
哪类任务 token 更高?是否伴随更长时长或更多模型交互? |
| 检查工具使用习惯 | task_command_count、task_tool_call_count、task_step_count |
是否出现过多命令、工具调用或模型往返? |
| 按任务或会话回溯 | task_id、thread_id、session_name、project_name |
某个侧栏任务下有几轮对话?某个项目的任务表现如何? |
| 验证采集是否完整 | token_match_status、timing_match_status、content_capture_scope |
这条记录是否成功关联到原生 token 事件?开始时间和输入是否可靠? |
💡 做对比实验的方法
固定仓库、分支、任务描述、模型、推理强度和权限,只改变一个变量。把 input_token_count + output_token_count、任务时长、模型交互次数、工具调用次数和实际结果一起比较。输入 token 变高不必然是坏事:它可能带来更少的往返或更高的一次解决率。
⚠️ 不适合用它做什么
tool_token_count 的语义会随 Codex 事件变化,不能当作稳定的总 token;本机日志也不等于官方计费记录。模型内部完整请求体、隐藏推理、完整流式中间回答不在此采集范围。
6.1 Grafana 字段说明:一条 completed 审计日志里有什么
Grafana 的 Table 视图会把 Loki 的标签、日志正文与属性平铺成列。请先用 {service_name="codex-turn-audit"} 筛选,并优先查看 event_name=codex.turn_completed 的完成记录;原生临时事件或验证记录并不一定包含全部字段。
ℹ️ 当前字段版本(2026-07-20)
本表以 D:\Software\docker\codex-observability\audit-proxy\audit-proxy.cjs 当前逻辑为准。旧日志可能仍带有 conversation_visible_input、turn_final_answer、process_steps_json 或压缩步骤字段;这些是历史记录,不代表新写入规则。新记录保留 codex_input、codex_output 与 codex_text_storage,但不写入完整会话上下文和步骤 JSON。
| 字段组 | 字段 | 内容与使用方式 |
|---|---|---|
| 查询与来源 | service_name |
固定为 codex-turn-audit,用于筛选本方案写入的聚合审计日志。 |
| 查询与来源 | event_name、Line |
完成记录为 codex.turn_completed;Line 是便于阅读的日志正文,不是完整过程数据。 |
| 查询与来源 | source_service_name、env |
记录原始上报服务与环境;用于排查数据从哪里进入代理。 |
| 查询与来源 | status |
当前完成记录通常是 completed 的兼容字段;它不是实时状态机。 |
| 任务标识 | task_id |
代理为“本次用户输入到本次回答完成”生成的唯一 ID;一轮任务只对应一条 completed 日志。 |
| 任务标识 | thread_id、conversation_id |
Codex 侧栏会话 ID;同一侧栏任务中的多轮对话共享它。 |
| 任务标识 | turn_id |
Codex 完成回调提供的轮次 ID,可和 task_id 交叉核对,但不是代理的主键。 |
| 任务标识 | project_name、cwd |
工作目录末级名称及原始工作目录;用于按项目筛选。 |
| 任务标识 | session_name、session_name_source |
侧栏任务标题及其来源;优先读取 session_index.jsonl 的 thread_name,其他来源是候选标题或首条输入。 |
| 任务标识 | session_name_candidate、session_name_candidate_source |
notify 回调附带的标题候选及来源;仅用于代理选择最终 session_name,通常不需要放在表格中。 |
| 可见文本 | turn_user_input |
本轮最新用户问题;做逐任务对比时优先使用。 |
| 可见文本 | codex_input、codex_output |
短文本直接显示。任一字段超过 12,000 字符时,两者都改为 D:\Software\docker\codex-observability\log\<task_id>\codex_input.txt 与 codex_output.txt 的文件路径。 |
| 可见文本 | codex_text_storage |
inline 表示两段文本直接在 Grafana;files 表示两字段是本机文件路径;inline_fallback_after_file_write_error 表示文件落盘失败后的截断兜底。 |
| 采集说明 | input_message_count、content_capture_scope |
notify 回调收到的消息条数与文本采集范围说明;用于解释 codex_input 来自可见消息,而不是模型内部请求体。 |
| 不写入 Loki | conversation_visible_input、turn_final_answer |
完整可见会话上下文与最终回答别名不写入 Grafana;回调原文保留在本机 turns.jsonl。 |
| 时间关联 | task_start_time、task_end_time、task_duration_ms |
当前 task 从原生用户输入事件到完成回调的开始、结束与耗时。 |
| 时间关联 | session_start_time、session_end_time、session_duration_ms |
代理本次观察到的整个会话累计时间;不是 Codex 历史上绝对首次创建时间。 |
| 时间关联 | timing_match_status |
matched_native_user_prompt 表示已关联原生输入时间;callback_fallback 表示只能用完成回调兜底。 |
| Token | input_token_count、output_token_count |
本轮已关联到的输入 / 输出 token;实验总量建议自行计算两者之和。 |
| Token | reasoning_token_count、cached_token_count、tool_token_count |
原生事件在提供时才出现的细分统计;可分析但不要把 tool_token_count 当成稳定总 token。 |
| Token | token_match_status |
matched 才表示 token 已成功挂回本轮任务;not_found 时仍可看到计数与时间,但不适合做 token 对比。 |
| 不写入 Loki | process_steps_json、codex_collapsed_activity_json、codex_collapsed_activity_text |
逐步工具输入/输出和步骤摘要已移除;Grafana 仅保留下方的步骤与调用次数统计。 |
| 过程计数 | task_step_count、task_tool_call_count、task_command_count |
当前 task 的总步骤、工具调用、命令执行次数。 |
| 过程计数 | task_model_request_count、task_model_interaction_count |
当前 task 的模型请求尝试数与 response.completed 成功完成交互数;一次任务可以向模型请求多次。 |
| 会话计数 | session_step_count、session_tool_call_count、session_command_count、session_model_request_count、session_model_interaction_count |
从代理观察到本会话开始累计至当前 task 的总数;适合看整个侧栏任务的规模。 |
| OTel 元数据 | observed_timestamp、scope_name、scope_version、severity_number、severity_text、env |
OTel/Loki 传输元数据;排错时有用,一般不需要作为默认表格列。 |
例如,要找“模型交互超过 3 次且输入 token 很高”的任务,可先筛选 task_model_interaction_count、input_token_count,再按 task_id 到本机 turns.jsonl 核对可见输入与最终回答。
7. 文件、容器与日常维护
| 用途 | 路径 / 名称 |
|---|---|
| 一键开关 | D:\Software\docker\codex-observability\Codex 审计开关。cmd |
| 开关控制器 | C:\Users\xiaocai.codex\codex-audit-toggle.cjs |
| notify 包装器 | C:\Users\xiaocai.codex\codex-audit-notify.cjs |
| Docker Compose | D:\Software\docker\codex-observability\docker-compose.yml |
| 回调备份 | D:\Software\docker\codex-observability\audit\turns.jsonl |
| 超长输入/输出文件 | D:\Software\docker\codex-observability\log<task_id>\codex_input.txt、codex_output.txt |
| 原 LGTM 数据(保留作回退) | D:\Software\docker\codex-observability\data |
| 当前 Grafana 数据 | D:\Software\docker\codex-observability\data-recovered-20260719 |
| 审计专用 Loki 数据与配置 | D:\Software\docker\codex-observability\loki-data、D:\Software\docker\codex-observability\loki\loki-config.yaml |
7.1 可复现部署模块:需要部署什么
这不是“只启动 Grafana”就能完成的方案。可复现的最小部署由下表三个 Docker 服务、两个脚本和一段 Codex 配置共同组成;少任何一项,看到的字段都会不完整。
| 模块 | Docker 镜像 / 文件 | 宿主机端口 | 持久化位置 | 职责 | 不能替代什么 |
|---|---|---|---|---|---|
| Grafana UI 与遥测套件 | grafana/otel-lgtm:0.27.1 |
127.0.0.1:3000 |
data-recovered-20260719 |
提供 Grafana 页面、Explore 查询入口 | 不作为本方案审计日志的最终 Loki 写入端 |
| 审计专用 Loki | grafana/loki:3.0.0 |
不暴露 | loki-data |
保存 codex-turn-audit 审计日志,保留 30 天 |
不直接接收 Codex;只能由代理在 Docker 网络访问 |
| 审计合并代理 | node:22-alpine + audit-proxy.cjs |
127.0.0.1:4318 |
audit/ |
接收原生 OTel 与完成回调、按 task 合并 token/时间/计数、写入 Loki | 不显示 UI,不取代 Grafana |
| Codex 开关 | Codex 审计开关.cmd + codex-audit-toggle.cjs |
无 | audit/enabled.flag |
开启/关闭 OTel 与文本回调采集 | 不启动 Docker,也不能让已运行的 Codex 立即重新读取配置 |
| Codex 通知桥 | codex-audit-notify.cjs |
调用 4318 |
audit/turns.jsonl |
透传原通知,并把可见输入和最终回答作为完成事件提交 | 不包含模型隐藏推理、完整内部请求正文 |
目录约定(本机绝对路径,Docker Desktop 的共享盘必须允许访问 D: 与 C:):
D:\Software\docker\codex-observability\
├─ docker-compose.yml # 三个 Docker 服务
├─ audit-proxy\audit-proxy.cjs # 合并代理源码
├─ loki\loki-config.yaml # 独立 Loki 配置
├─ loki-data\ # Loki 日志数据(自动创建)
├─ data-recovered-20260719\ # Grafana/LGTM 数据(自动创建)
├─ audit\enabled.flag # 存在=采集开启
├─ audit\turns.jsonl # notify 回调备份
├─ log\<task_id>\ # 超长 codex_input / codex_output 的文本目录
│ ├─ codex_input.txt
│ └─ codex_output.txt
C:\Users\xiaocai\.codex\
├─ config.toml # 保留原配置,只补丁式修改 notify 与 [otel]
├─ codex-audit-toggle.cjs
├─ codex-audit-notify.cjs
└─ session_index.jsonl # Desktop 侧栏 task 标题;容器只读挂载
⚠️ 手工部署前的前置条件
需要 Windows Docker Desktop 已启动、当前用户对 D: 与 C: 有读写权限,并有可执行的 Node.js(本机开关/通知脚本使用;容器内代理自带 Node 22)。先执行 docker version、docker compose version 与 node --version。所有端口均绑定 127.0.0.1,不要改成 0.0.0.0。
7.2 手工部署步骤(从空目录开始)
- 创建上述目录;将本节的
docker-compose.yml、下一节的loki-config.yaml与本文已说明的三个脚本/代理源码保存到对应位置。 - 确认
C:\Users\xiaocai\.codex\session_index.jsonl已存在;若某个 Codex Desktop 版本没有该文件,可先移除该只读挂载,但session_name将只能退回到回调候选或首条输入,无法可靠等于侧栏任务名称。 - 使用本文第 1.1 节的开关控制器,以“补丁”方式修改
config.toml:开启时让[otel]指向http://127.0.0.1:4318/v1/logs,同时让notify经过codex-audit-notify.cjs;关闭时恢复 exporter 为none并禁止回调写审计。绝不能覆盖或删除 Codex Desktop 原有的--previous-notify链。 - 在部署目录执行
docker compose up -d;完成后用下面的检查命令确认三个容器均为Up。 - 在 Grafana
http://127.0.0.1:3000配置 Loki 数据源:URL 填http://codex-loki:3100(这是 Grafana 容器内部地址,不是宿主机地址)。 - 双击开关使状态变为开启,完全退出并重启 Codex Desktop,完成一次短任务;等待任务结束后约 8 秒,在 Explore / Loki 用
{service_name="codex-turn-audit"}查询。
Set-Location 'D:\Software\docker\codex-observability'
docker compose up -d
docker compose ps
docker logs --tail 50 codex-audit-proxy
Get-Content '.\audit\enabled.flag'
成功标准:codex-observability、codex-loki 与 codex-audit-proxy 都显示 Up;代理日志在任务结束后显示 Forwarded completed task ... token_match_status=matched;Grafana 一行记录有 task_id、turn_user_input、时间、token 与任务/会话计数字段。
☑️ 实际效果图 / GIF 待补充:从 Docker Desktop 到 Grafana 单行审计记录的完整验证
请补充真实截图或 GIF,展示:三个容器均为 Up、代理成功转发日志、Grafana 中同一 task 的文本与 token 字段。
建议文件名:截图资源/07-docker-deploy-and-audit-result.png。
7.3 Docker Compose 完整配置
文件:D:\Software\docker\codex-observability\docker-compose.yml。下面是当前可运行的完整内容;不要只复制其中某一个服务。
services:
grafana-otel-lgtm:
# 固定版本;2026-07-19 的 latest 内嵌 Loki 进入了永久 shutting down 状态。
image: grafana/otel-lgtm:0.27.1
container_name: codex-observability
restart: unless-stopped
ports:
# Grafana UI only. OTLP/HTTP is received by codex-audit-proxy below.
- "127.0.0.1:3000:3000"
environment:
ENABLE_LOGS_ALL: "true"
volumes:
# 旧 data 保留不动;新的 Grafana 工作数据写入独立目录。
- "D:/Software/docker/codex-observability/data-recovered-20260719:/data"
# 审计日志单独使用单机 Loki,避开 LGTM 便捷镜像中损坏的内嵌 Loki 写入器。
codex-loki:
image: grafana/loki:3.0.0
container_name: codex-loki
restart: unless-stopped
command: ["-config.file=/etc/loki/config.yaml"]
volumes:
- "D:/Software/docker/codex-observability/loki/loki-config.yaml:/etc/loki/config.yaml:ro"
- "D:/Software/docker/codex-observability/loki-data:/loki"
codex-audit-proxy:
image: node:22-alpine
container_name: codex-audit-proxy
restart: unless-stopped
depends_on:
- grafana-otel-lgtm
- codex-loki
ports:
# Codex 和 notify 包装器提交 OTLP/HTTP 的唯一宿主机入口。
- "127.0.0.1:4318:4318"
volumes:
- "D:/Software/docker/codex-observability/audit-proxy:/app:ro"
# 超长 codex_input / codex_output 落盘;Loki 记录对应 Windows 路径。
- "D:/Software/docker/codex-observability/log:/audit-log"
# Desktop 侧栏任务标题;缺失时代理会降级为回调标题或首条输入。
- "C:/Users/xiaocai/.codex/session_index.jsonl:/host/session_index.jsonl:ro"
command: ["node", "/app/audit-proxy.cjs"]
7.4 独立 Loki 的完整配置
文件:D:\Software\docker\codex-observability\loki\loki-config.yaml。
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
replication_factor: 1
ring:
kvstore:
store: inmemory
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/tsdb-index
cache_location: /loki/tsdb-cache
compactor:
working_directory: /loki/compactor
limits_config:
allow_structured_metadata: true
retention_period: 30d
analytics:
reporting_enabled: false
7.5 交给 Codex 直接复现的指令
把下面整段提示词交给同一台 Windows 电脑上的 Codex。它采用“先检查、再创建、最后验证”的方式,且明确不删除旧数据;这是比让 Codex 猜测环境更可靠的复现入口。
请在 Windows 本机部署 Codex 的本地审计栈,目标目录固定为 D:\Software\docker\codex-observability,且不得删除已有 data、loki-data、audit 或 config.toml 内容。
目标架构:
1) grafana/otel-lgtm:0.27.1,容器名 codex-observability,仅映射 127.0.0.1:3000:3000,数据卷 D:/Software/docker/codex-observability/data-recovered-20260719:/data;
2) grafana/loki:3.0.0,容器名 codex-loki,不映射宿主机端口,使用 /etc/loki/config.yaml,数据卷为 loki/loki-config.yaml 与 loki-data:/loki;Loki 配置为单机 filesystem + TSDB v13、3100 端口、30 天保留、allow_structured_metadata=true;
3) node:22-alpine,容器名 codex-audit-proxy,仅映射 127.0.0.1:4318:4318,运行 /app/audit-proxy.cjs,挂载 audit-proxy:/app:ro、D:/Software/docker/codex-observability/log:/audit-log、C:/Users/xiaocai/.codex/session_index.jsonl:/host/session_index.jsonl:ro;代理的上游必须是 http://codex-loki:3100/otlp/v1/logs。
代理功能验收:接收 /v1/logs 的 OTLP/JSON;按 conversation.id 与每次 codex.user_prompt 创建唯一 task_id;缓存 codex.sse_event/response.completed 的 token、codex.api_request、codex.tool_decision、codex.tool_result 等过程事件;收到 event_name=codex.turn_completed 后延迟约 8 秒,合并为唯一 service_name=codex-turn-audit 的完成日志。完成日志必须包含 task_id、thread_id、project_name、session_name(优先读取 session_index.jsonl 的 thread_name)、turn_user_input、codex_input、codex_output、input/output/reasoning/tool token、任务/会话开始结束时刻和时长、步骤/命令/工具/模型交互计数。每个 task 只写一条 completed 日志;`codex_input` 或 `codex_output` 任一超过 12,000 字符时,将两个字段改为 `log\<task_id>` 下文本文件的 Windows 路径;步骤 JSON 不写入 Loki。
大型内容保护:不把 conversation_visible_input、工具参数/输出或步骤 JSON 写入 Loki;codex_input / codex_output 短文本直接写入,任一超长时将两段原文保存到 D:\Software\docker\codex-observability\log\<task_id> 并在 Grafana 显示文件路径。这样既保留输入输出,又避免超过 Loki 的 64 KB 结构化元数据上限。
同时检查 C:\Users\xiaocai\.codex\config.toml。只补丁修改 [otel] 与 notify:开启时 endpoint=http://127.0.0.1:4318/v1/logs、protocol=json、log_user_prompt=true;notify 必须先透传 Codex Desktop 原有 --previous-notify,再由 codex-audit-notify.cjs 在 enabled.flag 存在时向代理提交输入和最终回答。不要覆盖其他配置,也不要破坏原桌面通知。提供 Codex 审计开关.cmd 与 codex-audit-toggle.cjs,使它能创建/删除 enabled.flag 并切换 OTel;说明切换后必须重启 Codex。
执行 docker compose up -d,检查三容器均为 Up;Grafana 数据源 Loki 指向 http://codex-loki:3100;最后输出状态、实际修改的文件路径、docker compose ps、代理最近 50 行日志,以及在 Grafana Explore 中验证 {service_name="codex-turn-audit"} 的步骤。不要执行 docker compose down -v、不要删除任何历史目录。
容器首次启动或 Docker Desktop 重启后,执行:
Set-Location 'D:\Software\docker\codex-observability'
docker compose up -d
docker compose ps
Grafana 仅绑定 127.0.0.1:3000,OTLP 合并代理仅绑定 127.0.0.1:4318;codex-loki 不映射宿主机端口,只由 Docker 内部网络访问,因此不会暴露到局域网。
⚠️ 2026-07-19 的日志不显示故障与恢复
grafana/otel-lgtm:latest 的内嵌 Loki 虽然让容器显示 healthy,但写入返回 503 Ingester is shutting down;这会让 Collector 接收请求却无法落盘。重启、换新数据目录和固定 0.27.1 后仍复现,因此当前方案将 Grafana UI 与 Codex 审计 Loki 分离:代理直接将 OTLP/HTTP 写到 codex-loki:3100/otlp/v1/logs,Grafana 的 Loki 数据源指向 http://codex-loki:3100。旧 data 没有删除;新的审计记录可用 {service_name="codex-turn-audit"} 查询。
ℹ️ 轻量日志与大小保护
为避免长会话超过 Loki 64 KB 的结构化元数据上限,完成记录不会写入 conversation_visible_input、turn_final_answer、工具参数/输出、process_steps_json 或压缩步骤字段。codex_input 与 codex_output 会保留:短文本直接显示;任一超过 12,000 字符时,代理把两个字段分别保存为 D:\Software\docker\codex-observability\log\<task_id>\codex_input.txt 与 codex_output.txt,Grafana 中显示这两个路径。turns.jsonl 仍保留 notify 回调备份。
停止容器但保留历史数据:
Set-Location 'D:\Software\docker\codex-observability'
docker compose stop
🚨 删除历史数据
不要执行 docker compose down -v,也不要直接删除 data 或 audit 目录,除非确认不再需要历史记录并已备份。这些文件含输入和回答正文,删除后无法恢复。
7.6. 本地手动部署
7.6.1. 本地离线部署文件夹

7_1离线部署文件夹
7.6.2. 文件夹内文件说明
D:\Software\Docker\codex-observability\codex-baks 是一份可离线恢复的审计环境备份包:既保存 Docker 部署快照,也保存镜像和 Codex 本机侧脚本。恢复时应优先阅读 README.md,再按目标系统选择恢复脚本。

codex-baks 备份包组成
| 文件或文件夹 | 作用 |
|---|---|
codex-observability/ |
审计 Docker 部署快照。包含 docker-compose.yml、代理代码、Grafana/Loki 配置、持久化数据及已落盘审计文本;恢复时复制到实际部署目录。 |
codex-observability/docker-compose.yml |
定义 codex-audit-proxy、Loki、Grafana/OTel LGTM 等容器及卷挂载,是启动部署的入口。 |
codex-observability/audit-proxy/ |
审计代理源码。audit-proxy.cjs 接收 Codex OTel/完成事件、关联 token 与会话信息后转发到 Loki;session-title-overrides.json 存放会话标题覆盖配置。 |
codex-observability/audit/ |
本地审计侧的辅助数据;turns.jsonl 为完成回合缓存,generated-images/ 保存审计中生成图片的引用/占位文件。 |
codex-observability/data/ |
LGTM 容器的数据卷快照,含 Grafana 数据库、插件,以及 Loki、Prometheus、Tempo、Pyroscope 的数据目录。 |
codex-observability/data-recovered-20260719/ |
一次历史恢复得到的数据保留副本,用于回溯或比对;不应与当前 data/ 混用。 |
codex-observability/log/ |
超长输入、输出等不适合直接作为 Loki 属性保存的审计文本文件。 |
codex-observability/loki/ |
Loki 配置目录,核心文件是 loki-config.yaml。 |
codex-observability/loki-data/ |
独立 Loki 容器的数据目录,含 chunk、索引、WAL、规则与压缩缓存。 |
codex-observability/Codex 审计开关.cmd |
Windows 下的审计启停入口,调用本机的审计开关脚本。 |
images-tar/ |
离线 Docker 镜像包:grafana-otel-lgtm_0.27.1.tar、grafana-loki_3.0.0.tar、node_22-alpine.tar;目标机器无外网时先 docker load 导入。 |
codex-client-scripts/ |
Codex 客户端脚本备份。codex-audit-notify.cjs 在回合结束时写入/发送审计完成事件;codex-audit-toggle.cjs 负责启用或关闭本机审计配置。 |
README.md |
备份包总览、Windows 手动恢复命令和 Linux 恢复说明。 |
备份-Codex审计环境.ps1 |
Windows 备份脚本:短暂停止相关容器、复制部署快照、导出三个镜像 tar,最后恢复原有容器状态。 |
恢复-Codex审计环境.ps1 |
Windows 恢复脚本:导入镜像、复制部署快照和客户端脚本,并启动 Compose 服务。 |
恢复-容器镜像.cmd |
Windows 恢复脚本的双击启动包装器。 |
restore-codex-audit-linux.sh |
Linux 恢复脚本:导入镜像、复制部署快照、将 Windows 挂载路径改写为 Linux 路径、安装客户端脚本后启动容器。 |
💡 Tip
data/、loki-data/ 和 audit/turns.jsonl 都包含历史审计信息。若只需要重新搭建空环境,可以保留配置与脚本、另行初始化数据目录;若目标是“原样恢复”,则应完整保留这些目录。
8. 部署使用时的疑难杂症
8.1 NO_PROXY 代理问题:完成回调有日志,但 token 始终为空
现象
新 Windows 电脑将原生 OTel exporter 指向远程服务器:
[otel]
log_user_prompt = true
exporter = { otlp-http = { endpoint = "http://192.168.1.53:4318/v1/logs", protocol = "json" } }
Grafana 中能够看到一条 codex.turn_completed,也有 codex_input、codex_output,但没有 input_token_count、output_token_count、reasoning_token_count。代理调试日志只有:
AUDIT_DEBUG received_event ... event_name_underscore="codex.turn_completed"
AUDIT_DEBUG token_dequeued ... token_matched=false
没有 is_token_event=true 或 token_queued。
根因
新电脑 C:\Users\<用户名>\.codex\.env 的 NO_PROXY 没有包含远程审计服务器 192.168.1.53。Codex 原生 OTel exporter 会受系统/进程代理环境影响;该地址未被排除时,原生过程事件可能被代理接管、阻断或路由失败。notify 完成回调可以抵达,不代表原生 OTel 的批量事件也能抵达。
修复
在 运行 Codex 的 Windows 电脑 的 .env 中保留原有值并追加服务器 IP:
NO_PROXY=localhost,127.0.0.1,::1,192.168.1.53
如环境同时使用小写变量,也同步设置:
no_proxy=localhost,127.0.0.1,::1,192.168.1.53
随后完全退出并重启 Codex Desktop,再发起一轮新任务。不要只关闭当前聊天窗口。
验证
在 Linux 服务器执行:
docker logs --tail 300 codex-audit-proxy | grep AUDIT_DEBUG
预期顺序包含:
AUDIT_DEBUG received_event ... is_prompt_event:true
AUDIT_DEBUG received_event ... is_token_event:true
AUDIT_DEBUG token_queued ...
AUDIT_DEBUG token_dequeued ... token_matched:true
完成排查后,将 Compose 中 AUDIT_DEBUG_EVENT_SCHEMA 改回 "0",然后执行 docker compose up -d,避免长期输出调试元数据。
⚠️ 远程直连的端口边界
本机部署默认将 4318 绑定为 127.0.0.1:4318:4318,适合 Docker 与 Codex 同机或通过 SSH 隧道使用。若 Windows 客户端直接请求 192.168.1.53:4318,远程服务器必须显式开放该端口,并通过防火墙仅允许可信内网地址;不要把未认证的审计入口暴露到公网。
8.2 原生事件存在但仍没有 token:按字段与关联键排查
input_token_count 不是 notify 计算出来的,它只来自原生 OTel 的:
event.name = codex.sse_event
event.kind = response.completed
代理以原生事件的 conversation.id 为键入队,再以完成回调的 thread_id 出队。若二者不一致,或不同版本把字段改为下划线命名,也会出现 token_match_status=not_found。
排查时保留 AUDIT_DEBUG_EVENT_SCHEMA: "1",比对以下字段,而不是只看最终 completed 行:
| 需要对比的内容 | 正常情况 | 异常时的含义 |
|---|---|---|
event_name_dot / event_name_underscore |
能识别为 codex.sse_event |
事件字段名可能变化,需做兼容映射 |
event_kind_dot / event_kind_underscore |
能识别为 response.completed |
不是 token 完成事件,或事件类型变化 |
conversation_id_dot |
与完成回调 thread_id 相同 |
不同则 token 进入另一会话队列 |
token_fields |
至少 token 字段为 true |
原生事件自身未导出 token,代理无法伪造 |
token_queued → token_dequeued |
同一轮 token_matched:true |
缺前者是上游未到;后者为 false 是关联/时序失败 |
ℹ️ 当前已验证的事实
Codex 的原生 OTel 会发出 codex.api_request、codex.sse_event、codex.user_prompt、工具事件等;response.completed 承载 token 统计。若代理收不到这些事件,优先检查 exporter 地址、NO_PROXY、防火墙和完全重启,而不是改造 token 计算逻辑。
8.3 Docker 在 Linux 服务器时,session_name 不是侧栏标题
现象与原因
Docker 在 Linux 服务器而 Codex Desktop 在 Windows 时,服务器上的 /host/session_index.jsonl 不可能自动成为 Windows 的 C:\Users\<用户名>\.codex\session_index.jsonl。恢复脚本若仅把挂载路径转换到 Linux 本地,只能读到服务器自身的文件,导致 session_name 回退成首条输入、会话 ID 或 unknown。
当前方案
C:\Users\<用户名>\.codex\codex-audit-notify.cjs 会在 Windows 本机读取 session_index.jsonl,按完成回调的 thread-id 查找 thread_name,并把以下字段随 codex.turn_completed 发给远程代理:
session_name_candidate
session_name_candidate_source=codex_session_index
因此无需同步整个 Windows .codex 目录到 Linux。验证最终日志:
session_name=侧边栏任务名称
session_name_source=codex_session_index
9. 隐私与使用约定
- 默认关闭审计;只在明确的学习、验证或对照实验期间开启。
- 开启后,输入和最终回答会以明文进入本机 Loki 与 turns.jsonl;不要在涉及密码、API Key、个人隐私或生产机密的会话中启用。
- 每次 CodeGraph 对比实验结束后,双击一次开关关闭审计,并在确认 Codex 已完全重启后恢复正常使用。
- Docker 常驻只占用本机服务资源;真正涉及内容采集的是“开关开启 + 重启后的新会话”。
参考资料
更多推荐

所有评论(0)