ℹ️ 读者定位

这篇记录面向在 Windows + Codex Desktop 上做本机学习、验证或对照实验的人。前提是 Docker Desktop 已可用或有linux服务器。完成后,可以在 Grafana 中按轮查看可见输入、最终回答和 token 统计。

📄 要解决的不是计费,而是学习

目标是把每次 Codex 会话中可获得的任务 ID、项目名、会话名、最新用户问题、开始/结束时间、时长、token 与过程计数放到同一条轻量审计记录中,方便观察 Codex 如何调用模型。codex_inputcodex_output 会保留在 Loki;超长时改为本机文件路径。完整回调原文仍留在 turns.jsonl

0. 先看效果

0.1. Codex原生查看Token消耗

1_0先看效果
1_0先看效果

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

1_1先看效果
1_1先看效果
1_2先看效果
1_2先看效果
1_3先看效果
1_3先看效果
1_4先看效果
1_4先看效果
1_5先看效果
1_5先看效果

1. 最终方案:默认关闭,双击一次开关

Codex 本机审计最终整体架构
Codex 本机审计最终整体架构

日常使用保持关闭。需要做实验时,只做两件事:

  1. 双击 D:\Software\docker\codex-observability\Codex 审计开关。cmd。
  2. 完全退出并重新打开 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 本机审计实现原理
Codex 本机审计实现原理

要得到“每轮任务只有一条、同时含任务标识、token、耗时与过程计数”的轻量记录,不能只依赖单一来源。Codex 暴露的是两条互补事件流:

  1. 原生 OTel 事件流:随任务运行持续到达,包含用户输入开始、模型 response.completed 的 token、工具决策、工具参数和工具结果。它适合还原过程,却不保证提供完整最终回答。
  2. 任务完成 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_idthread_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
审计开关控制器:备份配置、更新 OTel 与同步 enabled.flag

开关后的状态 config.toml[otel] enabled.flag 实际效果
开启 log_user_prompt = trueexporter = { otlp-http = ... } 创建并写入 enabled 重启 Codex 后导出原生事件;notify 开始保留可见文本
关闭 log_user_prompt = falseexporter = "none" 删除 重启 Codex 后不再导出,也不再写审计文本

