@traice/codex-collector 0.1.0 → 0.1.2

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