@ramarivera/coding-agent-langfuse 0.1.44 → 0.1.46

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
@@ -8,6 +8,11 @@ Langfuse canonical `usage_details` and `cost_details` attributes so historical
8
8
  backfills participate in Langfuse model-usage and cost dashboards. Tool calls
9
9
  remain child spans under the same session.
10
10
 
11
+ Codex `event_msg` `token_count` rows are imported as non-billable accounting
12
+ spans. They are rolling/snapshot telemetry from the Codex session log, not
13
+ individual model generations, so the importer preserves their token details in
14
+ metadata without sending Langfuse generation `usage_details` or `cost_details`.
15
+
11
16
  ```sh
12
17
  coding-agent-langfuse-backfill --agents codex,claude,grok,pi,opencode
13
18
  ```
@@ -22,6 +27,16 @@ npx @ramarivera/coding-agent-langfuse@latest \
22
27
  --batch-size 10
23
28
  ```
24
29
 
30
+ For a Langfuse endpoint that requires project API keys, pass Basic auth
31
+ credentials as `publicKey:secretKey` or set `LANGFUSE_BACKFILL_AUTH`:
32
+
33
+ ```sh
34
+ npx @ramarivera/coding-agent-langfuse@latest \
35
+ --agents claude,codex,grok,pi,opencode \
36
+ --endpoint http://127.0.0.1:3000/api/public/otel/v1/traces \
37
+ --auth pk-lf-example:sk-lf-example
38
+ ```
39
+
25
40
  Run live incremental forwarding without putting inference behind a gateway:
26
41
 
27
42
  ```sh
@@ -54,9 +69,9 @@ Zai GLM. `gpt-5.5` is charged at current standard API list price by default:
54
69
  Pro defaults to `$30.00` input and `$180.00` output per 1M tokens. Claude Opus 4
55
70
  models default to `$15.00` input, `$1.50` cache hits, `$18.75` 5-minute cache
56
71
  writes, `$30.00` 1-hour cache writes, and `$75.00` output per 1M tokens.
57
- When a source only records a total token count without input/output/cache
58
- breakdown, the importer charges that total at the model input rate and marks the
59
- cost source as `calculated_total_as_input`.
72
+ When a billable generation source only records a total token count without
73
+ input/output/cache breakdown, the importer charges that total at the model input
74
+ rate and marks the cost source as `calculated_total_as_input`.
60
75
 
61
76
  Use an override only when you intentionally want a different accounting policy:
62
77
 
@@ -176,6 +191,7 @@ CLI against a local OTLP collector.
176
191
  npm run check
177
192
  npm test
178
193
  npm run test:e2e
