@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 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
@@ -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: "v12-cost-details",
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
- events.push({
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: pick(row, [
1412
- "type",
1413
- "entrypoint",
1414
- "version",
1415
- "gitBranch",
1416
- "error",
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: recordId,
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: asString(row.uuid) ?? asString(row.id),
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 = projectMetadata(first.cwd);
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 = projectMetadata(event.cwd);
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
@@ -15,6 +15,7 @@ type ServiceOptions = {
15
15
  postDelayMs: number;
16
16
  costRatesPath?: string;
17
17
  costRatesJson?: string;
18
+ pathTagsConfigPath?: string;
18
19
  npxPath: string;
19
20
  since?: string;
20
21
  dryRun: boolean;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",