@traice/codex-collector 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/collector.mjs CHANGED
@@ -1,85 +1,109 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from 'node:crypto';
3
- import { execFileSync } from 'node:child_process';
4
- import { createServer } from 'node:http';
5
- import {
6
- appendFileSync,
7
- existsSync,
8
- mkdirSync,
9
- readFileSync,
10
- readdirSync,
11
- statSync,
12
- writeFileSync,
13
- } from 'node:fs';
14
- import { homedir } from 'node:os';
15
- import { basename, dirname, join, resolve } from 'node:path';
16
- import { gunzipSync } from 'node:zlib';
17
-
18
- const DEFAULT_OUT_DIR = '~/.traice/internal-spend-codex';
19
- const LEGACY_OUT_DIR = '.traice-internal-spend';
2
+ import { createHash } from "node:crypto";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createServer } from "node:http";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
+ import { gunzipSync } from "node:zlib";
9
+ import { readApiKeyCredential, storeApiKey } from "./credentials.mjs";
10
+
11
+ const DEFAULT_OUT_DIR = "~/.traice/internal-spend-codex";
12
+ const LEGACY_OUT_DIR = ".traice-internal-spend";
20
13
  const DEFAULT_INSTALL_CONFIG = `${DEFAULT_OUT_DIR}/install.json`;
21
14
  const LEGACY_INSTALL_CONFIG = `${LEGACY_OUT_DIR}/install.json`;
22
15
  const DEFAULT_MAX_BATCH_SIZE = 50;
23
16
  const MAX_SENT_IDS = 20_000;
17
+ const collectorStartedAtMs = Date.now();
18
+ const pendingTurnLatency = new Map();
24
19
 
25
20
  const args = parseArgs(process.argv.slice(2));
26
- loadEnvFile(resolve('apps/web/.env'));
21
+ const inheritedApiKey = process.env.TRAICE_API_KEY;
22
+ loadEnvFile(resolve("apps/web/.env"));
27
23
 
28
- const configPath = args.get('config') ? resolveHome(args.get('config')) : findDefaultInstallConfigPath();
29
- const outDir = resolveHome(args.get('out') ?? dirname(configPath));
24
+ const configPath = args.get("config") ? resolveHome(args.get("config")) : findDefaultInstallConfigPath();
25
+ const outDir = resolveHome(args.get("out") ?? dirname(configPath));
30
26
  const installConfig = readJsonIfExists(configPath) ?? {};
27
+ const apiKey = await resolveCollectorApiKey();
31
28
  const config = {
32
- serverUrl: normalizeUrl(args.get('server-url') ?? installConfig.serverUrl ?? process.env.TRAICE_API_URL ?? 'http://127.0.0.1:3003'),
33
- apiKey: readApiKey() ?? installConfig.apiKey ?? process.env.TRAICE_API_KEY,
34
- workspaceSlug: args.get('workspace-slug') ?? installConfig.workspaceSlug ?? 'internal-spend-poc',
35
- sourceKey: args.get('source-key') ?? installConfig.sourceKey ?? 'codex-local',
36
- sourceName: args.get('source-name') ?? installConfig.sourceName ?? 'Codex local collector',
37
- sourceKind: args.get('source-kind') ?? installConfig.sourceKind ?? 'codex_otel',
38
- tool: args.get('tool') ?? installConfig.tool ?? 'codex',
39
- category: args.get('category') ?? installConfig.category ?? 'coding_agent',
40
- employeeEmail: args.get('employee-email') ?? installConfig.employeeEmail ?? process.env.TRAICE_EMPLOYEE_EMAIL,
41
- employeeName: args.get('employee-name') ?? installConfig.employeeName ?? process.env.TRAICE_EMPLOYEE_NAME,
42
- employeeExternalId: args.get('employee-external-id') ?? installConfig.employeeExternalId,
43
- teamName: args.get('team-name') ?? installConfig.teamName ?? process.env.TRAICE_TEAM_NAME,
44
- teamExternalId: args.get('team-external-id') ?? installConfig.teamExternalId,
45
- sourcePrincipal: args.get('source-principal') ?? installConfig.sourcePrincipal,
29
+ serverUrl: normalizeUrl(
30
+ args.get("server-url") ?? installConfig.serverUrl ?? process.env.TRAICE_API_URL ?? "http://127.0.0.1:3003",
31
+ ),
32
+ apiKey,
33
+ workspaceSlug: args.get("workspace-slug") ?? installConfig.workspaceSlug ?? "internal-spend-poc",
34
+ sourceKey: args.get("source-key") ?? installConfig.sourceKey ?? "codex-local",
35
+ sourceName: args.get("source-name") ?? installConfig.sourceName ?? "Codex local collector",
36
+ sourceKind: args.get("source-kind") ?? installConfig.sourceKind ?? "codex_otel",
37
+ tool: args.get("tool") ?? installConfig.tool ?? "codex",
38
+ category: args.get("category") ?? installConfig.category ?? "coding_agent",
39
+ employeeEmail: args.get("employee-email") ?? installConfig.employeeEmail ?? process.env.TRAICE_EMPLOYEE_EMAIL,
40
+ employeeName: args.get("employee-name") ?? installConfig.employeeName ?? process.env.TRAICE_EMPLOYEE_NAME,
41
+ employeeExternalId: args.get("employee-external-id") ?? installConfig.employeeExternalId,
42
+ teamName: args.get("team-name") ?? installConfig.teamName ?? process.env.TRAICE_TEAM_NAME,
43
+ teamExternalId: args.get("team-external-id") ?? installConfig.teamExternalId,
44
+ sourcePrincipal: args.get("source-principal") ?? installConfig.sourcePrincipal,
46
45
  seatMonthlyUsd: parseOptionalMoney(
47
- args.get('seat-monthly-usd') ?? installConfig.seatMonthlyUsd ?? process.env.TRAICE_SEAT_MONTHLY_USD,
46
+ args.get("seat-monthly-usd") ?? installConfig.seatMonthlyUsd ?? process.env.TRAICE_SEAT_MONTHLY_USD,
47
+ ),
48
+ codexHome: resolveHome(args.get("codex-home") ?? installConfig.codexHome ?? process.env.CODEX_HOME ?? "~/.codex"),
49
+ listenHost: args.get("listen-host") ?? installConfig.listenHost ?? "127.0.0.1",
50
+ listenPort: parsePort(args.get("listen-port") ?? installConfig.listenPort ?? "4318"),
51
+ pollIntervalMs: parsePositiveInt(args.get("poll-interval-ms") ?? installConfig.pollIntervalMs ?? "3000"),
52
+ backfillHours: parseBackfillHours(
53
+ args.get("backfill-hours") ??
54
+ (Number(installConfig.version ?? 1) >= 2 ? installConfig.backfillHours : undefined) ??
55
+ "all",
48
56
  ),
49
- codexHome: resolveHome(args.get('codex-home') ?? installConfig.codexHome ?? process.env.CODEX_HOME ?? '~/.codex'),
50
- listenHost: args.get('listen-host') ?? installConfig.listenHost ?? '127.0.0.1',
51
- listenPort: parsePort(args.get('listen-port') ?? installConfig.listenPort ?? '4318'),
52
- pollIntervalMs: parsePositiveInt(args.get('poll-interval-ms') ?? installConfig.pollIntervalMs ?? '3000'),
53
- backfillHours: Number(args.get('backfill-hours') ?? installConfig.backfillHours ?? 6),
54
- includeZeroOutput: boolArg(args.get('include-zero-output') ?? installConfig.includeZeroOutput, false),
55
- maxBatchSize: parsePositiveInt(args.get('max-batch-size') ?? installConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE),
57
+ includeZeroOutput: boolArg(args.get("include-zero-output") ?? installConfig.includeZeroOutput, false),
58
+ maxBatchSize: parsePositiveInt(args.get("max-batch-size") ?? installConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE),
56
59
  };