关键实现如下。getOtelTableRangesetTableKey 保证只更新 [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.tomlnotify 链调用;配置中把 Codex 原有的桌面通知程序作为第一个参数传入,因此包装器可以保持原通知体验。

notify 文本桥接器:先转发桌面通知,再按开关旁路审计
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-messageslast-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_inputcodex_input 当前侧栏中可见的输入上下文
最后一条 input-messages turn_user_input 本轮真正的新提问
last-assistant-message turn_final_answercodex_output 本轮最终回答,不是模型隐藏推理
thread-idturn-id thread_idturn_id 与 Codex 会话/回调交叉核对
cwd cwdproject_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 日志:

  1. Codex 原生 OTel:codex.user_promptcodex.api_requestcodex.sse_event/response.completedcodex.tool_decisioncodex.tool_result 等;其中 response.completed 提供 token 统计。
  2. codex-audit-notify.cjs 的完成回调:携带 codex_inputcodex_outputthread_id、工作目录和会话标题候选。

代理按 conversation.id/thread_id 建立会话缓存;看到每个 codex.user_prompt 时生成唯一 task_id,缓存这一轮的步骤和 token。完成回调到达后不立即写 Loki,而是等待 8 秒;这样晚到的 response.completed 可以和本轮文本正确配对。到期后它清除不应保存的长上下文/步骤 JSON,保留汇总数字和 codex_inputcodex_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

createTurncodex.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.completedinput_token_countoutput_token_countcached_token_countreasoning_token_counttool_token_count 放进会话队列。flushAudits 每秒检查完成回调队列;满 8 秒后取出同一会话的一组 token 和一轮 task,调用 addCompletionAttributes 写入最终字段。

codex_inputcodex_output 两者都不超过 12,000 字符时直接写 Loki;任一超长时,两个原文会分别写入 D:\Software\docker\codex-observability\log\<task_id>\codex_input.txtcodex_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_eventtoken_queuedtoken_dequeued,只包含事件名、属性键名、会话关联 ID 和 token 字段是否存在,不写入输入、输出、工具参数或工具结果。对比两台机器的 conversation_id_dotcompletion_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 本机审计方案演变过程

第一步只启用 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 本机审计合并流程图
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 中怎样看一轮完整记录

  1. 打开 http://localhost:3000,进入 Explore,选择 Loki
  2. 查询本机审计服务:
{service_name="codex-turn-audit"} |= "Codex task audit"
  1. 切换到表格视图,添加以下当前版本的推荐字段。这也是建议保存为 Explore 默认列的顺序:
必看字段 用途
Line 日志正文;用于快速确认这是一条 Codex task audit 完成记录。
task_id 每次用户输入到回答完成的一轮任务的唯一 ID。首次用户输入时在代理内生成,并在任务完成时写入唯一的一条审计记录;它不等于 thread_id。
project_name、session_name 当前项目与 Codex 侧栏任务标题;先用于定位任务。
codex_input、codex_output 本轮可见输入与最终回答。短文本直接显示;超长时显示 log\<task_id>\codex_input.txtcodex_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_counttask_tool_call_counttask_command_counttask_model_request_counttask_model_interaction_count 观察过程规模。codex_input / codex_output 保留;超长时显示其本机文件路径,完整回调原文仍保存在 D:\Software\docker\codex-observability\audit\turns.jsonl

6. 这套审计可以做什么

它的目的不是核对 OpenAI 的账单,也不是取得模型的隐藏推理,而是把一轮 Codex 任务的标识、最新问题、token、耗时与过程计数放在同一条可检索记录里。适合在本机做下面几类学习和验证。

可以做的事 在 Grafana 中看什么 能回答的问题
定位一次任务 task_idturn_user_inputtask_duration_ms 这是哪一轮任务,用户问了什么,任务花了多久?
诊断“为什么慢” task_duration_mstask_model_request_counttask_tool_call_count 模型往返与工具/命令调用次数是否异常偏高?
对比两种工作流 固定任务后比较 input/output token、时长、轮数、工具数与结果质量 例如启用/关闭 CodeGraph、不同 MCP、不同提示词策略,是否真的更有效?
观察输入规模与 token turn_user_inputinput_token_countoutput_token_count 哪类任务 token 更高?是否伴随更长时长或更多模型交互?
检查工具使用习惯 task_command_counttask_tool_call_counttask_step_count 是否出现过多命令、工具调用或模型往返?
按任务或会话回溯 task_idthread_idsession_nameproject_name 某个侧栏任务下有几轮对话?某个项目的任务表现如何?
验证采集是否完整 token_match_statustiming_match_statuscontent_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_inputturn_final_answerprocess_steps_json 或压缩步骤字段;这些是历史记录,不代表新写入规则。新记录保留 codex_inputcodex_outputcodex_text_storage,但不写入完整会话上下文和步骤 JSON。

字段组 字段 内容与使用方式
查询与来源 service_name 固定为 codex-turn-audit,用于筛选本方案写入的聚合审计日志。
查询与来源 event_nameLine 完成记录为 codex.turn_completedLine 是便于阅读的日志正文,不是完整过程数据。
查询与来源 source_service_nameenv 记录原始上报服务与环境;用于排查数据从哪里进入代理。
查询与来源 status 当前完成记录通常是 completed 的兼容字段;它不是实时状态机。
任务标识 task_id 代理为“本次用户输入到本次回答完成”生成的唯一 ID;一轮任务只对应一条 completed 日志。
任务标识 thread_idconversation_id Codex 侧栏会话 ID;同一侧栏任务中的多轮对话共享它。
任务标识 turn_id Codex 完成回调提供的轮次 ID,可和 task_id 交叉核对,但不是代理的主键。
任务标识 project_namecwd 工作目录末级名称及原始工作目录;用于按项目筛选。
任务标识 session_namesession_name_source 侧栏任务标题及其来源;优先读取 session_index.jsonlthread_name,其他来源是候选标题或首条输入。
任务标识 session_name_candidatesession_name_candidate_source notify 回调附带的标题候选及来源;仅用于代理选择最终 session_name,通常不需要放在表格中。
可见文本 turn_user_input 本轮最新用户问题;做逐任务对比时优先使用。
可见文本 codex_inputcodex_output 短文本直接显示。任一字段超过 12,000 字符时,两者都改为 D:\Software\docker\codex-observability\log\<task_id>\codex_input.txtcodex_output.txt 的文件路径。
可见文本 codex_text_storage inline 表示两段文本直接在 Grafana;files 表示两字段是本机文件路径;inline_fallback_after_file_write_error 表示文件落盘失败后的截断兜底。
采集说明 input_message_countcontent_capture_scope notify 回调收到的消息条数与文本采集范围说明;用于解释 codex_input 来自可见消息,而不是模型内部请求体。
不写入 Loki conversation_visible_inputturn_final_answer 完整可见会话上下文与最终回答别名不写入 Grafana;回调原文保留在本机 turns.jsonl
时间关联 task_start_timetask_end_timetask_duration_ms 当前 task 从原生用户输入事件到完成回调的开始、结束与耗时。
时间关联 session_start_timesession_end_timesession_duration_ms 代理本次观察到的整个会话累计时间;不是 Codex 历史上绝对首次创建时间。
时间关联 timing_match_status matched_native_user_prompt 表示已关联原生输入时间;callback_fallback 表示只能用完成回调兜底。
Token input_token_countoutput_token_count 本轮已关联到的输入 / 输出 token;实验总量建议自行计算两者之和。
Token reasoning_token_countcached_token_counttool_token_count 原生事件在提供时才出现的细分统计;可分析但不要把 tool_token_count 当成稳定总 token。
Token token_match_status matched 才表示 token 已成功挂回本轮任务;not_found 时仍可看到计数与时间,但不适合做 token 对比。
不写入 Loki process_steps_jsoncodex_collapsed_activity_jsoncodex_collapsed_activity_text 逐步工具输入/输出和步骤摘要已移除;Grafana 仅保留下方的步骤与调用次数统计。
过程计数 task_step_counttask_tool_call_counttask_command_count 当前 task 的总步骤、工具调用、命令执行次数。
过程计数 task_model_request_counttask_model_interaction_count 当前 task 的模型请求尝试数与 response.completed 成功完成交互数;一次任务可以向模型请求多次。
会话计数 session_step_countsession_tool_call_countsession_command_countsession_model_request_countsession_model_interaction_count 从代理观察到本会话开始累计至当前 task 的总数;适合看整个侧栏任务的规模。
OTel 元数据 observed_timestampscope_namescope_versionseverity_numberseverity_textenv OTel/Loki 传输元数据;排错时有用,一般不需要作为默认表格列。

例如,要找“模型交互超过 3 次且输入 token 很高”的任务,可先筛选 task_model_interaction_countinput_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 versiondocker compose versionnode --version。所有端口均绑定 127.0.0.1,不要改成 0.0.0.0

7.2 手工部署步骤(从空目录开始)

  1. 创建上述目录;将本节的 docker-compose.yml、下一节的 loki-config.yaml 与本文已说明的三个脚本/代理源码保存到对应位置。
  2. 确认 C:\Users\xiaocai\.codex\session_index.jsonl 已存在;若某个 Codex Desktop 版本没有该文件,可先移除该只读挂载,但 session_name 将只能退回到回调候选或首条输入,无法可靠等于侧栏任务名称。
  3. 使用本文第 1.1 节的开关控制器,以“补丁”方式修改 config.toml:开启时让 [otel] 指向 http://127.0.0.1:4318/v1/logs,同时让 notify 经过 codex-audit-notify.cjs;关闭时恢复 exporter 为 none 并禁止回调写审计。绝不能覆盖或删除 Codex Desktop 原有的 --previous-notify 链。
  4. 在部署目录执行 docker compose up -d;完成后用下面的检查命令确认三个容器均为 Up
  5. 在 Grafana http://127.0.0.1:3000 配置 Loki 数据源:URL 填 http://codex-loki:3100(这是 Grafana 容器内部地址,不是宿主机地址)。
  6. 双击开关使状态变为开启,完全退出并重启 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-observabilitycodex-lokicodex-audit-proxy 都显示 Up;代理日志在任务结束后显示 Forwarded completed task ... token_match_status=matched;Grafana 一行记录有 task_idturn_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 UICodex 审计 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_inputturn_final_answer、工具参数/输出、process_steps_json 或压缩步骤字段。codex_inputcodex_output 会保留:短文本直接显示;任一超过 12,000 字符时,代理把两个字段分别保存为 D:\Software\docker\codex-observability\log\<task_id>\codex_input.txtcodex_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_1离线部署文件夹

7.6.2. 文件夹内文件说明

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

codex-baks 备份包组成
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.targrafana-loki_3.0.0.tarnode_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_inputcodex_output,但没有 input_token_countoutput_token_countreasoning_token_count。代理调试日志只有:

AUDIT_DEBUG received_event ... event_name_underscore="codex.turn_completed"
AUDIT_DEBUG token_dequeued ... token_matched=false

没有 is_token_event=truetoken_queued

根因

新电脑 C:\Users\<用户名>\.codex\.envNO_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_queuedtoken_dequeued 同一轮 token_matched:true 缺前者是上游未到;后者为 false 是关联/时序失败

ℹ️ 当前已验证的事实

Codex 的原生 OTel 会发出 codex.api_requestcodex.sse_eventcodex.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 常驻只占用本机服务资源;真正涉及内容采集的是“开关开启 + 重启后的新会话”。

参考资料

更多推荐