194
+ npm run test:e2e:langfuse
179
195
  ```
180
196
 
181
197
  The e2e suite verifies:
@@ -185,5 +201,8 @@ The e2e suite verifies:
185
201
  - Follow mode picking up newly written Codex events
186
202
  - One CLI run posting reconstructable traces for Claude Code, Codex, Grok,
187
203
  OpenCode, and Pi
204
+ - A Docker-backed mini Langfuse import that queries the public observations API
205
+ and verifies usage/cost fields for sanitized multi-agent sessions, including
206
+ Claude Code Opus 4.7 and Opus 4.8 cache accounting
188
207
  - Service plan generation for Linux systemd user units, macOS LaunchAgents, and
189
208
  Windows Scheduled Tasks
@@ -33,6 +33,7 @@ type BackfillEvent = {
33
33
  type BackfillOptions = {
34
34
  agents: Set<AgentName>;
35
35
  endpoint: string;
36
+ auth?: string;
36
37
  statePath: string;
37
38
  homeDir: string;
38
39
  dryRun: boolean;
package/dist/backfill.js CHANGED
@@ -7,8 +7,8 @@ import { dirname, join } from "node:path";
7
7
  const allAgents = ["claude", "codex", "grok", "opencode", "pi"];
8
8
  const importStateIdentityVersion = "v9-cost-details";
9
9
  const importStateIdentityVersions = {
10
- claude: "v12-cost-details",
11
- codex: "v10-cost-details",
10
+ claude: "v13-claude-message-snapshot-dedupe",
11
+ codex: "v11-codex-token-accounting-nonbillable",
12
12
  grok: "v12-cost-details",
13
13
  opencode: "v11-cost-details",
14
14
  pi: "v12-cost-details",
@@ -22,6 +22,10 @@ const langfuseIdIdentityVersions = {
22
22
  pi: "v11-tool-results",
23
23
  };
24
24
  const importPayloadVersion = "v10-cost-details";
25
+ const importPayloadVersions = {
26
+ claude: "v11-claude-message-snapshot-dedupe",
27
+ codex: "v11-codex-token-accounting-nonbillable",
28
+ };
25
29
  const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
26
30
  const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
27
31
  const defaultMaxRequestBytes = 12 * 1024 * 1024;
@@ -193,6 +197,7 @@ function usage() {
193
197
 
194
198
  Options:
195
199
  --endpoint URL OTLP HTTP traces endpoint (default: ${defaultEndpoint})
200
+ --auth USER:PASS Optional Langfuse Basic auth credentials
196
201
  --agents LIST Comma-separated agents: claude,codex,grok,opencode,pi
197
202
  --state PATH Dedupe state file (default: ${defaultStatePath})
198
203
  --home PATH Home directory to scan (default: current user home)
@@ -216,6 +221,8 @@ Options:
216
221
  }
217
222
  function parseArgs(argv) {
218
223
  let endpoint = normalizeEndpoint(process.env.LANGFUSE_BACKFILL_ENDPOINT ?? defaultEndpoint);
224
+ let auth = process.env.LANGFUSE_BACKFILL_AUTH ??
225
+ process.env.CODING_AGENT_LANGFUSE_AUTH;
219
226
  let statePath = process.env.LANGFUSE_BACKFILL_STATE ?? defaultStatePath;
220
227
  let homeDir = process.env.HOME ?? homedir();
221
228
  let dryRun = false;
@@ -260,6 +267,9 @@ function parseArgs(argv) {
260
267
  else if (arg === "--endpoint") {
261
268
  endpoint = normalizeEndpoint(next());
262
269
  }
270
+ else if (arg === "--auth") {
271
+ auth = next();
272
+ }
263
273
  else if (arg === "--state") {
264
274
  statePath = next();
265
275
  }
@@ -345,9 +355,13 @@ function parseArgs(argv) {
345
355
  if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
346
356
  throw new Error("--limit must be a positive integer");
347
357
  }
358
+ if (auth !== undefined && auth.trim().length === 0) {
359
+ throw new Error("--auth must not be empty");
360
+ }
348
361
  return {
349
362
  agents,
350
363
  endpoint,
364
+ auth,
351
365
  statePath,
352
366
  homeDir,
353
367
  dryRun,
@@ -677,6 +691,25 @@ function usageDetails(usage) {
677
691
  details.total = usage.total;
678
692
  return Object.keys(details).length > 0 ? details : undefined;
679
693
  }
694
+ function usageTokenTotal(usage) {
695
+ if (!usage)
696
+ return 0;
697
+ return usage.total ??
698
+ (usage.input ?? 0) +
699
+ (usage.output ?? 0) +
700
+ (usage.reasoning ?? 0) +
701
+ (usage.cacheRead ?? 0) +
702
+ (usage.cacheWrite ?? 0) +
703
+ (usage.cacheWrite5m ?? 0) +
704
+ (usage.cacheWrite1h ?? 0);
705
+ }
706
+ function textLength(value) {
707
+ if (typeof value === "string")
708
+ return value.length;
709
+ if (value === undefined || value === null)
710
+ return 0;
711
+ return JSON.stringify(value).length;
712
+ }
680
713
  function calculateCost(event, usage, costRates) {
681
714
  if (!usage)
682
715
  return undefined;
@@ -952,8 +985,12 @@ function codexEvents(homeDir, options = {}) {
952
985
  cwd: currentCwd,
953
986
  startMs: timestamp,
954
987
  parentRecordId: "session",
955
- usage,
956
- metadata: pick(info, ["model_context_window"]),
988
+ metadata: {
989
+ ...pick(info, ["model_context_window"]),
990
+ token_usage_billable: false,
991
+ token_usage_source: "codex_event_msg_token_count",
992
+ token_usage_details: usageDetails(usage),
993
+ },
957
994
  });
958
995
  }
959
996
  }
@@ -1356,6 +1393,7 @@ function genericJsonlEvents(agent, files, sessionName) {
1356
1393
  startMs,
1357
1394
  },
1358
1395
  ];
1396
+ const claudeAssistantEventsByMessageId = new Map();
1359
1397
  for (const [index, row] of rows.entries()) {
1360
1398
  const message = asRecord(row.message);
1361
1399
  const role = asString(message.role) ?? asString(row.type);
@@ -1363,11 +1401,31 @@ function genericJsonlEvents(agent, files, sessionName) {
1363
1401
  asString(row.id) ??
1364
1402
  asString(row.toolUseID) ??
1365
1403
  `row-${index}`;
1404
+ let childParentRecordId = recordId;
1366
1405
  const toolUseId = asString(row.toolUseID) ?? asString(row.tool_use_id);
1367
1406
  const content = message.content ?? row.content;
1368
1407
  const timestamp = getTimestampMs(row.timestamp ?? row.time_created, startMs + index);
1369
1408
  const usage = normalizeUsage(message.usage ?? row.usage);
1370
- events.push({
1409
+ const claudeMessageId = agent === "claude" && role === "assistant"
1410
+ ? asString(message.id)
1411
+ : undefined;
1412
+ const eventMetadata = {
1413
+ ...pick(row, [
1414
+ "type",
1415
+ "entrypoint",
1416
+ "version",
1417
+ "gitBranch",
1418
+ "error",
1419
+ ]),
1420
+ ...(claudeMessageId
1421
+ ? {
1422
+ claude_message_id: claudeMessageId,
1423
+ claude_snapshot_count: 1,
1424
+ claude_usage_dedupe: "message_id_max_usage",
1425
+ }
1426
+ : {}),
1427
+ };
1428
+ const event = {
1371
1429
  agent,
1372
1430
  sourcePath: path,
1373
1431
  sessionId: asString(row.sessionId) ?? asString(row.session_id) ??
@@ -1391,14 +1449,36 @@ function genericJsonlEvents(agent, files, sessionName) {
1391
1449
  ? extractText(content)
1392
1450
  : undefined,
1393
1451
  usage,
1394
- metadata: pick(row, [
1395
- "type",
1396
- "entrypoint",
1397
- "version",
1398
- "gitBranch",
1399
- "error",
1400
- ]),
1401
- });
1452
+ metadata: eventMetadata,
1453
+ };
1454
+ if (claudeMessageId) {
1455
+ const existing = claudeAssistantEventsByMessageId.get(claudeMessageId);
1456
+ if (existing) {
1457
+ childParentRecordId = existing.recordId;
1458
+ if (usageTokenTotal(event.usage) > usageTokenTotal(existing.usage)) {
1459
+ existing.usage = event.usage;
1460
+ }
1461
+ if (event.output && textLength(event.output) > textLength(existing.output)) {
1462
+ existing.output = event.output;
1463
+ }
1464
+ if (!existing.model && event.model)
1465
+ existing.model = event.model;
1466
+ if (!existing.cwd && event.cwd)
1467
+ existing.cwd = event.cwd;
1468
+ existing.startMs = Math.min(existing.startMs, event.startMs);
1469
+ existing.metadata = {
1470
+ ...existing.metadata,
1471
+ claude_snapshot_count: (asNumber(existing.metadata?.claude_snapshot_count) ?? 1) + 1,
1472
+ };
1473
+ }
1474
+ else {
1475
+ claudeAssistantEventsByMessageId.set(claudeMessageId, event);
1476
+ events.push(event);
1477
+ }
1478
+ }
1479
+ else {
1480
+ events.push(event);
1481
+ }
1402
1482
  for (const reasoning of reasoningFromContent(content)) {
1403
1483
  events.push({
1404
1484
  agent,
@@ -1408,7 +1488,7 @@ function genericJsonlEvents(agent, files, sessionName) {
1408
1488
  name: `${agent} reasoning`,
1409
1489
  cwd,
1410
1490
  startMs: timestamp,
1411
- parentRecordId: recordId,
1491
+ parentRecordId: childParentRecordId,
1412
1492
  output: reasoning.text,
1413
1493
  metadata: { has_signature: reasoning.hasSignature },
1414
1494
  });
@@ -1422,7 +1502,7 @@ function genericJsonlEvents(agent, files, sessionName) {
1422
1502
  name: `${agent} tool ${tool.name}`,
1423
1503
  cwd,
1424
1504
  startMs: timestamp,
1425
- parentRecordId: asString(row.uuid) ?? asString(row.id),
1505
+ parentRecordId: childParentRecordId,
1426
1506
  input: tool.arguments,
1427
1507
  });
1428
1508
  }
@@ -1492,6 +1572,9 @@ function stableId(input) {
1492
1572
  function importIdentity(event) {
1493
1573
  return importStateIdentityVersions[event.agent] ?? importStateIdentityVersion;
1494
1574
  }
1575
+ function payloadVersion(event) {
1576
+ return importPayloadVersions[event.agent] ?? importPayloadVersion;
1577
+ }
1495
1578
  function langfuseIdIdentity(event) {
1496
1579
  return langfuseIdIdentityVersions[event.agent] ?? langfuseIdIdentityVersion;
1497
1580
  }
@@ -1599,7 +1682,7 @@ function toOtlp(events, options = {}) {
1599
1682
  attr("langfuse.trace.metadata.project_path", firstProject.projectPath),
1600
1683
  attr("langfuse.trace.metadata.project_name", firstProject.projectName),
1601
1684
  attr("langfuse.trace.metadata.project_folder", firstProject.projectFolder),
1602
- attr("langfuse.trace.metadata.import_payload_version", importPayloadVersion),
1685
+ attr("langfuse.trace.metadata.import_payload_version", payloadVersion(first)),
1603
1686
  attr("langfuse.trace.metadata.import_state_identity", importIdentity(first)),
1604
1687
  attr("langfuse.trace.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
1605
1688
  attr("langfuse.observation.metadata.agent", first.agent),
@@ -1612,7 +1695,7 @@ function toOtlp(events, options = {}) {
1612
1695
  attr("langfuse.observation.metadata.project_path", firstProject.projectPath),
1613
1696
  attr("langfuse.observation.metadata.project_name", firstProject.projectName),
1614
1697
  attr("langfuse.observation.metadata.project_folder", firstProject.projectFolder),
1615
- attr("langfuse.observation.metadata.import_payload_version", importPayloadVersion),
1698
+ attr("langfuse.observation.metadata.import_payload_version", payloadVersion(first)),
1616
1699
  attr("langfuse.observation.metadata.import_state_identity", importIdentity(first)),
1617
1700
  attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
1618
1701
  attr("source.path", first.sourcePath),
@@ -1666,7 +1749,7 @@ function toOtlp(events, options = {}) {
1666
1749
  attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
1667
1750
  attr("langfuse.observation.metadata.model", modelName ?? event.model),
1668
1751
  attr("langfuse.observation.metadata.provider", event.provider),
1669
- attr("langfuse.observation.metadata.import_payload_version", importPayloadVersion),
1752
+ attr("langfuse.observation.metadata.import_payload_version", payloadVersion(event)),
1670
1753
  attr("langfuse.observation.metadata.import_state_identity", importIdentity(event)),
1671
1754
  attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(event)),
1672
1755
  attr("langfuse.observation.usage_details", generation ? usage : undefined),
@@ -1677,6 +1760,9 @@ function toOtlp(events, options = {}) {
1677
1760
  attr("langfuse.observation.metadata.cost_model_key", cost?.modelKey),
1678
1761
  attr("langfuse.observation.metadata.cost_rates", cost?.rates),
1679
1762
  attr("langfuse.observation.metadata.recorded_cost", event.usage?.cost),
1763
+ attr("langfuse.observation.metadata.token_usage_billable", event.metadata?.token_usage_billable),
1764
+ attr("langfuse.observation.metadata.token_usage_source", event.metadata?.token_usage_source),
1765
+ attr("langfuse.observation.metadata.token_usage_details", event.metadata?.token_usage_details),
1680
1766
  attr("langfuse.observation.input", event.input),
1681
1767
  attr("langfuse.observation.output", event.output),
1682
1768
  attr("source.path", event.sourcePath),
@@ -1776,11 +1862,16 @@ function splitSendBatches(events, options) {
1776
1862
  }
1777
1863
  async function postOtlp(endpoint, events, options) {
1778
1864
  const body = JSON.stringify(toOtlp(events, options));
1865
+ const headers = {
1866
+ "content-type": "application/json",
1867
+ };
1868
+ if (options.auth)
1869
+ headers.Authorization = authHeader(options.auth);
1779
1870
  let response;
1780
1871
  try {
1781
1872
  response = await fetch(endpoint, {
1782
1873
  method: "POST",
1783
- headers: { "content-type": "application/json" },
1874
+ headers,
1784
1875
  body,
1785
1876
  });
1786
1877
  }
@@ -1791,6 +1882,11 @@ async function postOtlp(endpoint, events, options) {
1791
1882
  throw new Error(`OTLP POST failed: ${response.status} ${await response.text()}`);
1792
1883
  }
1793
1884
  }
1885
+ function authHeader(auth) {
1886
+ if (/^(Basic|Bearer)\s+/i.test(auth))
1887
+ return auth;
1888
+ return `Basic ${Buffer.from(auth, "utf8").toString("base64")}`;
1889
+ }
1794
1890
  function describeError(error) {
1795
1891
  if (!(error instanceof Error))
1796
1892
  return String(error);
@@ -1883,6 +1979,7 @@ async function run(options) {
1883
1979
  await postOtlp(options.endpoint, batch, {
1884
1980
  maxFieldBytes: options.maxFieldBytes,
1885
1981
  costRates: options.costRates,
1982
+ auth: options.auth,
1886
1983
  });
1887
1984
  for (const event of batch) {
1888
1985
  state.sent[fingerprint(event)] = new Date().toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "check": "tsc --noEmit",
26
26
  "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test test/**/*.test.ts",
27
27
  "test:e2e": "npm run build && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test e2e/test/**/*.test.ts",
28
+ "test:e2e:langfuse": "npm run build && LANGFUSE_DOCKER_E2E=1 node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test e2e/test/langfuse-docker.test.ts",
28
29
  "pack:dry-run": "npm pack --dry-run",
29
30
  "prepack": "npm run build"
30
31
  },