@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 +22 -3
- package/dist/backfill.d.ts +1 -0
- package/dist/backfill.js +116 -19
- package/package.json +2 -1
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
|
|
58
|
-
breakdown, the importer charges that total at the model input
|
|
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
|
package/dist/backfill.d.ts
CHANGED
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: "
|
|
11
|
-
codex: "
|
|
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
|
-
|
|
956
|
-
|
|
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
|
-
|
|
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:
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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:
|
|
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:
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
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.
|
|
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
|
},
|