57
60
 
61
+ async function resolveCollectorApiKey() {
62
+ const explicitApiKey = readApiKey() ?? inheritedApiKey;
63
+ if (explicitApiKey) return explicitApiKey;
64
+ if (installConfig.apiKeyCredential) return readApiKeyCredential(installConfig.apiKeyCredential);
65
+ if (installConfig.apiKey) {
66
+ const stored = await storeApiKey(configPath, installConfig.apiKey, "auto");
67
+ const migrated = { ...installConfig, apiKeyCredential: stored.credential };
68
+ delete migrated.apiKey;
69
+ writeFileSync(configPath, `${JSON.stringify(migrated, null, 2)}\n`, { mode: 0o600 });
70
+ if (stored.warning) console.warn(`[codex-otel] ${stored.warning}`);
71
+ return readApiKeyCredential(stored.credential);
72
+ }
73
+ return undefined;
74
+ }
75
+
58
76
  if (!config.apiKey) {
59
77
  console.error(
60
78
  [
61
- 'Missing collector API key.',
62
- 'Run: npx @traice/codex-collector@latest install',
63
- 'Provide the key with TRAICE_API_KEY or --api-key-stdin.',
64
- ].join('\n'),
79
+ "Missing collector API key.",
80
+ "Run: npx @traice/codex-collector@latest install",
81
+ "Provide the key with TRAICE_API_KEY or --api-key-stdin.",
82
+ ].join("\n"),
65
83
  );
66
84
  process.exit(2);
67
85
  }
68
86
 
69
87
  mkdirSync(outDir, { recursive: true });
70
- const rawFile = resolve(outDir, 'requests.ndjson');
71
- const eventsFile = resolve(outDir, 'events.ndjson');
72
- const statePath = resolve(outDir, 'collector-state.json');
88
+ const rawFile = resolve(outDir, "requests.ndjson");
89
+ const eventsFile = resolve(outDir, "events.ndjson");
90
+ const statePath = resolve(outDir, "collector-state.json");
73
91
  const state = loadState(statePath);
92
+ const destinationKey = hashObject({ serverUrl: config.serverUrl, apiKeyPrefix: config.apiKey.slice(0, 12) });
93
+ if (state.destinationKey !== destinationKey) {
94
+ state.files = {};
95
+ state.sent = {};
96
+ state.destinationKey = destinationKey;
97
+ }
74
98
 
75
- const shouldRunOnce = args.get('once') === 'true';
76
- const sessionTailEnabled = args.get('session-tail') !== 'false';
77
- const otelListenerEnabled = args.get('otel-listener') !== 'false' && !shouldRunOnce;
99
+ const shouldRunOnce = args.get("once") === "true";
100
+ const sessionTailEnabled = args.get("session-tail") !== "false";
101
+ const otelListenerEnabled = args.get("otel-listener") !== "false" && !shouldRunOnce;
78
102
 
79
103
  if (shouldRunOnce) {
80
104
  const result = sessionTailEnabled ? await scanCodexSessions() : { candidates: 0, sent: 0, files: 0 };
81
105
  saveState(statePath, state);
82
- console.log(JSON.stringify({ ok: true, mode: 'once', ...result }, null, 2));
106
+ console.log(JSON.stringify({ ok: true, mode: "once", ...result }, null, 2));
83
107
  process.exit(0);
84
108
  }
85
109
 
