@ramarivera/coding-agent-langfuse 0.1.45 → 0.1.47
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 +62 -0
- package/dist/backfill.d.ts +21 -0
- package/dist/backfill.js +262 -14
- package/dist/service.d.ts +1 -0
- package/dist/service.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,68 @@ npx @ramarivera/coding-agent-langfuse@latest \
|
|
|
51
51
|
The importer is fail-fast: the first failed OTLP POST stops the run, prints the
|
|
52
52
|
real network cause, and preserves local state so reruns resume cleanly.
|
|
53
53
|
|
|
54
|
+
## Project tags and metadata
|
|
55
|
+
|
|
56
|
+
Backfills and live followers can attach stable Langfuse dimensions based on the
|
|
57
|
+
session cwd. Use this for client/workstream dashboards like ITSynch without
|
|
58
|
+
guessing from host names or Windows paths in the Langfuse UI.
|
|
59
|
+
|
|
60
|
+
There are two config layers:
|
|
61
|
+
|
|
62
|
+
- Global host config: `~/.config/coding-agent-langfuse/path-tags.json`
|
|
63
|
+
- Optional project-local overlay: `.langfuse-ca.json` in the project directory
|
|
64
|
+
or any parent of the session cwd
|
|
65
|
+
|
|
66
|
+
Global config uses prefix rules:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"rules": [
|
|
71
|
+
{
|
|
72
|
+
"pathPrefix": "/Users/ramarivera/dev/itsynch",
|
|
73
|
+
"tags": ["itsynch", "client:itsynch"],
|
|
74
|
+
"metadata": {
|
|
75
|
+
"project_group": "itsynch",
|
|
76
|
+
"project_owner": "ramiro"
|
|
77
|
+
},
|
|
78
|
+
"projectName": "itsynch",
|
|
79
|
+
"projectFolder": "itsynch"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Project-local config is intentionally smaller and overrides/extends the matched
|
|
86
|
+
global rule:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"tags": ["repo:portal"],
|
|
91
|
+
"metadata": {
|
|
92
|
+
"service": "portal"
|
|
93
|
+
},
|
|
94
|
+
"projectName": "itsynch-portal"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Matched values are emitted on root trace metadata, observation metadata, and
|
|
99
|
+
top-level OTLP attributes such as `project.tags`, `project.group`, and
|
|
100
|
+
`project.owner`. The root span also sets `langfuse.trace.tags`, so Langfuse
|
|
101
|
+
custom dashboards can filter/group by tags or metadata. Historical repair runs
|
|
102
|
+
use the same OTLP builder as live follow mode, so rerunning with `--force` can
|
|
103
|
+
backfill newly added tags for existing sessions.
|
|
104
|
+
|
|
105
|
+
Use a non-default global config path with:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
npx @ramarivera/coding-agent-langfuse@latest \
|
|
109
|
+
--path-tags-config "$HOME/.config/coding-agent-langfuse/path-tags.json"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Generated services pass `--path-tags-config` when provided to
|
|
113
|
+
`service install`; otherwise the package reads the default global config path if
|
|
114
|
+
it exists.
|
|
115
|
+
|
|
54
116
|
## Cost calculation
|
|
55
117
|
|
|
56
118
|
Backfill cost calculation follows Langfuse's OpenTelemetry mapping: generation
|
package/dist/backfill.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ type BackfillOptions = {
|
|
|
50
50
|
maxFieldBytes: number;
|
|
51
51
|
postDelayMs: number;
|
|
52
52
|
costRates: CostCatalog;
|
|
53
|
+
pathTagsConfigPath?: string;
|
|
54
|
+
pathTagsConfig: PathTagsConfig;
|
|
53
55
|
};
|
|
54
56
|
type CostRates = {
|
|
55
57
|
input?: number;
|
|
@@ -64,6 +66,25 @@ type CostCatalog = Record<string, CostRates>;
|
|
|
64
66
|
type OtlpOptions = {
|
|
65
67
|
maxFieldBytes?: number;
|
|
66
68
|
costRates?: CostCatalog;
|
|
69
|
+
pathTagsConfig?: PathTagsConfig;
|
|
70
|
+
};
|
|
71
|
+
type PathTagRule = {
|
|
72
|
+
pathPrefix: string;
|
|
73
|
+
tags: string[];
|
|
74
|
+
metadata: Record<string, unknown>;
|
|
75
|
+
projectName?: string;
|
|
76
|
+
projectFolder?: string;
|
|
77
|
+
};
|
|
78
|
+
type PathTagsConfig = {
|
|
79
|
+
rules: PathTagRule[];
|
|
80
|
+
project?: ProjectTagOverlay;
|
|
81
|
+
};
|
|
82
|
+
type ProjectTagOverlay = {
|
|
83
|
+
tags: string[];
|
|
84
|
+
metadata: Record<string, unknown>;
|
|
85
|
+
projectName?: string;
|
|
86
|
+
projectFolder?: string;
|
|
87
|
+
sourcePath?: string;
|
|
67
88
|
};
|
|
68
89
|
type RunSummary = {
|
|
69
90
|
discovered: Record<string, number>;
|
package/dist/backfill.js
CHANGED
|
@@ -7,7 +7,7 @@ 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: "
|
|
10
|
+
claude: "v13-claude-message-snapshot-dedupe",
|
|
11
11
|
codex: "v11-codex-token-accounting-nonbillable",
|
|
12
12
|
grok: "v12-cost-details",
|
|
13
13
|
opencode: "v11-cost-details",
|
|
@@ -23,6 +23,7 @@ const langfuseIdIdentityVersions = {
|
|
|
23
23
|
};
|
|
24
24
|
const importPayloadVersion = "v10-cost-details";
|
|
25
25
|
const importPayloadVersions = {
|
|
26
|
+
claude: "v11-claude-message-snapshot-dedupe",
|
|
26
27
|
codex: "v11-codex-token-accounting-nonbillable",
|
|
27
28
|
};
|
|
28
29
|
const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
|
|
@@ -30,7 +31,10 @@ const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
|
|
|
30
31
|
const defaultMaxRequestBytes = 12 * 1024 * 1024;
|
|
31
32
|
const defaultMaxFieldBytes = 512 * 1024;
|
|
32
33
|
const defaultStatePath = join(homedir(), ".local/state/coding-agent-langfuse/backfill-v6.json");
|
|
34
|
+
const defaultPathTagsConfigPath = join(homedir(), ".config/coding-agent-langfuse/path-tags.json");
|
|
33
35
|
const currentHost = hostname();
|
|
36
|
+
const projectLocalConfigFile = ".langfuse-ca.json";
|
|
37
|
+
const projectLocalConfigCache = new Map();
|
|
34
38
|
const kimiFirepassRates = {
|
|
35
39
|
input: 2,
|
|
36
40
|
output: 8,
|
|
@@ -191,6 +195,57 @@ function projectMetadata(cwd) {
|
|
|
191
195
|
projectFolder: projectName,
|
|
192
196
|
};
|
|
193
197
|
}
|
|
198
|
+
function matchPathTags(cwd, config) {
|
|
199
|
+
if (!cwd)
|
|
200
|
+
return { tags: [], metadata: {} };
|
|
201
|
+
const normalizedCwd = normalizeRulePath(cwd);
|
|
202
|
+
const matched = config.rules.filter((rule) => {
|
|
203
|
+
const prefix = normalizeRulePath(rule.pathPrefix);
|
|
204
|
+
return normalizedCwd === prefix || normalizedCwd.startsWith(`${prefix}/`);
|
|
205
|
+
});
|
|
206
|
+
const globalMatch = matched.reduce((acc, rule) => ({
|
|
207
|
+
tags: [...new Set([...acc.tags, ...rule.tags])],
|
|
208
|
+
metadata: { ...acc.metadata, ...rule.metadata },
|
|
209
|
+
projectName: rule.projectName ?? acc.projectName,
|
|
210
|
+
projectFolder: rule.projectFolder ?? acc.projectFolder,
|
|
211
|
+
}), {
|
|
212
|
+
tags: [],
|
|
213
|
+
metadata: {},
|
|
214
|
+
projectName: undefined,
|
|
215
|
+
projectFolder: undefined,
|
|
216
|
+
});
|
|
217
|
+
const overlays = [
|
|
218
|
+
globalMatch,
|
|
219
|
+
config.project,
|
|
220
|
+
loadProjectLocalOverlay(cwd),
|
|
221
|
+
].filter((overlay) => overlay !== undefined);
|
|
222
|
+
return overlays.reduce((acc, overlay) => {
|
|
223
|
+
if (!overlay)
|
|
224
|
+
return acc;
|
|
225
|
+
return {
|
|
226
|
+
tags: [...new Set([...acc.tags, ...overlay.tags])],
|
|
227
|
+
metadata: { ...acc.metadata, ...overlay.metadata },
|
|
228
|
+
projectName: overlay.projectName ?? acc.projectName,
|
|
229
|
+
projectFolder: overlay.projectFolder ?? acc.projectFolder,
|
|
230
|
+
};
|
|
231
|
+
}, {
|
|
232
|
+
tags: [],
|
|
233
|
+
metadata: {},
|
|
234
|
+
projectName: undefined,
|
|
235
|
+
projectFolder: undefined,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function mergeProjectMetadata(cwd, config) {
|
|
239
|
+
const project = projectMetadata(cwd);
|
|
240
|
+
const pathTags = matchPathTags(cwd, config);
|
|
241
|
+
return {
|
|
242
|
+
...project,
|
|
243
|
+
projectName: pathTags.projectName ?? project.projectName,
|
|
244
|
+
projectFolder: pathTags.projectFolder ?? project.projectFolder,
|
|
245
|
+
tags: pathTags.tags,
|
|
246
|
+
metadata: pathTags.metadata,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
194
249
|
function usage() {
|
|
195
250
|
return `Usage: coding-agent-langfuse-backfill [options]
|
|
196
251
|
|
|
@@ -210,6 +265,7 @@ Options:
|
|
|
210
265
|
--post-delay-ms N Delay after each successful OTLP POST (default: 0)
|
|
211
266
|
--cost-rates PATH JSON model cost-rate overrides in USD per 1M tokens
|
|
212
267
|
--cost-rates-json JSON Inline JSON model cost-rate overrides in USD per 1M tokens
|
|
268
|
+
--path-tags-config PATH JSON path-prefix rules that add Langfuse tags/metadata
|
|
213
269
|
--follow Keep scanning and sending newly written events
|
|
214
270
|
--poll-interval-ms N Delay between --follow scans (default: 5000)
|
|
215
271
|
--idle-exit-after-ms N Stop --follow after this much time without new sends
|
|
@@ -240,6 +296,9 @@ function parseArgs(argv) {
|
|
|
240
296
|
if (!Number.isFinite(postDelayMs))
|
|
241
297
|
postDelayMs = 0;
|
|
242
298
|
let costRates = loadCostCatalogFromEnv();
|
|
299
|
+
let pathTagsConfigPath = process.env.CODING_AGENT_LANGFUSE_PATH_TAGS_CONFIG ??
|
|
300
|
+
process.env.LANGFUSE_BACKFILL_PATH_TAGS_CONFIG ??
|
|
301
|
+
defaultPathTagsConfigPath;
|
|
243
302
|
let follow = false;
|
|
244
303
|
let pollIntervalMs = 5_000;
|
|
245
304
|
let idleExitAfterMs;
|
|
@@ -306,6 +365,9 @@ function parseArgs(argv) {
|
|
|
306
365
|
else if (arg === "--cost-rates-json") {
|
|
307
366
|
costRates = mergeCostCatalog(costRates, parseCostCatalogJson(next()));
|
|
308
367
|
}
|
|
368
|
+
else if (arg === "--path-tags-config") {
|
|
369
|
+
pathTagsConfigPath = next();
|
|
370
|
+
}
|
|
309
371
|
else if (arg === "--follow") {
|
|
310
372
|
follow = true;
|
|
311
373
|
}
|
|
@@ -377,8 +439,110 @@ function parseArgs(argv) {
|
|
|
377
439
|
maxFieldBytes,
|
|
378
440
|
postDelayMs,
|
|
379
441
|
costRates,
|
|
442
|
+
pathTagsConfigPath,
|
|
443
|
+
pathTagsConfig: loadPathTagsConfig(pathTagsConfigPath),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function loadPathTagsConfig(path) {
|
|
447
|
+
if (!path || !existsSync(path))
|
|
448
|
+
return { rules: [] };
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
throw new Error(`Invalid path tags config ${path}: ${describeError(error)}`);
|
|
455
|
+
}
|
|
456
|
+
const root = asRecord(parsed);
|
|
457
|
+
const rawRules = Array.isArray(root.rules) ? root.rules : [];
|
|
458
|
+
const rules = rawRules.map((rawRule, index) => {
|
|
459
|
+
const rule = asRecord(rawRule);
|
|
460
|
+
const pathPrefix = asString(rule.pathPrefix) ?? asString(rule.path_prefix);
|
|
461
|
+
if (!pathPrefix) {
|
|
462
|
+
throw new Error(`Invalid path tags config ${path}: rules[${index}].pathPrefix is required`);
|
|
463
|
+
}
|
|
464
|
+
const tags = Array.isArray(rule.tags)
|
|
465
|
+
? rule.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0)
|
|
466
|
+
: [];
|
|
467
|
+
const metadata = asRecord(rule.metadata);
|
|
468
|
+
return {
|
|
469
|
+
pathPrefix,
|
|
470
|
+
tags,
|
|
471
|
+
metadata,
|
|
472
|
+
projectName: asString(rule.projectName) ?? asString(rule.project_name),
|
|
473
|
+
projectFolder: asString(rule.projectFolder) ?? asString(rule.project_folder),
|
|
474
|
+
};
|
|
475
|
+
});
|
|
476
|
+
return { rules, project: normalizeProjectTagOverlay(root, path) };
|
|
477
|
+
}
|
|
478
|
+
function loadProjectLocalOverlay(cwd) {
|
|
479
|
+
const configPath = findProjectLocalConfig(cwd);
|
|
480
|
+
if (!configPath)
|
|
481
|
+
return undefined;
|
|
482
|
+
const cached = projectLocalConfigCache.get(configPath);
|
|
483
|
+
if (projectLocalConfigCache.has(configPath))
|
|
484
|
+
return cached;
|
|
485
|
+
let overlay;
|
|
486
|
+
try {
|
|
487
|
+
overlay = normalizeProjectTagOverlay(JSON.parse(readFileSync(configPath, "utf8")), configPath);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
throw new Error(`Invalid project Langfuse config ${configPath}: ${describeError(error)}`);
|
|
491
|
+
}
|
|
492
|
+
projectLocalConfigCache.set(configPath, overlay);
|
|
493
|
+
return overlay;
|
|
494
|
+
}
|
|
495
|
+
function findProjectLocalConfig(cwd) {
|
|
496
|
+
if (!cwd)
|
|
497
|
+
return undefined;
|
|
498
|
+
let current = cwd;
|
|
499
|
+
while (true) {
|
|
500
|
+
try {
|
|
501
|
+
current = statSync(current).isDirectory() ? current : dirname(current);
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
const parent = dirname(current);
|
|
506
|
+
if (parent === current)
|
|
507
|
+
return undefined;
|
|
508
|
+
current = parent;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
while (true) {
|
|
512
|
+
const candidate = join(current, projectLocalConfigFile);
|
|
513
|
+
if (existsSync(candidate))
|
|
514
|
+
return candidate;
|
|
515
|
+
const parent = dirname(current);
|
|
516
|
+
if (parent === current)
|
|
517
|
+
return undefined;
|
|
518
|
+
current = parent;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function normalizeProjectTagOverlay(value, sourcePath) {
|
|
522
|
+
const root = asRecord(value);
|
|
523
|
+
const rawTags = Array.isArray(root.tags) ? root.tags : [];
|
|
524
|
+
const tags = rawTags.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
|
|
525
|
+
const metadata = asRecord(root.metadata);
|
|
526
|
+
const projectName = asString(root.projectName) ?? asString(root.project_name);
|
|
527
|
+
const projectFolder = asString(root.projectFolder) ?? asString(root.project_folder);
|
|
528
|
+
if (tags.length === 0 &&
|
|
529
|
+
Object.keys(metadata).length === 0 &&
|
|
530
|
+
!projectName &&
|
|
531
|
+
!projectFolder) {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
tags,
|
|
536
|
+
metadata,
|
|
537
|
+
projectName,
|
|
538
|
+
projectFolder,
|
|
539
|
+
sourcePath,
|
|
380
540
|
};
|
|
381
541
|
}
|
|
542
|
+
function normalizeRulePath(path) {
|
|
543
|
+
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
544
|
+
return /^[a-z]:\//i.test(normalized) ? normalized.toLowerCase() : normalized;
|
|
545
|
+
}
|
|
382
546
|
function loadCostCatalogFromEnv() {
|
|
383
547
|
let catalog = { ...defaultCostRates };
|
|
384
548
|
const path = process.env.CODING_AGENT_LANGFUSE_COST_RATES_PATH ??
|
|
@@ -690,6 +854,25 @@ function usageDetails(usage) {
|
|
|
690
854
|
details.total = usage.total;
|
|
691
855
|
return Object.keys(details).length > 0 ? details : undefined;
|
|
692
856
|
}
|
|
857
|
+
function usageTokenTotal(usage) {
|
|
858
|
+
if (!usage)
|
|
859
|
+
return 0;
|
|
860
|
+
return usage.total ??
|
|
861
|
+
(usage.input ?? 0) +
|
|
862
|
+
(usage.output ?? 0) +
|
|
863
|
+
(usage.reasoning ?? 0) +
|
|
864
|
+
(usage.cacheRead ?? 0) +
|
|
865
|
+
(usage.cacheWrite ?? 0) +
|
|
866
|
+
(usage.cacheWrite5m ?? 0) +
|
|
867
|
+
(usage.cacheWrite1h ?? 0);
|
|
868
|
+
}
|
|
869
|
+
function textLength(value) {
|
|
870
|
+
if (typeof value === "string")
|
|
871
|
+
return value.length;
|
|
872
|
+
if (value === undefined || value === null)
|
|
873
|
+
return 0;
|
|
874
|
+
return JSON.stringify(value).length;
|
|
875
|
+
}
|
|
693
876
|
function calculateCost(event, usage, costRates) {
|
|
694
877
|
if (!usage)
|
|
695
878
|
return undefined;
|
|
@@ -1373,6 +1556,7 @@ function genericJsonlEvents(agent, files, sessionName) {
|
|
|
1373
1556
|
startMs,
|
|
1374
1557
|
},
|
|
1375
1558
|
];
|
|
1559
|
+
const claudeAssistantEventsByMessageId = new Map();
|
|
1376
1560
|
for (const [index, row] of rows.entries()) {
|
|
1377
1561
|
const message = asRecord(row.message);
|
|
1378
1562
|
const role = asString(message.role) ?? asString(row.type);
|
|
@@ -1380,11 +1564,31 @@ function genericJsonlEvents(agent, files, sessionName) {
|
|
|
1380
1564
|
asString(row.id) ??
|
|
1381
1565
|
asString(row.toolUseID) ??
|
|
1382
1566
|
`row-${index}`;
|
|
1567
|
+
let childParentRecordId = recordId;
|
|
1383
1568
|
const toolUseId = asString(row.toolUseID) ?? asString(row.tool_use_id);
|
|
1384
1569
|
const content = message.content ?? row.content;
|
|
1385
1570
|
const timestamp = getTimestampMs(row.timestamp ?? row.time_created, startMs + index);
|
|
1386
1571
|
const usage = normalizeUsage(message.usage ?? row.usage);
|
|
1387
|
-
|
|
1572
|
+
const claudeMessageId = agent === "claude" && role === "assistant"
|
|
1573
|
+
? asString(message.id)
|
|
1574
|
+
: undefined;
|
|
1575
|
+
const eventMetadata = {
|
|
1576
|
+
...pick(row, [
|
|
1577
|
+
"type",
|
|
1578
|
+
"entrypoint",
|
|
1579
|
+
"version",
|
|
1580
|
+
"gitBranch",
|
|
1581
|
+
"error",
|
|
1582
|
+
]),
|
|
1583
|
+
...(claudeMessageId
|
|
1584
|
+
? {
|
|
1585
|
+
claude_message_id: claudeMessageId,
|
|
1586
|
+
claude_snapshot_count: 1,
|
|
1587
|
+
claude_usage_dedupe: "message_id_max_usage",
|
|
1588
|
+
}
|
|
1589
|
+
: {}),
|
|
1590
|
+
};
|
|
1591
|
+
const event = {
|
|
1388
1592
|
agent,
|
|
1389
1593
|
sourcePath: path,
|
|
1390
1594
|
sessionId: asString(row.sessionId) ?? asString(row.session_id) ??
|
|
@@ -1408,14 +1612,36 @@ function genericJsonlEvents(agent, files, sessionName) {
|
|
|
1408
1612
|
? extractText(content)
|
|
1409
1613
|
: undefined,
|
|
1410
1614
|
usage,
|
|
1411
|
-
metadata:
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1615
|
+
metadata: eventMetadata,
|
|
1616
|
+
};
|
|
1617
|
+
if (claudeMessageId) {
|
|
1618
|
+
const existing = claudeAssistantEventsByMessageId.get(claudeMessageId);
|
|
1619
|
+
if (existing) {
|
|
1620
|
+
childParentRecordId = existing.recordId;
|
|
1621
|
+
if (usageTokenTotal(event.usage) > usageTokenTotal(existing.usage)) {
|
|
1622
|
+
existing.usage = event.usage;
|
|
1623
|
+
}
|
|
1624
|
+
if (event.output && textLength(event.output) > textLength(existing.output)) {
|
|
1625
|
+
existing.output = event.output;
|
|
1626
|
+
}
|
|
1627
|
+
if (!existing.model && event.model)
|
|
1628
|
+
existing.model = event.model;
|
|
1629
|
+
if (!existing.cwd && event.cwd)
|
|
1630
|
+
existing.cwd = event.cwd;
|
|
1631
|
+
existing.startMs = Math.min(existing.startMs, event.startMs);
|
|
1632
|
+
existing.metadata = {
|
|
1633
|
+
...existing.metadata,
|
|
1634
|
+
claude_snapshot_count: (asNumber(existing.metadata?.claude_snapshot_count) ?? 1) + 1,
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
claudeAssistantEventsByMessageId.set(claudeMessageId, event);
|
|
1639
|
+
events.push(event);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
else {
|
|
1643
|
+
events.push(event);
|
|
1644
|
+
}
|
|
1419
1645
|
for (const reasoning of reasoningFromContent(content)) {
|
|
1420
1646
|
events.push({
|
|
1421
1647
|
agent,
|
|
@@ -1425,7 +1651,7 @@ function genericJsonlEvents(agent, files, sessionName) {
|
|
|
1425
1651
|
name: `${agent} reasoning`,
|
|
1426
1652
|
cwd,
|
|
1427
1653
|
startMs: timestamp,
|
|
1428
|
-
parentRecordId:
|
|
1654
|
+
parentRecordId: childParentRecordId,
|
|
1429
1655
|
output: reasoning.text,
|
|
1430
1656
|
metadata: { has_signature: reasoning.hasSignature },
|
|
1431
1657
|
});
|
|
@@ -1439,7 +1665,7 @@ function genericJsonlEvents(agent, files, sessionName) {
|
|
|
1439
1665
|
name: `${agent} tool ${tool.name}`,
|
|
1440
1666
|
cwd,
|
|
1441
1667
|
startMs: timestamp,
|
|
1442
|
-
parentRecordId:
|
|
1668
|
+
parentRecordId: childParentRecordId,
|
|
1443
1669
|
input: tool.arguments,
|
|
1444
1670
|
});
|
|
1445
1671
|
}
|
|
@@ -1550,6 +1776,12 @@ function attr(key, value) {
|
|
|
1550
1776
|
return { key, value: { stringValue: value } };
|
|
1551
1777
|
return { key, value: { stringValue: JSON.stringify(value).slice(0, 8000) } };
|
|
1552
1778
|
}
|
|
1779
|
+
function nonEmptyArray(value) {
|
|
1780
|
+
return value.length > 0 ? value : undefined;
|
|
1781
|
+
}
|
|
1782
|
+
function nonEmptyRecord(value) {
|
|
1783
|
+
return Object.keys(value).length > 0 ? value : undefined;
|
|
1784
|
+
}
|
|
1553
1785
|
function utf8Bytes(value) {
|
|
1554
1786
|
return Buffer.byteLength(value, "utf8");
|
|
1555
1787
|
}
|
|
@@ -1582,6 +1814,7 @@ function limitEventPayload(event, maxFieldBytes) {
|
|
|
1582
1814
|
function toOtlp(events, options = {}) {
|
|
1583
1815
|
const maxFieldBytes = options.maxFieldBytes ?? defaultMaxFieldBytes;
|
|
1584
1816
|
const costRates = options.costRates ?? loadCostCatalogFromEnv();
|
|
1817
|
+
const pathTagsConfig = options.pathTagsConfig ?? { rules: [] };
|
|
1585
1818
|
const spansByTrace = new Map();
|
|
1586
1819
|
for (const rawEvent of events) {
|
|
1587
1820
|
const event = limitEventPayload(rawEvent, maxFieldBytes);
|
|
@@ -1596,7 +1829,7 @@ function toOtlp(events, options = {}) {
|
|
|
1596
1829
|
const traceStartMs = sortedEvents[0]?.startMs ?? Date.now();
|
|
1597
1830
|
const traceEndMs = Math.max(...sortedEvents.map((event) => event.endMs ?? event.startMs + 1), traceStartMs + 1);
|
|
1598
1831
|
const shouldEmitRootSpan = sortedEvents.some((event) => event.recordId === "session");
|
|
1599
|
-
const firstProject =
|
|
1832
|
+
const firstProject = mergeProjectMetadata(first.cwd, pathTagsConfig);
|
|
1600
1833
|
const rootAttributes = [
|
|
1601
1834
|
attr("service.name", `agent.${first.agent}`),
|
|
1602
1835
|
attr("deployment.environment", "local"),
|
|
@@ -1619,6 +1852,9 @@ function toOtlp(events, options = {}) {
|
|
|
1619
1852
|
attr("langfuse.trace.metadata.project_path", firstProject.projectPath),
|
|
1620
1853
|
attr("langfuse.trace.metadata.project_name", firstProject.projectName),
|
|
1621
1854
|
attr("langfuse.trace.metadata.project_folder", firstProject.projectFolder),
|
|
1855
|
+
attr("langfuse.trace.metadata.project_tags", nonEmptyArray(firstProject.tags)),
|
|
1856
|
+
attr("langfuse.trace.metadata.path_tags", nonEmptyRecord(firstProject.metadata)),
|
|
1857
|
+
attr("langfuse.trace.tags", nonEmptyArray(firstProject.tags)),
|
|
1622
1858
|
attr("langfuse.trace.metadata.import_payload_version", payloadVersion(first)),
|
|
1623
1859
|
attr("langfuse.trace.metadata.import_state_identity", importIdentity(first)),
|
|
1624
1860
|
attr("langfuse.trace.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
|
|
@@ -1632,6 +1868,8 @@ function toOtlp(events, options = {}) {
|
|
|
1632
1868
|
attr("langfuse.observation.metadata.project_path", firstProject.projectPath),
|
|
1633
1869
|
attr("langfuse.observation.metadata.project_name", firstProject.projectName),
|
|
1634
1870
|
attr("langfuse.observation.metadata.project_folder", firstProject.projectFolder),
|
|
1871
|
+
attr("langfuse.observation.metadata.project_tags", nonEmptyArray(firstProject.tags)),
|
|
1872
|
+
attr("langfuse.observation.metadata.path_tags", nonEmptyRecord(firstProject.metadata)),
|
|
1635
1873
|
attr("langfuse.observation.metadata.import_payload_version", payloadVersion(first)),
|
|
1636
1874
|
attr("langfuse.observation.metadata.import_state_identity", importIdentity(first)),
|
|
1637
1875
|
attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
|
|
@@ -1640,6 +1878,9 @@ function toOtlp(events, options = {}) {
|
|
|
1640
1878
|
attr("project.path", firstProject.projectPath),
|
|
1641
1879
|
attr("project.name", firstProject.projectName),
|
|
1642
1880
|
attr("project.folder", firstProject.projectFolder),
|
|
1881
|
+
attr("project.tags", nonEmptyArray(firstProject.tags)),
|
|
1882
|
+
attr("project.group", firstProject.metadata.project_group),
|
|
1883
|
+
attr("project.owner", firstProject.metadata.project_owner),
|
|
1643
1884
|
].filter((item) => Boolean(item));
|
|
1644
1885
|
const rootSpan = {
|
|
1645
1886
|
traceId: traceId(first),
|
|
@@ -1659,7 +1900,7 @@ function toOtlp(events, options = {}) {
|
|
|
1659
1900
|
const generation = isGenerationEvent(event);
|
|
1660
1901
|
const usage = usageDetails(event.usage);
|
|
1661
1902
|
const cost = generation ? calculateCost(event, usage, costRates) : undefined;
|
|
1662
|
-
const eventProject =
|
|
1903
|
+
const eventProject = mergeProjectMetadata(event.cwd, pathTagsConfig);
|
|
1663
1904
|
const attributes = [
|
|
1664
1905
|
attr("service.name", `agent.${event.agent}`),
|
|
1665
1906
|
attr("deployment.environment", "local"),
|
|
@@ -1684,6 +1925,8 @@ function toOtlp(events, options = {}) {
|
|
|
1684
1925
|
attr("langfuse.observation.metadata.project_path", eventProject.projectPath),
|
|
1685
1926
|
attr("langfuse.observation.metadata.project_name", eventProject.projectName),
|
|
1686
1927
|
attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
|
|
1928
|
+
attr("langfuse.observation.metadata.project_tags", nonEmptyArray(eventProject.tags)),
|
|
1929
|
+
attr("langfuse.observation.metadata.path_tags", nonEmptyRecord(eventProject.metadata)),
|
|
1687
1930
|
attr("langfuse.observation.metadata.model", modelName ?? event.model),
|
|
1688
1931
|
attr("langfuse.observation.metadata.provider", event.provider),
|
|
1689
1932
|
attr("langfuse.observation.metadata.import_payload_version", payloadVersion(event)),
|
|
@@ -1707,6 +1950,9 @@ function toOtlp(events, options = {}) {
|
|
|
1707
1950
|
attr("project.path", eventProject.projectPath),
|
|
1708
1951
|
attr("project.name", eventProject.projectName),
|
|
1709
1952
|
attr("project.folder", eventProject.projectFolder),
|
|
1953
|
+
attr("project.tags", nonEmptyArray(eventProject.tags)),
|
|
1954
|
+
attr("project.group", eventProject.metadata.project_group),
|
|
1955
|
+
attr("project.owner", eventProject.metadata.project_owner),
|
|
1710
1956
|
attr("role", event.role),
|
|
1711
1957
|
attr("agent.model", event.model),
|
|
1712
1958
|
attr("agent.provider", event.provider),
|
|
@@ -1890,6 +2136,7 @@ async function run(options) {
|
|
|
1890
2136
|
maxRequestBytes: options.maxRequestBytes,
|
|
1891
2137
|
maxFieldBytes: options.maxFieldBytes,
|
|
1892
2138
|
costRates: options.costRates,
|
|
2139
|
+
pathTagsConfig: options.pathTagsConfig,
|
|
1893
2140
|
});
|
|
1894
2141
|
}
|
|
1895
2142
|
catch (error) {
|
|
@@ -1916,6 +2163,7 @@ async function run(options) {
|
|
|
1916
2163
|
await postOtlp(options.endpoint, batch, {
|
|
1917
2164
|
maxFieldBytes: options.maxFieldBytes,
|
|
1918
2165
|
costRates: options.costRates,
|
|
2166
|
+
pathTagsConfig: options.pathTagsConfig,
|
|
1919
2167
|
auth: options.auth,
|
|
1920
2168
|
});
|
|
1921
2169
|
for (const event of batch) {
|
package/dist/service.d.ts
CHANGED
package/dist/service.js
CHANGED
|
@@ -26,6 +26,7 @@ Service options:
|
|
|
26
26
|
--post-delay-ms N Delay after each successful OTLP POST (default: 0)
|
|
27
27
|
--cost-rates PATH JSON model cost-rate overrides in USD per 1M tokens
|
|
28
28
|
--cost-rates-json JSON Inline JSON model cost-rate overrides in USD per 1M tokens
|
|
29
|
+
--path-tags-config PATH JSON path-prefix rules that add Langfuse tags/metadata
|
|
29
30
|
--since ISO_OR_MS Optional lower bound for events the follower may send
|
|
30
31
|
--working-directory DIR Directory the service starts in (default: --home)
|
|
31
32
|
--path VALUE PATH value injected into the service environment
|
|
@@ -55,6 +56,8 @@ function parseServiceArgs(argv) {
|
|
|
55
56
|
process.env.LANGFUSE_BACKFILL_COST_RATES_PATH;
|
|
56
57
|
let costRatesJson = process.env.CODING_AGENT_LANGFUSE_COST_RATES_JSON ??
|
|
57
58
|
process.env.LANGFUSE_BACKFILL_COST_RATES_JSON;
|
|
59
|
+
let pathTagsConfigPath = process.env.CODING_AGENT_LANGFUSE_PATH_TAGS_CONFIG ??
|
|
60
|
+
process.env.LANGFUSE_BACKFILL_PATH_TAGS_CONFIG;
|
|
58
61
|
let since;
|
|
59
62
|
let dryRun = false;
|
|
60
63
|
let start = true;
|
|
@@ -107,6 +110,9 @@ function parseServiceArgs(argv) {
|
|
|
107
110
|
else if (arg === "--cost-rates-json") {
|
|
108
111
|
costRatesJson = next();
|
|
109
112
|
}
|
|
113
|
+
else if (arg === "--path-tags-config") {
|
|
114
|
+
pathTagsConfigPath = next();
|
|
115
|
+
}
|
|
110
116
|
else if (arg === "--since") {
|
|
111
117
|
since = next();
|
|
112
118
|
}
|
|
@@ -148,6 +154,7 @@ function parseServiceArgs(argv) {
|
|
|
148
154
|
postDelayMs,
|
|
149
155
|
costRatesPath,
|
|
150
156
|
costRatesJson,
|
|
157
|
+
pathTagsConfigPath,
|
|
151
158
|
since,
|
|
152
159
|
dryRun,
|
|
153
160
|
start,
|
|
@@ -300,6 +307,8 @@ function buildFollowCommand(options) {
|
|
|
300
307
|
command.push("--cost-rates", options.costRatesPath);
|
|
301
308
|
if (options.costRatesJson)
|
|
302
309
|
command.push("--cost-rates-json", options.costRatesJson);
|
|
310
|
+
if (options.pathTagsConfigPath)
|
|
311
|
+
command.push("--path-tags-config", options.pathTagsConfigPath);
|
|
303
312
|
return command;
|
|
304
313
|
}
|
|
305
314
|
function renderSystemdUnit(options, command) {
|