@ramarivera/coding-agent-langfuse 0.1.46 → 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
@@ -31,7 +31,10 @@ 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();
35
38
  const kimiFirepassRates = {
36
39
  input: 2,
37
40
  output: 8,
@@ -192,6 +195,57 @@ function projectMetadata(cwd) {
192
195
  projectFolder: projectName,
193
196
  };
194
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
+ }
195
249
  function usage() {
196
250
  return `Usage: coding-agent-langfuse-backfill [options]
197
251
 
@@ -211,6 +265,7 @@ Options:
211
265
  --post-delay-ms N Delay after each successful OTLP POST (default: 0)
212
266
  --cost-rates PATH JSON model cost-rate overrides in USD per 1M tokens
213
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
214
269
  --follow Keep scanning and sending newly written events
215
270
  --poll-interval-ms N Delay between --follow scans (default: 5000)
216
271
  --idle-exit-after-ms N Stop --follow after this much time without new sends
@@ -241,6 +296,9 @@ function parseArgs(argv) {
241
296
  if (!Number.isFinite(postDelayMs))
242
297
  postDelayMs = 0;
243
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;
244
302
  let follow = false;
245
303
  let pollIntervalMs = 5_000;
246
304
  let idleExitAfterMs;
@@ -307,6 +365,9 @@ function parseArgs(argv) {
307
365
  else if (arg === "--cost-rates-json") {
308
366
  costRates = mergeCostCatalog(costRates, parseCostCatalogJson(next()));
309
367
  }
368
+ else if (arg === "--path-tags-config") {
369
+ pathTagsConfigPath = next();
370
+ }
310
371
  else if (arg === "--follow") {
311
372
  follow = true;
312
373
  }
@@ -378,8 +439,110 @@ function parseArgs(argv) {
378
439
  maxFieldBytes,
379
440
  postDelayMs,
380
441
  costRates,
442
+ pathTagsConfigPath,
443
+ pathTagsConfig: loadPathTagsConfig(pathTagsConfigPath),
381
444
  };
382
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,
540
+ };
541
+ }
542
+ function normalizeRulePath(path) {
543
+ const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
544
+ return /^[a-z]:\//i.test(normalized) ? normalized.toLowerCase() : normalized;
545
+ }
383
546
  function loadCostCatalogFromEnv() {
384
547
  let catalog = { ...defaultCostRates };
385
548
  const path = process.env.CODING_AGENT_LANGFUSE_COST_RATES_PATH ??
@@ -1613,6 +1776,12 @@ function attr(key, value) {
1613
1776
  return { key, value: { stringValue: value } };
1614
1777
  return { key, value: { stringValue: JSON.stringify(value).slice(0, 8000) } };
1615
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
+ }
1616
1785
  function utf8Bytes(value) {
1617
1786
  return Buffer.byteLength(value, "utf8");
1618
1787
  }
@@ -1645,6 +1814,7 @@ function limitEventPayload(event, maxFieldBytes) {
1645
1814
  function toOtlp(events, options = {}) {
1646
1815
  const maxFieldBytes = options.maxFieldBytes ?? defaultMaxFieldBytes;
1647
1816
  const costRates = options.costRates ?? loadCostCatalogFromEnv();
1817
+ const pathTagsConfig = options.pathTagsConfig ?? { rules: [] };
1648
1818
  const spansByTrace = new Map();
1649
1819
  for (const rawEvent of events) {
1650
1820
  const event = limitEventPayload(rawEvent, maxFieldBytes);
@@ -1659,7 +1829,7 @@ function toOtlp(events, options = {}) {
1659
1829
  const traceStartMs = sortedEvents[0]?.startMs ?? Date.now();
1660
1830
  const traceEndMs = Math.max(...sortedEvents.map((event) => event.endMs ?? event.startMs + 1), traceStartMs + 1);
1661
1831
  const shouldEmitRootSpan = sortedEvents.some((event) => event.recordId === "session");
1662
- const firstProject = projectMetadata(first.cwd);
1832
+ const firstProject = mergeProjectMetadata(first.cwd, pathTagsConfig);
1663
1833
  const rootAttributes = [
1664
1834
  attr("service.name", `agent.${first.agent}`),
1665
1835
  attr("deployment.environment", "local"),
@@ -1682,6 +1852,9 @@ function toOtlp(events, options = {}) {
1682
1852
  attr("langfuse.trace.metadata.project_path", firstProject.projectPath),
1683
1853
  attr("langfuse.trace.metadata.project_name", firstProject.projectName),
1684
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)),
1685
1858
  attr("langfuse.trace.metadata.import_payload_version", payloadVersion(first)),
1686
1859
  attr("langfuse.trace.metadata.import_state_identity", importIdentity(first)),
1687
1860
  attr("langfuse.trace.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
@@ -1695,6 +1868,8 @@ function toOtlp(events, options = {}) {
1695
1868
  attr("langfuse.observation.metadata.project_path", firstProject.projectPath),
1696
1869
  attr("langfuse.observation.metadata.project_name", firstProject.projectName),
1697
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)),
1698
1873
  attr("langfuse.observation.metadata.import_payload_version", payloadVersion(first)),
1699
1874
  attr("langfuse.observation.metadata.import_state_identity", importIdentity(first)),
1700
1875
  attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
@@ -1703,6 +1878,9 @@ function toOtlp(events, options = {}) {
1703
1878
  attr("project.path", firstProject.projectPath),
1704
1879
  attr("project.name", firstProject.projectName),
1705
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),
1706
1884
  ].filter((item) => Boolean(item));
1707
1885
  const rootSpan = {
1708
1886
  traceId: traceId(first),
@@ -1722,7 +1900,7 @@ function toOtlp(events, options = {}) {
1722
1900
  const generation = isGenerationEvent(event);
1723
1901
  const usage = usageDetails(event.usage);
1724
1902
  const cost = generation ? calculateCost(event, usage, costRates) : undefined;
1725
- const eventProject = projectMetadata(event.cwd);
1903
+ const eventProject = mergeProjectMetadata(event.cwd, pathTagsConfig);
1726
1904
  const attributes = [
1727
1905
  attr("service.name", `agent.${event.agent}`),
1728
1906
  attr("deployment.environment", "local"),
@@ -1747,6 +1925,8 @@ function toOtlp(events, options = {}) {
1747
1925
  attr("langfuse.observation.metadata.project_path", eventProject.projectPath),
1748
1926
  attr("langfuse.observation.metadata.project_name", eventProject.projectName),
1749
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)),
1750
1930
  attr("langfuse.observation.metadata.model", modelName ?? event.model),
1751
1931
  attr("langfuse.observation.metadata.provider", event.provider),
1752
1932
  attr("langfuse.observation.metadata.import_payload_version", payloadVersion(event)),
@@ -1770,6 +1950,9 @@ function toOtlp(events, options = {}) {
1770
1950
  attr("project.path", eventProject.projectPath),
1771
1951
  attr("project.name", eventProject.projectName),
1772
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),
1773
1956
  attr("role", event.role),
1774
1957
  attr("agent.model", event.model),
1775
1958
  attr("agent.provider", event.provider),
@@ -1953,6 +2136,7 @@ async function run(options) {
1953
2136
  maxRequestBytes: options.maxRequestBytes,
1954
2137
  maxFieldBytes: options.maxFieldBytes,
1955
2138
  costRates: options.costRates,
2139
+ pathTagsConfig: options.pathTagsConfig,
1956
2140
  });
1957
2141
  }
1958
2142
  catch (error) {
@@ -1979,6 +2163,7 @@ async function run(options) {
1979
2163
  await postOtlp(options.endpoint, batch, {
1980
2164
  maxFieldBytes: options.maxFieldBytes,
1981
2165
  costRates: options.costRates,
2166
+ pathTagsConfig: options.pathTagsConfig,
1982
2167
  auth: options.auth,
1983
2168
  });
1984
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.46",
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",