@@ -97,39 +121,39 @@ if (sessionTailEnabled) {
97
121
  if (otelListenerEnabled) {
98
122
  startOtelServer();
99
123
  } else if (!sessionTailEnabled) {
100
- console.error('Nothing to run: both OTel listener and session tail are disabled.');
124
+ console.error("Nothing to run: both OTel listener and session tail are disabled.");
101
125
  process.exit(2);
102
126
  }
103
127
 
104
- process.on('SIGINT', () => {
128
+ process.on("SIGINT", () => {
105
129
  saveState(statePath, state);
106
130
  process.exit(0);
107
131
  });
108
- process.on('SIGTERM', () => {
132
+ process.on("SIGTERM", () => {
109
133
  saveState(statePath, state);
110
134
  process.exit(0);
111
135
  });
112
136
 
113
137
  function startOtelServer() {
114
138
  const server = createServer(async (req, res) => {
115
- if (req.method === 'GET') {
116
- res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
139
+ if (req.method === "GET") {
140
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
117
141
  res.end(
118
142
  [
119
- 'trAIce Internal Spend Codex collector is running.',
120
- '',
143
+ "trAIce Internal Spend Codex collector is running.",
144
+ "",
121
145
  `OTLP endpoint: http://${config.listenHost}:${config.listenPort}/v1/logs`,
122
146
  `Internal usage ingest: ${config.serverUrl}/api/v1/internal-usage`,
123
147
  `Internal Spend view: ${config.serverUrl}/dashboard?tab=internal`,
124
- '',
125
- 'New Codex agents send OTel here when user-level [otel] config is active.',
126
- 'Already-open Codex agents are covered by tailing ~/.codex/sessions token_count events.',
127
- ].join('\n'),
148
+ "",
149
+ "New Codex agents send OTel here when user-level [otel] config is active.",
150
+ "Already-open Codex agents are covered by tailing ~/.codex/sessions token_count events.",
151
+ ].join("\n"),
128
152
  );
129
153
  return;
130
154
  }
131
155
 
132
- if (req.method !== 'POST') {
156
+ if (req.method !== "POST") {
133
157
  res.writeHead(404).end();
134
158
  return;
135
159
  }
@@ -143,25 +167,25 @@ function startOtelServer() {
143
167
  return;
144
168
  }
145
169
 
146
- const contentType = req.headers['content-type'] ?? '';
170
+ const contentType = req.headers["content-type"] ?? "";
147
171
  const rawEnvelope = {
148
172
  received_at: receivedAt,
149
173
  method: req.method,
150
174
  url: req.url,
151
175
  content_type: contentType,
152
- content_encoding: req.headers['content-encoding'],
153
- body_base64: contentType.includes('json') ? undefined : body.toString('base64'),
176
+ content_encoding: req.headers["content-encoding"],
177
+ body_base64: contentType.includes("json") ? undefined : body.toString("base64"),
154
178
  };
155
179
 
156
180
  let normalized = [];
157
- if (contentType.includes('json')) {
181
+ if (contentType.includes("json")) {
158
182
  try {
159
- const payload = JSON.parse(body.toString('utf8'));
183
+ const payload = JSON.parse(body.toString("utf8"));
160
184
  rawEnvelope.body = redactSensitive(payload);
161
185
  normalized = normalizeOtlpLogs(payload);
162
186
  } catch (error) {
163
187
  rawEnvelope.parse_error = error.message;
164
- rawEnvelope.body_text = redactSensitive(body.toString('utf8'));
188
+ rawEnvelope.body_text = redactSensitive(body.toString("utf8"));
165
189
  }
166
190
  }
167
191
 
@@ -175,7 +199,7 @@ function startOtelServer() {
175
199
  console.log(
176
200
  JSON.stringify({
177
201
  received_at: receivedAt,
178
- source: 'otel',
202
+ source: "otel",
179
203
  records: normalized.length,
180
204
  ingest_candidates: ingestEvents.length,
181
205
  sent,
@@ -186,12 +210,14 @@ function startOtelServer() {
186
210
  console.error(`[codex-otel] internal usage ingest failed: ${error.message}`);
187
211
  }
188
212
 
189
- res.writeHead(200, { 'content-type': 'application/json' });
190
- res.end('{}');
213
+ res.writeHead(200, { "content-type": "application/json" });
214
+ res.end("{}");
191
215
  });
192
216
 
193
217
  server.listen(config.listenPort, config.listenHost, () => {
194
- console.log(`trAIce Internal Spend Codex collector listening on http://${config.listenHost}:${config.listenPort}/v1/logs`);
218
+ console.log(
219
+ `trAIce Internal Spend Codex collector listening on http://${config.listenHost}:${config.listenPort}/v1/logs`,
220
+ );
195
221
  console.log(`Forwarding Codex internal usage to ${config.serverUrl}/api/v1/internal-usage`);
196
222
  if (config.seatMonthlyUsd > 0) {
197
223
  console.log(`Recording seat subscription commitment: $${config.seatMonthlyUsd}/mo`);
@@ -201,10 +227,13 @@ function startOtelServer() {
201
227
  }
202
228
 
203
229
  async function scanCodexSessions() {
204
- const sessionsDir = resolve(config.codexHome, 'sessions');
230
+ const sessionsDir = resolve(config.codexHome, "sessions");
205
231
  if (!existsSync(sessionsDir)) return { candidates: 0, sent: 0, files: 0 };
206
232
 
207
- const cutoffMs = Date.now() - Math.max(0, config.backfillHours) * 60 * 60 * 1000;
233
+ const cutoffMs =
234
+ config.backfillHours === Infinity
235
+ ? Number.NEGATIVE_INFINITY
236
+ : collectorStartedAtMs - config.backfillHours * 60 * 60 * 1000;
208
237
  const threadMetadata = loadThreadMetadata(config.codexHome);
209
238
  const files = findSessionFiles(sessionsDir)
210
239
  .map((file) => ({ file, stat: statSync(file) }))
@@ -237,7 +266,7 @@ async function scanCodexSessions() {
237
266
  }
238
267
 
239
268
  if (sent > 0) {
240
- console.log(JSON.stringify({ source: 'codex_session_jsonl', candidates, sent, files: touchedFiles }));
269
+ console.log(JSON.stringify({ source: "codex_session_jsonl", candidates, sent, files: touchedFiles }));
241
270
  }
242
271
 
243
272
  return { candidates, sent, files: touchedFiles };
@@ -255,20 +284,19 @@ function readNewSessionTokenEvents(file, stat, cutoffMs, threadMetadata) {
255
284
 
256
285
  const buffer = readFileSync(file);
257
286
  const chunk = buffer.subarray(startOffset);
258
- const text = chunk.toString('utf8');
287
+ const text = chunk.toString("utf8");
259
288
  if (!text) return { events: [], nextOffset: startOffset };
260
289
 
261
- const completeChars = text.endsWith('\n') ? text.length : text.lastIndexOf('\n') + 1;
290
+ const completeChars = text.endsWith("\n") ? text.length : text.lastIndexOf("\n") + 1;
262
291
  if (completeChars <= 0) return { events: [], nextOffset: startOffset };
263
292
 
264
293
  const completeText = text.slice(0, completeChars);
265
294
  const nextOffset = startOffset + Buffer.byteLength(completeText);
266
295
  const threadId = extractThreadId(file);
267
296
  const metadata = (threadId && threadMetadata.get(threadId)) || threadMetadata.get(file) || null;
268
- const isInitialBackfill = !prior;
269
297
  const events = [];
270
298
 
271
- for (const line of completeText.split('\n')) {
299
+ for (const line of completeText.split("\n")) {
272
300
  if (!line) continue;
273
301
  let entry;
274
302
  try {
@@ -276,10 +304,12 @@ function readNewSessionTokenEvents(file, stat, cutoffMs, threadMetadata) {
276
304
  } catch {
277
305
  continue;
278
306
  }
279
- if (entry?.type !== 'event_msg' || entry?.payload?.type !== 'token_count') continue;
307
+ if (entry?.type !== "event_msg" || entry?.payload?.type !== "token_count") continue;
280
308
 
281
309
  const tsMs = new Date(entry.timestamp).getTime();
282
- if (isInitialBackfill && Number.isFinite(tsMs) && tsMs < cutoffMs) continue;
310
+ // A finite startup boundary intentionally limits history. The default is
311
+ // full history; destination-scoped state and source event IDs dedupe restarts.
312
+ if (Number.isFinite(tsMs) && tsMs < cutoffMs) continue;
283
313
 
284
314
  const event = sessionTokenCountToIngestEvent({ entry, file, threadId, metadata });
285
315
  if (event) events.push(event);
@@ -290,21 +320,21 @@ function readNewSessionTokenEvents(file, stat, cutoffMs, threadMetadata) {
290
320
 
291
321
  function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
292
322
  const usage = entry?.payload?.info?.last_token_usage;
293
- if (!usage || typeof usage !== 'object') return null;
323
+ if (!usage || typeof usage !== "object") return null;
294
324
 
295
- const inputTokens = getNumber(usage, 'input_tokens');
296
- const cachedInputTokens = getNumber(usage, 'cached_input_tokens');
297
- const outputTokens = getNumber(usage, 'output_tokens');
298
- const reasoningOutputTokens = getNumber(usage, 'reasoning_output_tokens');
325
+ const inputTokens = getNumber(usage, "input_tokens");
326
+ const cachedInputTokens = getNumber(usage, "cached_input_tokens");
327
+ const outputTokens = getNumber(usage, "output_tokens");
328
+ const reasoningOutputTokens = getNumber(usage, "reasoning_output_tokens");
299
329
  const nonCachedInputTokens = Math.max(inputTokens - cachedInputTokens, 0);
300
330
  const billableTokenEstimate = nonCachedInputTokens + outputTokens;
301
331
  if (!config.includeZeroOutput && outputTokens === 0 && reasoningOutputTokens === 0) return null;
302
332
 
303
333
  const timestamp = normalizeTimestamp(entry.timestamp);
304
- const model = metadata?.model ?? 'unknown';
334
+ const model = metadata?.model ?? "unknown";
305
335
  const eventId = hashObject({
306
- source: 'codex_session_jsonl',
307
- file,
336
+ sourceAgent: "codex",
337
+ conversationId: threadId ?? basename(file, ".jsonl").slice(-36),
308
338
  timestamp,
309
339
  inputTokens,
310
340
  cachedInputTokens,
@@ -319,7 +349,7 @@ function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
319
349
  sourceKind: config.sourceKind,
320
350
  tool: config.tool,
321
351
  category: config.category,
322
- provider: metadata?.modelProvider ?? 'openai',
352
+ provider: metadata?.modelProvider ?? "openai",
323
353
  model,
324
354
  employeeEmail: config.employeeEmail,
325
355
  employeeName: config.employeeName,
@@ -327,8 +357,8 @@ function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
327
357
  teamName: config.teamName,
328
358
  teamExternalId: config.teamExternalId,
329
359
  sourcePrincipal: config.sourcePrincipal,
330
- sourcePrincipalType: 'device_user',
331
- runId: threadId ?? basename(file, '.jsonl').slice(-36),
360
+ sourcePrincipalType: "device_user",
361
+ runId: threadId ?? basename(file, ".jsonl").slice(-36),
332
362
  stepId: eventId.slice(0, 32),
333
363
  sourceEventId: eventId,
334
364
  inputTokens,
@@ -336,13 +366,13 @@ function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
336
366
  outputTokens,
337
367
  totalTokens: inputTokens + outputTokens,
338
368
  costUsd: 0,
339
- costBasis: config.seatMonthlyUsd > 0 ? 'seat_subscription' : 'usage_only',
340
- status: 'ok',
369
+ costBasis: config.seatMonthlyUsd > 0 ? "seat_subscription" : "usage_only",
370
+ status: "ok",
341
371
  metadata: compactMetadata({
342
- usageDomain: 'developer_agent',
343
- sourceAgent: 'codex',
344
- sourceTransport: 'codex_session_jsonl',
345
- sourceEventName: 'codex.session_token_count',
372
+ usageDomain: "developer_agent",
373
+ sourceAgent: "codex",
374
+ sourceTransport: "codex_session_jsonl",
375
+ sourceEventName: "codex.session_token_count",
346
376
  workspaceSlug: config.workspaceSlug,
347
377
  tokenAccounting: {
348
378
  rawInputTokens: inputTokens,
@@ -351,20 +381,19 @@ function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
351
381
  outputTokens,
352
382
  reasoningOutputTokens,
353
383
  billableTokenEstimate,
354
- rawTotalTokens: getNumber(usage, 'total_tokens'),
384
+ rawTotalTokens: getNumber(usage, "total_tokens"),
355
385
  cumulative: entry?.payload?.info?.total_token_usage ?? null,
356
386
  },
357
387
  subscription: subscriptionMetadata(),
358
388
  codex: {
359
389
  conversationId: threadId,
360
390
  sessionFile: compactPath(file),
361
- eventKind: 'token_count',
362
- modelContextWindow: getNumber(entry?.payload?.info, 'model_context_window'),
391
+ eventKind: "token_count",
392
+ modelContextWindow: getNumber(entry?.payload?.info, "model_context_window"),
363
393
  rateLimits: rateLimitSummary(entry?.payload?.rate_limits),
364
394
  },
365
395
  thread: metadata
366
396
  ? {
367
- title: metadata.title,
368
397
  cwd: metadata.cwd,
369
398
  source: metadata.source,
370
399
  sandboxPolicy: metadata.sandboxPolicy,
@@ -379,26 +408,46 @@ function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
379
408
 
380
409
  function otelRecordToIngestEvent(record) {
381
410
  const attributes = record.attributes ?? {};
382
- if (record.event_name !== 'codex.sse_event') return [];
383
- if (getString(attributes, 'event.kind') !== 'response.completed') return [];
384
- if (getString(attributes, 'error.message')) return [];
385
-
386
- const inputTokens = getNumber(attributes, 'input_token_count');
387
- const cachedInputTokens = getNumber(attributes, 'cached_token_count');
388
- const outputTokens = getNumber(attributes, 'output_token_count');
389
- const reasoningOutputTokens = getNumber(attributes, 'reasoning_token_count');
411
+ if (record.event_name === "codex.turn_ttft") {
412
+ const conversationId = getString(attributes, "conversation.id");
413
+ const durationMs = getNullableNumber(attributes, "duration_ms");
414
+ if (conversationId && durationMs != null) {
415
+ pendingTurnLatency.set(conversationId, {
416
+ durationMs,
417
+ recordedAtMs: new Date(getString(attributes, "event.timestamp") ?? record.received_at).getTime(),
418
+ });
419
+ if (pendingTurnLatency.size > 1_000) pendingTurnLatency.delete(pendingTurnLatency.keys().next().value);
420
+ }
421
+ return [];
422
+ }
423
+ if (record.event_name !== "codex.sse_event") return [];
424
+ if (getString(attributes, "event.kind") !== "response.completed") return [];
425
+ if (getString(attributes, "error.message")) return [];
426
+
427
+ const inputTokens = getNumber(attributes, "input_token_count");
428
+ const cachedInputTokens = getNumber(attributes, "cached_token_count");
429
+ const outputTokens = getNumber(attributes, "output_token_count");
430
+ const reasoningOutputTokens = getNumber(attributes, "reasoning_token_count");
390
431
  const nonCachedInputTokens = Math.max(inputTokens - cachedInputTokens, 0);
391
432
  const zeroOutputRecord = outputTokens === 0 && reasoningOutputTokens === 0;
392
433
  if (!config.includeZeroOutput && zeroOutputRecord) return [];
393
434
 
394
- const timestamp = normalizeTimestamp(getString(attributes, 'event.timestamp') ?? record.received_at);
395
- const conversationId = getString(attributes, 'conversation.id') ?? 'unknown';
396
- const model = getString(attributes, 'model') ?? 'unknown';
435
+ const timestamp = normalizeTimestamp(getString(attributes, "event.timestamp") ?? record.received_at);
436
+ const conversationId = getString(attributes, "conversation.id") ?? "unknown";
437
+ const model = getString(attributes, "model") ?? "unknown";
438
+ const turnLatency = pendingTurnLatency.get(conversationId);
439
+ const responseTimestampMs = new Date(timestamp).getTime();
440
+ const latencyMs =
441
+ turnLatency &&
442
+ responseTimestampMs - turnLatency.recordedAtMs >= 0 &&
443
+ responseTimestampMs - turnLatency.recordedAtMs < 300_000
444
+ ? turnLatency.durationMs
445
+ : getNullableNumber(attributes, "duration_ms");
446
+ if (turnLatency) pendingTurnLatency.delete(conversationId);
397
447
  const eventId = hashObject({
398
- source: 'otel',
448
+ sourceAgent: "codex",
399
449
  conversationId,
400
450
  timestamp,
401
- model,
402
451
  inputTokens,
403
452
  cachedInputTokens,
404
453
  outputTokens,
@@ -413,7 +462,7 @@ function otelRecordToIngestEvent(record) {
413
462
  sourceKind: config.sourceKind,
414
463
  tool: config.tool,
415
464
  category: config.category,
416
- provider: 'openai',
465
+ provider: "openai",
417
466
  model,
418
467
  employeeEmail: config.employeeEmail,
419
468
  employeeName: config.employeeName,
@@ -421,7 +470,7 @@ function otelRecordToIngestEvent(record) {
421
470
  teamName: config.teamName,
422
471
  teamExternalId: config.teamExternalId,
423
472
  sourcePrincipal: config.sourcePrincipal,
424
- sourcePrincipalType: 'device_user',
473
+ sourcePrincipalType: "device_user",
425
474
  runId: conversationId,
426
475
  stepId: eventId.slice(0, 32),
427
476
  sourceEventId: eventId,
@@ -430,14 +479,15 @@ function otelRecordToIngestEvent(record) {
430
479
  outputTokens,
431
480
  totalTokens: inputTokens + outputTokens,
432
481
  costUsd: 0,
433
- costBasis: config.seatMonthlyUsd > 0 ? 'seat_subscription' : 'usage_only',
434
- latencyMs: getNullableNumber(attributes, 'duration_ms') ?? undefined,
435
- status: 'ok',
482
+ costBasis: config.seatMonthlyUsd > 0 ? "seat_subscription" : "usage_only",
483
+ latencyMs: latencyMs ?? undefined,
484
+ status: "ok",
436
485
  metadata: compactMetadata({
437
- usageDomain: 'developer_agent',
438
- sourceAgent: 'codex',
439
- sourceTransport: 'otel',
486
+ usageDomain: "developer_agent",
487
+ sourceAgent: "codex",
488
+ sourceTransport: "otel",
440
489
  sourceEventName: record.event_name,
490
+ latencyKind: latencyMs != null ? (turnLatency ? "time_to_first_token" : "duration") : null,
441
491
  workspaceSlug: config.workspaceSlug,
442
492
  tokenAccounting: {
443
493
  rawInputTokens: inputTokens,
@@ -450,16 +500,16 @@ function otelRecordToIngestEvent(record) {
450
500
  },
451
501
  subscription: subscriptionMetadata(),
452
502
  service: {
453
- name: getString(record.resource, 'service.name'),
454
- version: getString(record.resource, 'service.version'),
455
- environment: getString(record.resource, 'env'),
503
+ name: getString(record.resource, "service.name"),
504
+ version: getString(record.resource, "service.version"),
505
+ environment: getString(record.resource, "env"),
456
506
  },
457
507
  codex: {
458
508
  conversationId,
459
- eventKind: getString(attributes, 'event.kind'),
460
- authMode: getString(attributes, 'auth_mode'),
461
- sandboxPolicy: getString(attributes, 'sandbox_policy'),
462
- approvalPolicy: getString(attributes, 'approval_policy'),
509
+ eventKind: getString(attributes, "event.kind"),
510
+ authMode: getString(attributes, "auth_mode"),
511
+ sandboxPolicy: getString(attributes, "sandbox_policy"),
512
+ approvalPolicy: getString(attributes, "approval_policy"),
463
513
  },
464
514
  }),
465
515
  },
@@ -477,15 +527,15 @@ async function forwardEvents(events) {
477
527
  for (let i = 0; i < pending.length; i += config.maxBatchSize) {
478
528
  const batch = pending.slice(i, i + config.maxBatchSize);
479
529
  const response = await fetch(`${config.serverUrl}/api/v1/internal-usage`, {
480
- method: 'POST',
530
+ method: "POST",
481
531
  headers: {
482
532
  authorization: `Bearer ${config.apiKey}`,
483
- 'content-type': 'application/json',
533
+ "content-type": "application/json",
484
534
  },
485
535
  body: JSON.stringify({ events: batch }),
486
536
  });
487
537
  if (!response.ok) {
488
- const text = await response.text().catch(() => '');
538
+ const text = await response.text().catch(() => "");
489
539
  throw new Error(`POST /api/v1/internal-usage returned ${response.status}: ${text.slice(0, 500)}`);
490
540
  }
491
541
 
@@ -546,21 +596,21 @@ function appendNormalizedEvents(records) {
546
596
 
547
597
  function loadThreadMetadata(codexHome) {
548
598
  const byId = new Map();
549
- const dbPath = resolve(codexHome, 'state_5.sqlite');
599
+ const dbPath = resolve(codexHome, "state_5.sqlite");
550
600
  if (!existsSync(dbPath)) return byId;
551
601
 
552
602
  try {
553
603
  const query = [
554
- 'select id, rollout_path, source, model_provider, cwd, title, sandbox_policy,',
555
- 'approval_mode, tokens_used, model, reasoning_effort, created_at, updated_at',
556
- 'from threads',
557
- ].join(' ');
558
- const output = execFileSync('sqlite3', ['-json', dbPath, query], {
559
- encoding: 'utf8',
604
+ "select id, rollout_path, source, model_provider, cwd, sandbox_policy,",
605
+ "approval_mode, tokens_used, model, reasoning_effort, created_at, updated_at",
606
+ "from threads",
607
+ ].join(" ");
608
+ const output = execFileSync("sqlite3", ["-json", dbPath, query], {
609
+ encoding: "utf8",
560
610
  timeout: 3000,
561
611
  maxBuffer: 8 * 1024 * 1024,
562
612
  });
563
- const rows = JSON.parse(output || '[]');
613
+ const rows = JSON.parse(output || "[]");
564
614
  for (const row of rows) {
565
615
  const metadata = {
566
616
  id: row.id,
@@ -568,7 +618,6 @@ function loadThreadMetadata(codexHome) {
568
618
  source: row.source,
569
619
  modelProvider: row.model_provider,
570
620
  cwd: row.cwd,
571
- title: row.title,
572
621
  sandboxPolicy: row.sandbox_policy,
573
622
  approvalMode: row.approval_mode,
574
623
  tokensUsed: row.tokens_used,
@@ -594,7 +643,7 @@ function findSessionFiles(dir) {
594
643
  const fullPath = join(current, entry.name);
595
644
  if (entry.isDirectory()) {
596
645
  visit(fullPath);
597
- } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
646
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
598
647
  files.push(fullPath);
599
648
  }
600
649
  }
@@ -606,10 +655,10 @@ function findSessionFiles(dir) {
606
655
  function readRequestBody(req) {
607
656
  return new Promise((resolveBody, rejectBody) => {
608
657
  const chunks = [];
609
- req.on('data', (chunk) => chunks.push(chunk));
610
- req.on('end', () => {
658
+ req.on("data", (chunk) => chunks.push(chunk));
659
+ req.on("end", () => {
611
660
  const body = Buffer.concat(chunks);
612
- if (req.headers['content-encoding'] === 'gzip') {
661
+ if (req.headers["content-encoding"] === "gzip") {
613
662
  try {
614
663
  resolveBody(gunzipSync(body));
615
664
  } catch (error) {
@@ -619,19 +668,19 @@ function readRequestBody(req) {
619
668
  }
620
669
  resolveBody(body);
621
670
  });
622
- req.on('error', rejectBody);
671
+ req.on("error", rejectBody);
623
672
  });
624
673
  }
625
674
 
626
675
  function otlpValueToJs(value) {
627
- if (!value || typeof value !== 'object') return value;
628
- if ('stringValue' in value) return value.stringValue;
629
- if ('intValue' in value) return Number(value.intValue);
630
- if ('doubleValue' in value) return Number(value.doubleValue);
631
- if ('boolValue' in value) return Boolean(value.boolValue);
632
- if ('bytesValue' in value) return value.bytesValue;
633
- if ('arrayValue' in value) return (value.arrayValue?.values ?? []).map(otlpValueToJs);
634
- if ('kvlistValue' in value) return keyValuesToObject(value.kvlistValue?.values ?? []);
676
+ if (!value || typeof value !== "object") return value;
677
+ if ("stringValue" in value) return value.stringValue;
678
+ if ("intValue" in value) return Number(value.intValue);
679
+ if ("doubleValue" in value) return Number(value.doubleValue);
680
+ if ("boolValue" in value) return Boolean(value.boolValue);
681
+ if ("bytesValue" in value) return value.bytesValue;
682
+ if ("arrayValue" in value) return (value.arrayValue?.values ?? []).map(otlpValueToJs);
683
+ if ("kvlistValue" in value) return keyValuesToObject(value.kvlistValue?.values ?? []);
635
684
  return value;
636
685
  }
637
686
 
@@ -645,9 +694,9 @@ function keyValuesToObject(values) {
645
694
  }
646
695
 
647
696
  function maybeParseJsonString(value) {
648
- if (typeof value !== 'string') return value;
697
+ if (typeof value !== "string") return value;
649
698
  const trimmed = value.trim();
650
- if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value;
699
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
651
700
  try {
652
701
  return JSON.parse(trimmed);
653
702
  } catch {
@@ -656,8 +705,8 @@ function maybeParseJsonString(value) {
656
705
  }
657
706
 
658
707
  function extractEventName(body, attributes) {
659
- if (typeof body === 'string') return body;
660
- if (body && typeof body === 'object') {
708
+ if (typeof body === "string") return body;
709
+ if (body && typeof body === "object") {
661
710
  return (
662
711
  body.event_name ??
663
712
  body.eventName ??
@@ -665,27 +714,27 @@ function extractEventName(body, attributes) {
665
714
  body.name ??
666
715
  body.type ??
667
716
  body.message ??
668
- attributes['event.name'] ??
717
+ attributes["event.name"] ??
669
718
  attributes.event_name ??
670
719
  attributes.name ??
671
- 'unknown'
720
+ "unknown"
672
721
  );
673
722
  }
674
- return attributes['event.name'] ?? attributes.event_name ?? attributes.name ?? 'unknown';
723
+ return attributes["event.name"] ?? attributes.event_name ?? attributes.name ?? "unknown";
675
724
  }
676
725
 
677
- function redactSensitive(value, path = '') {
726
+ function redactSensitive(value, path = "") {
678
727
  if (value === null || value === undefined) return value;
679
728
  if (Array.isArray(value)) return value.map((item, index) => redactSensitive(item, `${path}.${index}`));
680
- if (typeof value === 'object') {
729
+ if (typeof value === "object") {
681
730
  const out = {};
682
731
  for (const [key, child] of Object.entries(value)) {
683
732
  const childPath = path ? `${path}.${key}` : key;
684
- out[key] = isSensitiveKey(childPath) || isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(child, childPath);
733
+ out[key] = isSensitiveKey(childPath) || isSensitiveKey(key) ? "[REDACTED]" : redactSensitive(child, childPath);
685
734
  }
686
735
  return out;
687
736
  }
688
- if (typeof value === 'string' && /\b[^@\s]+@[^@\s]+\.[^@\s]+\b/.test(value)) return '[REDACTED]';
737
+ if (typeof value === "string" && /\b[^@\s]+@[^@\s]+\.[^@\s]+\b/.test(value)) return "[REDACTED]";
689
738
  return value;
690
739
  }
691
740
 
@@ -703,8 +752,8 @@ function subscriptionMetadata() {
703
752
  if (config.seatMonthlyUsd <= 0) return null;
704
753
  return {
705
754
  monthlySeatUsd: config.seatMonthlyUsd,
706
- allocation: 'monthly_seat_commitment',
707
- note: 'Subscription commitment is shown separately from per-request usage cost.',
755
+ allocation: "monthly_seat_commitment",
756
+ note: "Subscription commitment is shown separately from per-request usage cost.",
708
757
  };
709
758
  }
710
759
 
@@ -712,7 +761,7 @@ function pruneNullish(value) {
712
761
  if (Array.isArray(value)) {
713
762
  return value.map(pruneNullish).filter((item) => item !== undefined);
714
763
  }
715
- if (value && typeof value === 'object') {
764
+ if (value && typeof value === "object") {
716
765
  const out = {};
717
766
  for (const [key, child] of Object.entries(value)) {
718
767
  const pruned = pruneNullish(child);
@@ -720,11 +769,11 @@ function pruneNullish(value) {
720
769
  }
721
770
  return Object.keys(out).length > 0 ? out : undefined;
722
771
  }
723
- return value === null || value === undefined || value === '' ? undefined : value;
772
+ return value === null || value === undefined || value === "" ? undefined : value;
724
773
  }
725
774
 
726
775
  function rateLimitSummary(rateLimits) {
727
- if (!rateLimits || typeof rateLimits !== 'object') return null;
776
+ if (!rateLimits || typeof rateLimits !== "object") return null;
728
777
  return {
729
778
  limitId: rateLimits.limit_id,
730
779
  planType: rateLimits.plan_type,
@@ -747,14 +796,15 @@ function rateLimitSummary(rateLimits) {
747
796
  }
748
797
 
749
798
  function loadState(path) {
750
- const fallback = { version: 1, files: {}, sent: {} };
799
+ const fallback = { version: 2, files: {}, sent: {}, destinationKey: undefined };
751
800
  if (!existsSync(path)) return fallback;
752
801
  try {
753
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
802
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
754
803
  return {
755
- version: 1,
756
- files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
757
- sent: parsed.sent && typeof parsed.sent === 'object' ? parsed.sent : {},
804
+ version: 2,
805
+ files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
806
+ sent: parsed.sent && typeof parsed.sent === "object" ? parsed.sent : {},
807
+ destinationKey: typeof parsed.destinationKey === "string" ? parsed.destinationKey : undefined,
758
808
  lastSessionScanAt: parsed.lastSessionScanAt,
759
809
  };
760
810
  } catch {
@@ -781,14 +831,14 @@ function parseArgs(argv) {
781
831
  const parsed = new Map();
782
832
  for (let i = 0; i < argv.length; i += 1) {
783
833
  const arg = argv[i];
784
- if (!arg.startsWith('--')) continue;
834
+ if (!arg.startsWith("--")) continue;
785
835
  const key = arg.slice(2);
786
836
  const next = argv[i + 1];
787
- if (next && !next.startsWith('--')) {
837
+ if (next && !next.startsWith("--")) {
788
838
  parsed.set(key, next);
789
839
  i += 1;
790
840
  } else {
791
- parsed.set(key, 'true');
841
+ parsed.set(key, "true");
792
842
  }
793
843
  }
794
844
  return parsed;
@@ -796,9 +846,9 @@ function parseArgs(argv) {
796
846
 
797
847
  function loadEnvFile(path) {
798
848
  if (!existsSync(path)) return;
799
- for (const line of readFileSync(path, 'utf8').split('\n')) {
849
+ for (const line of readFileSync(path, "utf8").split("\n")) {
800
850
  const trimmed = line.trim();
801
- if (!trimmed || trimmed.startsWith('#')) continue;
851
+ if (!trimmed || trimmed.startsWith("#")) continue;
802
852
  const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
803
853
  if (!match) continue;
804
854
  const [, key, rawValue] = match;
@@ -808,10 +858,7 @@ function loadEnvFile(path) {
808
858
  }
809
859
 
810
860
  function unquoteEnvValue(value) {
811
- if (
812
- (value.startsWith('"') && value.endsWith('"')) ||
813
- (value.startsWith("'") && value.endsWith("'"))
814
- ) {
861
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
815
862
  return value.slice(1, -1);
816
863
  }
817
864
  return value;
@@ -820,14 +867,14 @@ function unquoteEnvValue(value) {
820
867
  function readJsonIfExists(path) {
821
868
  if (!existsSync(path)) return null;
822
869
  try {
823
- return JSON.parse(readFileSync(path, 'utf8'));
870
+ return JSON.parse(readFileSync(path, "utf8"));
824
871
  } catch {
825
872
  return null;
826
873
  }
827
874
  }
828
875
 
829
876
  function normalizeUrl(value) {
830
- return String(value).replace(/\/+$/, '');
877
+ return String(value).replace(/\/+$/, "");
831
878
  }
832
879
 
833
880
  function findDefaultInstallConfigPath() {
@@ -841,8 +888,8 @@ function findDefaultInstallConfigPath() {
841
888
  }
842
889
 
843
890
  function resolveHome(value) {
844
- if (value === '~') return homedir();
845
- if (String(value).startsWith('~/')) return resolve(homedir(), String(value).slice(2));
891
+ if (value === "~") return homedir();
892
+ if (String(value).startsWith("~/")) return resolve(homedir(), String(value).slice(2));
846
893
  return resolve(String(value));
847
894
  }
848
895
 
@@ -864,9 +911,19 @@ function parsePositiveInt(value) {
864
911
  return parsed;
865
912
  }
866
913
 
914
+ function parseBackfillHours(value) {
915
+ if (["all", "full", "infinity"].includes(String(value).toLowerCase())) return Infinity;
916
+ const parsed = Number(value);
917
+ if (!Number.isFinite(parsed) || parsed < 0) {
918
+ console.error(`Invalid backfill hours: ${value}. Use a non-negative number or "all".`);
919
+ process.exit(2);
920
+ }
921
+ return parsed;
922
+ }
923
+
867
924
  function parseOptionalMoney(value) {
868
- if (value === undefined || value === null || value === '') return 0;
869
- const parsed = Number(String(value).replace(/[$,]/g, ''));
925
+ if (value === undefined || value === null || value === "") return 0;
926
+ const parsed = Number(String(value).replace(/[$,]/g, ""));
870
927
  if (!Number.isFinite(parsed) || parsed < 0) {
871
928
  console.error(`Invalid USD amount: ${value}`);
872
929
  process.exit(2);
@@ -875,19 +932,19 @@ function parseOptionalMoney(value) {
875
932
  }
876
933
 
877
934
  function boolArg(value, fallback) {
878
- if (value === undefined || value === null || value === '') return fallback;
879
- if (typeof value === 'boolean') return value;
880
- return String(value) === 'true';
935
+ if (value === undefined || value === null || value === "") return fallback;
936
+ if (typeof value === "boolean") return value;
937
+ return String(value) === "true";
881
938
  }
882
939
 
883
940
  function readApiKey() {
884
- if (args.get('api-key-stdin') !== 'true') return args.get('api-key');
885
- return readFileSync(0, 'utf8').trim();
941
+ if (args.get("api-key-stdin") !== "true") return args.get("api-key");
942
+ return readFileSync(0, "utf8").trim();
886
943
  }
887
944
 
888
945
  function getString(source, key) {
889
946
  const value = source?.[key];
890
- return typeof value === 'string' && value.length > 0 ? value : null;
947
+ return typeof value === "string" && value.length > 0 ? value : null;
891
948
  }
892
949
 
893
950
  function getNumber(source, key) {
@@ -896,8 +953,8 @@ function getNumber(source, key) {
896
953
 
897
954
  function getNullableNumber(source, key) {
898
955
  const value = source?.[key];
899
- if (typeof value === 'number' && Number.isFinite(value)) return value;
900
- if (typeof value === 'string') {
956
+ if (typeof value === "number" && Number.isFinite(value)) return value;
957
+ if (typeof value === "string") {
901
958
  const parsed = Number(value);
902
959
  return Number.isFinite(parsed) ? parsed : null;
903
960
  }
@@ -928,5 +985,5 @@ function compactPath(file) {
928
985
  }
929
986
 
930
987
  function hashObject(value) {
931
- return createHash('sha256').update(JSON.stringify(value)).digest('hex');
988
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
932
989
  }