@ramarivera/coding-agent-langfuse 0.1.46 → 0.1.48
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 +65 -0
- package/dist/backfill.d.ts +21 -0
- package/dist/backfill.js +255 -2
- 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,71 @@ 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`. When the session cwd is inside a Git repository, the exporter
|
|
101
|
+
also emits `git.worktree.path`, `git.branch`, and `git.commit` plus matching
|
|
102
|
+
Langfuse metadata fields. The root span also sets `langfuse.trace.tags`, so
|
|
103
|
+
Langfuse custom dashboards can filter/group by tags, metadata, branch, or
|
|
104
|
+
worktree. Historical repair runs use the same OTLP builder as live follow mode,
|
|
105
|
+
so rerunning with `--force` can backfill newly added tags and Git metadata for
|
|
106
|
+
existing sessions.
|
|
107
|
+
|
|
108
|
+
Use a non-default global config path with:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
npx @ramarivera/coding-agent-langfuse@latest \
|
|
112
|
+
--path-tags-config "$HOME/.config/coding-agent-langfuse/path-tags.json"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Generated services pass `--path-tags-config` when provided to
|
|
116
|
+
`service install`; otherwise the package reads the default global config path if
|
|
117
|
+
it exists.
|
|
118
|
+
|
|
54
119
|
## Cost calculation
|
|
55
120
|
|
|
56
121
|
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
|
@@ -31,7 +31,11 @@ const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
|
|
|
31
31
|
const defaultMaxRequestBytes = 12 * 1024 * 1024;
|
|
32
32
|
const defaultMaxFieldBytes = 512 * 1024;
|
|
33
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");
|
|
34
35
|
const currentHost = hostname();
|
|
36
|
+
const projectLocalConfigFile = ".langfuse-ca.json";
|
|
37
|
+
const projectLocalConfigCache = new Map();
|
|
38
|
+
const gitMetadataCache = new Map();
|
|
35
39
|
const kimiFirepassRates = {
|
|
36
40
|
input: 2,
|
|
37
41
|
output: 8,
|
|
@@ -192,6 +196,109 @@ function projectMetadata(cwd) {
|
|
|
192
196
|
projectFolder: projectName,
|
|
193
197
|
};
|
|
194
198
|
}
|
|
199
|
+
function matchPathTags(cwd, config) {
|
|
200
|
+
if (!cwd)
|
|
201
|
+
return { tags: [], metadata: {} };
|
|
202
|
+
const normalizedCwd = normalizeRulePath(cwd);
|
|
203
|
+
const matched = config.rules.filter((rule) => {
|
|
204
|
+
const prefix = normalizeRulePath(rule.pathPrefix);
|
|
205
|
+
return normalizedCwd === prefix || normalizedCwd.startsWith(`${prefix}/`);
|
|
206
|
+
});
|
|
207
|
+
const globalMatch = matched.reduce((acc, rule) => ({
|
|
208
|
+
tags: [...new Set([...acc.tags, ...rule.tags])],
|
|
209
|
+
metadata: { ...acc.metadata, ...rule.metadata },
|
|
210
|
+
projectName: rule.projectName ?? acc.projectName,
|
|
211
|
+
projectFolder: rule.projectFolder ?? acc.projectFolder,
|
|
212
|
+
}), {
|
|
213
|
+
tags: [],
|
|
214
|
+
metadata: {},
|
|
215
|
+
projectName: undefined,
|
|
216
|
+
projectFolder: undefined,
|
|
217
|
+
});
|
|
218
|
+
const overlays = [
|
|
219
|
+
globalMatch,
|
|
220
|
+
config.project,
|
|
221
|
+
loadProjectLocalOverlay(cwd),
|
|
222
|
+
].filter((overlay) => overlay !== undefined);
|
|
223
|
+
return overlays.reduce((acc, overlay) => {
|
|
224
|
+
if (!overlay)
|
|
225
|
+
return acc;
|
|
226
|
+
return {
|
|
227
|
+
tags: [...new Set([...acc.tags, ...overlay.tags])],
|
|
228
|
+
metadata: { ...acc.metadata, ...overlay.metadata },
|
|
229
|
+
projectName: overlay.projectName ?? acc.projectName,
|
|
230
|
+
projectFolder: overlay.projectFolder ?? acc.projectFolder,
|
|
231
|
+
};
|
|
232
|
+
}, {
|
|
233
|
+
tags: [],
|
|
234
|
+
metadata: {},
|
|
235
|
+
projectName: undefined,
|
|
236
|
+
projectFolder: undefined,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function mergeProjectMetadata(cwd, config) {
|
|
240
|
+
const project = projectMetadata(cwd);
|
|
241
|
+
const pathTags = matchPathTags(cwd, config);
|
|
242
|
+
return {
|
|
243
|
+
...project,
|
|
244
|
+
projectName: pathTags.projectName ?? project.projectName,
|
|
245
|
+
projectFolder: pathTags.projectFolder ?? project.projectFolder,
|
|
246
|
+
tags: pathTags.tags,
|
|
247
|
+
metadata: pathTags.metadata,
|
|
248
|
+
git: loadGitMetadata(cwd),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function loadGitMetadata(cwd) {
|
|
252
|
+
const gitCwd = nearestExistingDirectory(cwd);
|
|
253
|
+
if (!gitCwd)
|
|
254
|
+
return {};
|
|
255
|
+
const cached = gitMetadataCache.get(gitCwd);
|
|
256
|
+
if (cached)
|
|
257
|
+
return cached;
|
|
258
|
+
const worktreePath = gitOutput(gitCwd, ["rev-parse", "--show-toplevel"]);
|
|
259
|
+
if (!worktreePath) {
|
|
260
|
+
gitMetadataCache.set(gitCwd, {});
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
const branch = gitOutput(gitCwd, ["branch", "--show-current"]) ??
|
|
264
|
+
gitOutput(gitCwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
265
|
+
const commit = gitOutput(gitCwd, ["rev-parse", "--verify", "HEAD"]);
|
|
266
|
+
const metadata = {
|
|
267
|
+
worktreePath,
|
|
268
|
+
branch: branch === "HEAD" ? undefined : branch,
|
|
269
|
+
commit,
|
|
270
|
+
};
|
|
271
|
+
gitMetadataCache.set(gitCwd, metadata);
|
|
272
|
+
return metadata;
|
|
273
|
+
}
|
|
274
|
+
function gitOutput(cwd, args) {
|
|
275
|
+
try {
|
|
276
|
+
const output = execFileSync("git", ["-C", cwd, ...args], {
|
|
277
|
+
encoding: "utf8",
|
|
278
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
279
|
+
}).trim();
|
|
280
|
+
return output.length > 0 ? output : undefined;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function nearestExistingDirectory(path) {
|
|
287
|
+
if (!path)
|
|
288
|
+
return undefined;
|
|
289
|
+
let current = path;
|
|
290
|
+
while (true) {
|
|
291
|
+
try {
|
|
292
|
+
return statSync(current).isDirectory() ? current : dirname(current);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
const parent = dirname(current);
|
|
296
|
+
if (parent === current)
|
|
297
|
+
return undefined;
|
|
298
|
+
current = parent;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
195
302
|
function usage() {
|
|
196
303
|
return `Usage: coding-agent-langfuse-backfill [options]
|
|
197
304
|
|
|
@@ -211,6 +318,7 @@ Options:
|
|
|
211
318
|
--post-delay-ms N Delay after each successful OTLP POST (default: 0)
|
|
212
319
|
--cost-rates PATH JSON model cost-rate overrides in USD per 1M tokens
|
|
213
320
|
--cost-rates-json JSON Inline JSON model cost-rate overrides in USD per 1M tokens
|
|
321
|
+
--path-tags-config PATH JSON path-prefix rules that add Langfuse tags/metadata
|
|
214
322
|
--follow Keep scanning and sending newly written events
|
|
215
323
|
--poll-interval-ms N Delay between --follow scans (default: 5000)
|
|
216
324
|
--idle-exit-after-ms N Stop --follow after this much time without new sends
|
|
@@ -241,6 +349,9 @@ function parseArgs(argv) {
|
|
|
241
349
|
if (!Number.isFinite(postDelayMs))
|
|
242
350
|
postDelayMs = 0;
|
|
243
351
|
let costRates = loadCostCatalogFromEnv();
|
|
352
|
+
let pathTagsConfigPath = process.env.CODING_AGENT_LANGFUSE_PATH_TAGS_CONFIG ??
|
|
353
|
+
process.env.LANGFUSE_BACKFILL_PATH_TAGS_CONFIG ??
|
|
354
|
+
defaultPathTagsConfigPath;
|
|
244
355
|
let follow = false;
|
|
245
356
|
let pollIntervalMs = 5_000;
|
|
246
357
|
let idleExitAfterMs;
|
|
@@ -307,6 +418,9 @@ function parseArgs(argv) {
|
|
|
307
418
|
else if (arg === "--cost-rates-json") {
|
|
308
419
|
costRates = mergeCostCatalog(costRates, parseCostCatalogJson(next()));
|
|
309
420
|
}
|
|
421
|
+
else if (arg === "--path-tags-config") {
|
|
422
|
+
pathTagsConfigPath = next();
|
|
423
|
+
}
|
|
310
424
|
else if (arg === "--follow") {
|
|
311
425
|
follow = true;
|
|
312
426
|
}
|
|
@@ -378,8 +492,110 @@ function parseArgs(argv) {
|
|
|
378
492
|
maxFieldBytes,
|
|
379
493
|
postDelayMs,
|
|
380
494
|
costRates,
|
|
495
|
+
pathTagsConfigPath,
|
|
496
|
+
pathTagsConfig: loadPathTagsConfig(pathTagsConfigPath),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function loadPathTagsConfig(path) {
|
|
500
|
+
if (!path || !existsSync(path))
|
|
501
|
+
return { rules: [] };
|
|
502
|
+
let parsed;
|
|
503
|
+
try {
|
|
504
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
throw new Error(`Invalid path tags config ${path}: ${describeError(error)}`);
|
|
508
|
+
}
|
|
509
|
+
const root = asRecord(parsed);
|
|
510
|
+
const rawRules = Array.isArray(root.rules) ? root.rules : [];
|
|
511
|
+
const rules = rawRules.map((rawRule, index) => {
|
|
512
|
+
const rule = asRecord(rawRule);
|
|
513
|
+
const pathPrefix = asString(rule.pathPrefix) ?? asString(rule.path_prefix);
|
|
514
|
+
if (!pathPrefix) {
|
|
515
|
+
throw new Error(`Invalid path tags config ${path}: rules[${index}].pathPrefix is required`);
|
|
516
|
+
}
|
|
517
|
+
const tags = Array.isArray(rule.tags)
|
|
518
|
+
? rule.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0)
|
|
519
|
+
: [];
|
|
520
|
+
const metadata = asRecord(rule.metadata);
|
|
521
|
+
return {
|
|
522
|
+
pathPrefix,
|
|
523
|
+
tags,
|
|
524
|
+
metadata,
|
|
525
|
+
projectName: asString(rule.projectName) ?? asString(rule.project_name),
|
|
526
|
+
projectFolder: asString(rule.projectFolder) ?? asString(rule.project_folder),
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
return { rules, project: normalizeProjectTagOverlay(root, path) };
|
|
530
|
+
}
|
|
531
|
+
function loadProjectLocalOverlay(cwd) {
|
|
532
|
+
const configPath = findProjectLocalConfig(cwd);
|
|
533
|
+
if (!configPath)
|
|
534
|
+
return undefined;
|
|
535
|
+
const cached = projectLocalConfigCache.get(configPath);
|
|
536
|
+
if (projectLocalConfigCache.has(configPath))
|
|
537
|
+
return cached;
|
|
538
|
+
let overlay;
|
|
539
|
+
try {
|
|
540
|
+
overlay = normalizeProjectTagOverlay(JSON.parse(readFileSync(configPath, "utf8")), configPath);
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
throw new Error(`Invalid project Langfuse config ${configPath}: ${describeError(error)}`);
|
|
544
|
+
}
|
|
545
|
+
projectLocalConfigCache.set(configPath, overlay);
|
|
546
|
+
return overlay;
|
|
547
|
+
}
|
|
548
|
+
function findProjectLocalConfig(cwd) {
|
|
549
|
+
if (!cwd)
|
|
550
|
+
return undefined;
|
|
551
|
+
let current = cwd;
|
|
552
|
+
while (true) {
|
|
553
|
+
try {
|
|
554
|
+
current = statSync(current).isDirectory() ? current : dirname(current);
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
const parent = dirname(current);
|
|
559
|
+
if (parent === current)
|
|
560
|
+
return undefined;
|
|
561
|
+
current = parent;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
while (true) {
|
|
565
|
+
const candidate = join(current, projectLocalConfigFile);
|
|
566
|
+
if (existsSync(candidate))
|
|
567
|
+
return candidate;
|
|
568
|
+
const parent = dirname(current);
|
|
569
|
+
if (parent === current)
|
|
570
|
+
return undefined;
|
|
571
|
+
current = parent;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function normalizeProjectTagOverlay(value, sourcePath) {
|
|
575
|
+
const root = asRecord(value);
|
|
576
|
+
const rawTags = Array.isArray(root.tags) ? root.tags : [];
|
|
577
|
+
const tags = rawTags.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
|
|
578
|
+
const metadata = asRecord(root.metadata);
|
|
579
|
+
const projectName = asString(root.projectName) ?? asString(root.project_name);
|
|
580
|
+
const projectFolder = asString(root.projectFolder) ?? asString(root.project_folder);
|
|
581
|
+
if (tags.length === 0 &&
|
|
582
|
+
Object.keys(metadata).length === 0 &&
|
|
583
|
+
!projectName &&
|
|
584
|
+
!projectFolder) {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
tags,
|
|
589
|
+
metadata,
|
|
590
|
+
projectName,
|
|
591
|
+
projectFolder,
|
|
592
|
+
sourcePath,
|
|
381
593
|
};
|
|
382
594
|
}
|
|
595
|
+
function normalizeRulePath(path) {
|
|
596
|
+
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
597
|
+
return /^[a-z]:\//i.test(normalized) ? normalized.toLowerCase() : normalized;
|
|
598
|
+
}
|
|
383
599
|
function loadCostCatalogFromEnv() {
|
|
384
600
|
let catalog = { ...defaultCostRates };
|
|
385
601
|
const path = process.env.CODING_AGENT_LANGFUSE_COST_RATES_PATH ??
|
|
@@ -1613,6 +1829,12 @@ function attr(key, value) {
|
|
|
1613
1829
|
return { key, value: { stringValue: value } };
|
|
1614
1830
|
return { key, value: { stringValue: JSON.stringify(value).slice(0, 8000) } };
|
|
1615
1831
|
}
|
|
1832
|
+
function nonEmptyArray(value) {
|
|
1833
|
+
return value.length > 0 ? value : undefined;
|
|
1834
|
+
}
|
|
1835
|
+
function nonEmptyRecord(value) {
|
|
1836
|
+
return Object.keys(value).length > 0 ? value : undefined;
|
|
1837
|
+
}
|
|
1616
1838
|
function utf8Bytes(value) {
|
|
1617
1839
|
return Buffer.byteLength(value, "utf8");
|
|
1618
1840
|
}
|
|
@@ -1645,6 +1867,7 @@ function limitEventPayload(event, maxFieldBytes) {
|
|
|
1645
1867
|
function toOtlp(events, options = {}) {
|
|
1646
1868
|
const maxFieldBytes = options.maxFieldBytes ?? defaultMaxFieldBytes;
|
|
1647
1869
|
const costRates = options.costRates ?? loadCostCatalogFromEnv();
|
|
1870
|
+
const pathTagsConfig = options.pathTagsConfig ?? { rules: [] };
|
|
1648
1871
|
const spansByTrace = new Map();
|
|
1649
1872
|
for (const rawEvent of events) {
|
|
1650
1873
|
const event = limitEventPayload(rawEvent, maxFieldBytes);
|
|
@@ -1659,7 +1882,7 @@ function toOtlp(events, options = {}) {
|
|
|
1659
1882
|
const traceStartMs = sortedEvents[0]?.startMs ?? Date.now();
|
|
1660
1883
|
const traceEndMs = Math.max(...sortedEvents.map((event) => event.endMs ?? event.startMs + 1), traceStartMs + 1);
|
|
1661
1884
|
const shouldEmitRootSpan = sortedEvents.some((event) => event.recordId === "session");
|
|
1662
|
-
const firstProject =
|
|
1885
|
+
const firstProject = mergeProjectMetadata(first.cwd, pathTagsConfig);
|
|
1663
1886
|
const rootAttributes = [
|
|
1664
1887
|
attr("service.name", `agent.${first.agent}`),
|
|
1665
1888
|
attr("deployment.environment", "local"),
|
|
@@ -1682,6 +1905,12 @@ function toOtlp(events, options = {}) {
|
|
|
1682
1905
|
attr("langfuse.trace.metadata.project_path", firstProject.projectPath),
|
|
1683
1906
|
attr("langfuse.trace.metadata.project_name", firstProject.projectName),
|
|
1684
1907
|
attr("langfuse.trace.metadata.project_folder", firstProject.projectFolder),
|
|
1908
|
+
attr("langfuse.trace.metadata.project_tags", nonEmptyArray(firstProject.tags)),
|
|
1909
|
+
attr("langfuse.trace.metadata.path_tags", nonEmptyRecord(firstProject.metadata)),
|
|
1910
|
+
attr("langfuse.trace.metadata.git_worktree_path", firstProject.git.worktreePath),
|
|
1911
|
+
attr("langfuse.trace.metadata.git_branch", firstProject.git.branch),
|
|
1912
|
+
attr("langfuse.trace.metadata.git_commit", firstProject.git.commit),
|
|
1913
|
+
attr("langfuse.trace.tags", nonEmptyArray(firstProject.tags)),
|
|
1685
1914
|
attr("langfuse.trace.metadata.import_payload_version", payloadVersion(first)),
|
|
1686
1915
|
attr("langfuse.trace.metadata.import_state_identity", importIdentity(first)),
|
|
1687
1916
|
attr("langfuse.trace.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
|
|
@@ -1695,6 +1924,11 @@ function toOtlp(events, options = {}) {
|
|
|
1695
1924
|
attr("langfuse.observation.metadata.project_path", firstProject.projectPath),
|
|
1696
1925
|
attr("langfuse.observation.metadata.project_name", firstProject.projectName),
|
|
1697
1926
|
attr("langfuse.observation.metadata.project_folder", firstProject.projectFolder),
|
|
1927
|
+
attr("langfuse.observation.metadata.project_tags", nonEmptyArray(firstProject.tags)),
|
|
1928
|
+
attr("langfuse.observation.metadata.path_tags", nonEmptyRecord(firstProject.metadata)),
|
|
1929
|
+
attr("langfuse.observation.metadata.git_worktree_path", firstProject.git.worktreePath),
|
|
1930
|
+
attr("langfuse.observation.metadata.git_branch", firstProject.git.branch),
|
|
1931
|
+
attr("langfuse.observation.metadata.git_commit", firstProject.git.commit),
|
|
1698
1932
|
attr("langfuse.observation.metadata.import_payload_version", payloadVersion(first)),
|
|
1699
1933
|
attr("langfuse.observation.metadata.import_state_identity", importIdentity(first)),
|
|
1700
1934
|
attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
|
|
@@ -1703,6 +1937,12 @@ function toOtlp(events, options = {}) {
|
|
|
1703
1937
|
attr("project.path", firstProject.projectPath),
|
|
1704
1938
|
attr("project.name", firstProject.projectName),
|
|
1705
1939
|
attr("project.folder", firstProject.projectFolder),
|
|
1940
|
+
attr("project.tags", nonEmptyArray(firstProject.tags)),
|
|
1941
|
+
attr("project.group", firstProject.metadata.project_group),
|
|
1942
|
+
attr("project.owner", firstProject.metadata.project_owner),
|
|
1943
|
+
attr("git.worktree.path", firstProject.git.worktreePath),
|
|
1944
|
+
attr("git.branch", firstProject.git.branch),
|
|
1945
|
+
attr("git.commit", firstProject.git.commit),
|
|
1706
1946
|
].filter((item) => Boolean(item));
|
|
1707
1947
|
const rootSpan = {
|
|
1708
1948
|
traceId: traceId(first),
|
|
@@ -1722,7 +1962,7 @@ function toOtlp(events, options = {}) {
|
|
|
1722
1962
|
const generation = isGenerationEvent(event);
|
|
1723
1963
|
const usage = usageDetails(event.usage);
|
|
1724
1964
|
const cost = generation ? calculateCost(event, usage, costRates) : undefined;
|
|
1725
|
-
const eventProject =
|
|
1965
|
+
const eventProject = mergeProjectMetadata(event.cwd, pathTagsConfig);
|
|
1726
1966
|
const attributes = [
|
|
1727
1967
|
attr("service.name", `agent.${event.agent}`),
|
|
1728
1968
|
attr("deployment.environment", "local"),
|
|
@@ -1747,6 +1987,11 @@ function toOtlp(events, options = {}) {
|
|
|
1747
1987
|
attr("langfuse.observation.metadata.project_path", eventProject.projectPath),
|
|
1748
1988
|
attr("langfuse.observation.metadata.project_name", eventProject.projectName),
|
|
1749
1989
|
attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
|
|
1990
|
+
attr("langfuse.observation.metadata.project_tags", nonEmptyArray(eventProject.tags)),
|
|
1991
|
+
attr("langfuse.observation.metadata.path_tags", nonEmptyRecord(eventProject.metadata)),
|
|
1992
|
+
attr("langfuse.observation.metadata.git_worktree_path", eventProject.git.worktreePath),
|
|
1993
|
+
attr("langfuse.observation.metadata.git_branch", eventProject.git.branch),
|
|
1994
|
+
attr("langfuse.observation.metadata.git_commit", eventProject.git.commit),
|
|
1750
1995
|
attr("langfuse.observation.metadata.model", modelName ?? event.model),
|
|
1751
1996
|
attr("langfuse.observation.metadata.provider", event.provider),
|
|
1752
1997
|
attr("langfuse.observation.metadata.import_payload_version", payloadVersion(event)),
|
|
@@ -1770,6 +2015,12 @@ function toOtlp(events, options = {}) {
|
|
|
1770
2015
|
attr("project.path", eventProject.projectPath),
|
|
1771
2016
|
attr("project.name", eventProject.projectName),
|
|
1772
2017
|
attr("project.folder", eventProject.projectFolder),
|
|
2018
|
+
attr("project.tags", nonEmptyArray(eventProject.tags)),
|
|
2019
|
+
attr("project.group", eventProject.metadata.project_group),
|
|
2020
|
+
attr("project.owner", eventProject.metadata.project_owner),
|
|
2021
|
+
attr("git.worktree.path", eventProject.git.worktreePath),
|
|
2022
|
+
attr("git.branch", eventProject.git.branch),
|
|
2023
|
+
attr("git.commit", eventProject.git.commit),
|
|
1773
2024
|
attr("role", event.role),
|
|
1774
2025
|
attr("agent.model", event.model),
|
|
1775
2026
|
attr("agent.provider", event.provider),
|
|
@@ -1953,6 +2204,7 @@ async function run(options) {
|
|
|
1953
2204
|
maxRequestBytes: options.maxRequestBytes,
|
|
1954
2205
|
maxFieldBytes: options.maxFieldBytes,
|
|
1955
2206
|
costRates: options.costRates,
|
|
2207
|
+
pathTagsConfig: options.pathTagsConfig,
|
|
1956
2208
|
});
|
|
1957
2209
|
}
|
|
1958
2210
|
catch (error) {
|
|
@@ -1979,6 +2231,7 @@ async function run(options) {
|
|
|
1979
2231
|
await postOtlp(options.endpoint, batch, {
|
|
1980
2232
|
maxFieldBytes: options.maxFieldBytes,
|
|
1981
2233
|
costRates: options.costRates,
|
|
2234
|
+
pathTagsConfig: options.pathTagsConfig,
|
|
1982
2235
|
auth: options.auth,
|
|
1983
2236
|
});
|
|
1984
2237
|
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) {
|