@ramarivera/coding-agent-langfuse 0.1.16 → 0.1.18

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/README.md CHANGED
@@ -18,7 +18,18 @@ npx @ramarivera/coding-agent-langfuse@latest \
18
18
  --agents claude,codex,grok,pi,opencode \
19
19
  --endpoint https://langfuse.ai.roxasroot.net/otel/v1/traces \
20
20
  --state "$HOME/.local/state/coding-agent-langfuse/backfill-v6.json" \
21
- --batch-size 1000
21
+ --batch-size 10
22
+ ```
23
+
24
+ Run live incremental forwarding without putting inference behind a gateway:
25
+
26
+ ```sh
27
+ npx @ramarivera/coding-agent-langfuse@latest \
28
+ --agents codex \
29
+ --endpoint https://langfuse.ai.roxasroot.net/otel/v1/traces \
30
+ --state "$HOME/.local/state/coding-agent-langfuse/live-codex.json" \
31
+ --batch-size 10 \
32
+ --follow
22
33
  ```
23
34
 
24
35
  The importer is fail-fast: the first failed OTLP POST stops the run, prints the
@@ -33,6 +33,9 @@ type BackfillOptions = {
33
33
  statePath: string;
34
34
  homeDir: string;
35
35
  dryRun: boolean;
36
+ follow: boolean;
37
+ pollIntervalMs: number;
38
+ idleExitAfterMs?: number;
36
39
  limit?: number;
37
40
  sinceMs?: number;
38
41
  untilMs?: number;
@@ -50,6 +53,10 @@ type RunSummary = {
50
53
  endpoint: string;
51
54
  statePath: string;
52
55
  };
56
+ type FollowSummary = RunSummary & {
57
+ iterations: number;
58
+ follow: true;
59
+ };
53
60
  declare function parseArgs(argv: string[]): BackfillOptions;
54
61
  declare function codexEvents(homeDir: string): BackfillEvent[];
55
62
  declare function claudeEvents(homeDir: string): BackfillEvent[];
@@ -60,5 +67,6 @@ declare function fingerprint(event: BackfillEvent): string;
60
67
  declare function toOtlp(events: BackfillEvent[]): Record<string, unknown>;
61
68
  declare function discoverEvents(options: BackfillOptions): BackfillEvent[];
62
69
  declare function run(options: BackfillOptions): Promise<RunSummary>;
63
- declare function main(argv?: string[]): Promise<RunSummary>;
64
- export { type BackfillEvent, type BackfillOptions, claudeEvents, codexEvents, discoverEvents, fingerprint, grokEvents, main, opencodeEvents, parseArgs, piEvents, run, toOtlp, };
70
+ declare function follow(options: BackfillOptions): Promise<FollowSummary>;
71
+ declare function main(argv?: string[]): Promise<RunSummary | FollowSummary>;
72
+ export { type BackfillEvent, type BackfillOptions, claudeEvents, codexEvents, discoverEvents, fingerprint, follow, grokEvents, main, opencodeEvents, parseArgs, piEvents, run, toOtlp, };
package/dist/backfill.js CHANGED
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  const allAgents = ["claude", "codex", "grok", "opencode", "pi"];
8
- const importIdentityVersion = "v8-cached-input-token-split";
8
+ const importIdentityVersion = "v9-codex-conversation-events";
9
9
  const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
10
10
  const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
11
11
  const defaultStatePath = join(homedir(), ".local/state/coding-agent-langfuse/backfill-v6.json");
@@ -21,6 +21,9 @@ Options:
21
21
  --until ISO_OR_MS Only import events before or at this timestamp
22
22
  --limit N Stop after N unsent events
23
23
  --batch-size N OTLP spans per POST (default: 50)
24
+ --follow Keep scanning and sending newly written events
25
+ --poll-interval-ms N Delay between --follow scans (default: 5000)
26
+ --idle-exit-after-ms N Stop --follow after this much time without new sends
24
27
  --dry-run Discover and dedupe without sending or mutating state
25
28
  --help Show this help
26
29
  `;
@@ -34,6 +37,9 @@ function parseArgs(argv) {
34
37
  let sinceMs;
35
38
  let untilMs;
36
39
  let batchSize = 50;
40
+ let follow = false;
41
+ let pollIntervalMs = 5_000;
42
+ let idleExitAfterMs;
37
43
  const agents = new Set(allAgents);
38
44
  for (let i = 0; i < argv.length; i++) {
39
45
  const arg = argv[i];
@@ -75,6 +81,15 @@ function parseArgs(argv) {
75
81
  else if (arg === "--batch-size") {
76
82
  batchSize = Number.parseInt(next(), 10);
77
83
  }
84
+ else if (arg === "--follow") {
85
+ follow = true;
86
+ }
87
+ else if (arg === "--poll-interval-ms") {
88
+ pollIntervalMs = Number.parseInt(next(), 10);
89
+ }
90
+ else if (arg === "--idle-exit-after-ms") {
91
+ idleExitAfterMs = Number.parseInt(next(), 10);
92
+ }
78
93
  else if (arg === "--since") {
79
94
  sinceMs = parseTime(next());
80
95
  }
@@ -88,6 +103,13 @@ function parseArgs(argv) {
88
103
  if (!Number.isFinite(batchSize) || batchSize < 1) {
89
104
  throw new Error("--batch-size must be a positive integer");
90
105
  }
106
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 1) {
107
+ throw new Error("--poll-interval-ms must be a positive integer");
108
+ }
109
+ if (idleExitAfterMs !== undefined &&
110
+ (!Number.isFinite(idleExitAfterMs) || idleExitAfterMs < 1)) {
111
+ throw new Error("--idle-exit-after-ms must be a positive integer");
112
+ }
91
113
  if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
92
114
  throw new Error("--limit must be a positive integer");
93
115
  }
@@ -97,6 +119,9 @@ function parseArgs(argv) {
97
119
  statePath,
98
120
  homeDir,
99
121
  dryRun,
122
+ follow,
123
+ pollIntervalMs,
124
+ idleExitAfterMs,
100
125
  limit,
101
126
  sinceMs,
102
127
  untilMs,
@@ -204,7 +229,10 @@ function extractText(value, maxLength = 4000) {
204
229
  const text = value
205
230
  .map((item) => {
206
231
  const record = asRecord(item);
207
- return asString(record.text) ?? asString(record.content) ?? "";
232
+ return asString(record.text) ??
233
+ extractText(record.content, maxLength) ??
234
+ extractText(record.summary, maxLength) ??
235
+ "";
208
236
  })
209
237
  .filter(Boolean)
210
238
  .join("\n");
@@ -338,7 +366,8 @@ function costDetails(usage, model) {
338
366
  return undefined;
339
367
  }
340
368
  function isGenerationEvent(event) {
341
- return event.usage !== undefined && event.role !== "user";
369
+ return event.usage !== undefined && event.role !== "user" &&
370
+ event.role !== "developer" && event.role !== "system";
342
371
  }
343
372
  function codexEvents(homeDir) {
344
373
  const files = listFiles(join(homeDir, ".codex/sessions"), (path) => path.endsWith(".jsonl"));
@@ -353,6 +382,7 @@ function codexEvents(homeDir) {
353
382
  asString(getPath(payload, ["model"]));
354
383
  let currentModel = model;
355
384
  let currentCwd = cwd;
385
+ let currentTurnRecordId = "session";
356
386
  const seenTokenCounts = new Set();
357
387
  const events = [
358
388
  {
@@ -381,11 +411,12 @@ function codexEvents(homeDir) {
381
411
  if (type === "turn_context") {
382
412
  currentModel = asString(rowPayload.model) ?? currentModel;
383
413
  currentCwd = asString(rowPayload.cwd) ?? currentCwd;
414
+ currentTurnRecordId = `turn-${asString(rowPayload.turn_id) ?? index}`;
384
415
  events.push({
385
416
  agent: "codex",
386
417
  sourcePath: path,
387
418
  sessionId,
388
- recordId: `turn-${asString(rowPayload.turn_id) ?? index}`,
419
+ recordId: currentTurnRecordId,
389
420
  name: "codex turn",
390
421
  cwd: currentCwd,
391
422
  model: currentModel,
@@ -395,34 +426,99 @@ function codexEvents(homeDir) {
395
426
  });
396
427
  }
397
428
  if (type === "response_item" && itemType === "message") {
429
+ const role = asString(rowPayload.role);
430
+ const text = extractText(rowPayload.content);
431
+ const input = role === "user" || role === "developer" ||
432
+ role === "system"
433
+ ? text
434
+ : undefined;
435
+ const output = role === "assistant" ? text : undefined;
398
436
  events.push({
399
437
  agent: "codex",
400
438
  sourcePath: path,
401
439
  sessionId,
402
440
  recordId: `message-${asString(rowPayload.id) ?? index}`,
403
- name: `codex ${asString(rowPayload.role) ?? "message"}`,
404
- role: asString(rowPayload.role),
441
+ name: `codex ${role ?? "message"}`,
442
+ role,
405
443
  model: currentModel,
406
444
  cwd: currentCwd,
407
445
  startMs: timestamp,
408
- parentRecordId: "session",
409
- output: extractText(rowPayload.content),
446
+ parentRecordId: currentTurnRecordId,
447
+ input,
448
+ output,
410
449
  usage: normalizeUsage(rowPayload.usage),
450
+ metadata: pick(rowPayload, ["phase"]),
451
+ });
452
+ }
453
+ if (type === "response_item" && itemType === "reasoning") {
454
+ events.push({
455
+ agent: "codex",
456
+ sourcePath: path,
457
+ sessionId,
458
+ recordId: `reasoning-${index}`,
459
+ name: "codex reasoning",
460
+ model: currentModel,
461
+ cwd: currentCwd,
462
+ startMs: timestamp,
463
+ parentRecordId: currentTurnRecordId,
464
+ output: extractText(rowPayload.content) ??
465
+ extractText(rowPayload.summary),
466
+ metadata: {
467
+ has_encrypted_content: rowPayload.encrypted_content !== undefined,
468
+ },
411
469
  });
412
470
  }
413
471
  if (type === "response_item" && itemType === "function_call") {
472
+ const callId = asString(rowPayload.call_id) ?? `${index}`;
414
473
  events.push({
415
474
  agent: "codex",
416
475
  sourcePath: path,
417
476
  sessionId,
418
- recordId: `tool-${asString(rowPayload.call_id) ?? index}`,
477
+ recordId: `tool-${callId}`,
419
478
  name: `codex tool ${asString(rowPayload.name) ?? "call"}`,
420
479
  model: currentModel,
421
480
  cwd: currentCwd,
422
481
  startMs: timestamp,
423
- parentRecordId: "session",
482
+ parentRecordId: currentTurnRecordId,
424
483
  input: rowPayload.arguments,
425
- metadata: pick(rowPayload, ["name", "call_id"]),
484
+ metadata: pick(rowPayload, ["name", "namespace", "call_id"]),
485
+ });
486
+ }
487
+ if (type === "response_item" && itemType === "function_call_output") {
488
+ const callId = asString(rowPayload.call_id) ?? `${index}`;
489
+ events.push({
490
+ agent: "codex",
491
+ sourcePath: path,
492
+ sessionId,
493
+ recordId: `tool-result-${callId}`,
494
+ name: "codex tool result",
495
+ model: currentModel,
496
+ cwd: currentCwd,
497
+ startMs: timestamp,
498
+ parentRecordId: `tool-${callId}`,
499
+ output: rowPayload.output,
500
+ metadata: pick(rowPayload, ["call_id", "status", "execution"]),
501
+ });
502
+ }
503
+ if (type === "response_item" &&
504
+ (itemType === "tool_search_call" || itemType === "tool_search_output")) {
505
+ const callId = asString(rowPayload.call_id) ?? `${index}`;
506
+ const isOutput = itemType === "tool_search_output";
507
+ events.push({
508
+ agent: "codex",
509
+ sourcePath: path,
510
+ sessionId,
511
+ recordId: `${isOutput ? "tool-search-result" : "tool-search"}-${callId}`,
512
+ name: isOutput ? "codex tool_search result" : "codex tool_search",
513
+ model: currentModel,
514
+ cwd: currentCwd,
515
+ startMs: timestamp,
516
+ parentRecordId: isOutput
517
+ ? `tool-search-${callId}`
518
+ : currentTurnRecordId,
519
+ input: isOutput ? undefined : rowPayload.arguments,
520
+ output: isOutput ? rowPayload.tools : undefined,
521
+ metadata: pick(rowPayload, ["call_id", "status", "execution"]),
426
522
  });
427
523
  }
428
524
  if (type === "event_msg" && rowPayload.type === "token_count") {
@@ -837,6 +933,7 @@ function toOtlp(events) {
837
933
  attributes: rootAttributes,
838
934
  status: { code: 1 },
839
935
  };
936
+ const spanIdsByRecordId = new Map(sortedEvents.map((event) => [event.recordId, spanId(event)]));
840
937
  const childSpans = sortedEvents.map((event) => {
841
938
  const startMs = event.startMs;
842
939
  const durationMs = Math.max(1, (event.endMs ?? event.startMs + 1) - event.startMs);
@@ -898,7 +995,10 @@ function toOtlp(events) {
898
995
  return {
899
996
  traceId: traceId(event),
900
997
  spanId: spanId(event),
901
- parentSpanId: rootSpanId(event),
998
+ parentSpanId: event.parentRecordId &&
999
+ event.parentRecordId !== "session"
1000
+ ? spanIdsByRecordId.get(event.parentRecordId) ?? rootSpanId(event)
1001
+ : rootSpanId(event),
902
1002
  name: event.name,
903
1003
  kind: 1,
904
1004
  startTimeUnixNano: ns(startMs),
@@ -1035,8 +1135,63 @@ async function run(options) {
1035
1135
  statePath: options.statePath,
1036
1136
  };
1037
1137
  }
1138
+ function mergeSummary(base, next) {
1139
+ return {
1140
+ discovered: Object.fromEntries(allAgents.map((agent) => [
1141
+ agent,
1142
+ Math.max(base.discovered[agent] ?? 0, next.discovered[agent] ?? 0),
1143
+ ])),
1144
+ sent: base.sent + next.sent,
1145
+ skipped: next.skipped,
1146
+ failed: base.failed + next.failed,
1147
+ notAttempted: next.notAttempted,
1148
+ aborted: base.aborted || next.aborted,
1149
+ ...(next.error ? { error: next.error } : base.error ? { error: base.error } : {}),
1150
+ dryRun: base.dryRun,
1151
+ endpoint: base.endpoint,
1152
+ statePath: base.statePath,
1153
+ };
1154
+ }
1155
+ function sleep(ms) {
1156
+ return new Promise((resolve) => setTimeout(resolve, ms));
1157
+ }
1158
+ async function follow(options) {
1159
+ let iterations = 0;
1160
+ let lastSendOrStartMs = Date.now();
1161
+ let aggregate;
1162
+ while (true) {
1163
+ iterations += 1;
1164
+ const summary = await run(options);
1165
+ aggregate = aggregate === undefined ? summary : mergeSummary(aggregate, summary);
1166
+ if (summary.sent > 0)
1167
+ lastSendOrStartMs = Date.now();
1168
+ if (summary.aborted)
1169
+ break;
1170
+ if (options.idleExitAfterMs !== undefined &&
1171
+ Date.now() - lastSendOrStartMs >= options.idleExitAfterMs) {
1172
+ break;
1173
+ }
1174
+ await sleep(options.pollIntervalMs);
1175
+ }
1176
+ return {
1177
+ ...(aggregate ?? {
1178
+ discovered: Object.fromEntries(allAgents.map((agent) => [agent, 0])),
1179
+ sent: 0,
1180
+ skipped: 0,
1181
+ failed: 0,
1182
+ notAttempted: 0,
1183
+ aborted: false,
1184
+ dryRun: options.dryRun,
1185
+ endpoint: options.endpoint,
1186
+ statePath: options.statePath,
1187
+ }),
1188
+ iterations,
1189
+ follow: true,
1190
+ };
1191
+ }
1038
1192
  async function main(argv = process.argv.slice(2)) {
1039
- const summary = await run(parseArgs(argv));
1193
+ const options = parseArgs(argv);
1194
+ const summary = options.follow ? await follow(options) : await run(options);
1040
1195
  console.log(JSON.stringify(summary, null, 2));
1041
1196
  return summary;
1042
1197
  }
@@ -1050,4 +1205,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
1050
1205
  process.exit(1);
1051
1206
  }
1052
1207
  }
1053
- export { claudeEvents, codexEvents, discoverEvents, fingerprint, grokEvents, main, opencodeEvents, parseArgs, piEvents, run, toOtlp, };
1208
+ export { claudeEvents, codexEvents, discoverEvents, fingerprint, follow, grokEvents, main, opencodeEvents, parseArgs, piEvents, run, toOtlp, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",