@ramarivera/coding-agent-langfuse 0.1.47 → 0.1.50

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
@@ -60,8 +60,8 @@ guessing from host names or Windows paths in the Langfuse UI.
60
60
  There are two config layers:
61
61
 
62
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
63
+ - Optional project-local overlays: every `.langfuse-ca.json` found while
64
+ walking from the session cwd up to the scanned home directory
65
65
 
66
66
  Global config uses prefix rules:
67
67
 
@@ -83,7 +83,9 @@ Global config uses prefix rules:
83
83
  ```
84
84
 
85
85
  Project-local config is intentionally smaller and overrides/extends the matched
86
- global rule:
86
+ global rule. Parent overlays are applied before child overlays, so a config at
87
+ `~/work/client-a/.langfuse-ca.json` can tag a whole workstream while nested
88
+ repos add repo-specific fields:
87
89
 
88
90
  ```json
89
91
  {
@@ -97,10 +99,13 @@ global rule:
97
99
 
98
100
  Matched values are emitted on root trace metadata, observation metadata, and
99
101
  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.
102
+ `project.owner`. When the session cwd is inside a Git repository, the exporter
103
+ also emits `git.worktree.path`, `git.branch`, and `git.commit` plus matching
104
+ Langfuse metadata fields. The root span also sets `langfuse.trace.tags`, so
105
+ Langfuse custom dashboards can filter/group by tags, metadata, branch, or
106
+ worktree. Historical repair runs use the same OTLP builder as live follow mode,
107
+ so rerunning with `--force` can backfill newly added tags and Git metadata for
108
+ existing sessions.
104
109
 
105
110
  Use a non-default global config path with:
106
111
 
@@ -109,9 +114,9 @@ npx @ramarivera/coding-agent-langfuse@latest \
109
114
  --path-tags-config "$HOME/.config/coding-agent-langfuse/path-tags.json"
110
115
  ```
111
116
 
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.
117
+ Generated services keep their command small and rely on the package's default
118
+ global config plus upward project-local discovery. Use `--path-tags-config` only
119
+ for one-off CLI runs that need a non-default global config path.
115
120
 
116
121
  ## Cost calculation
117
122
 
@@ -67,6 +67,7 @@ type OtlpOptions = {
67
67
  maxFieldBytes?: number;
68
68
  costRates?: CostCatalog;
69
69
  pathTagsConfig?: PathTagsConfig;
70
+ homeDir?: string;
70
71
  };
71
72
  type PathTagRule = {
72
73
  pathPrefix: string;
package/dist/backfill.js CHANGED
@@ -35,6 +35,7 @@ const defaultPathTagsConfigPath = join(homedir(), ".config/coding-agent-langfuse
35
35
  const currentHost = hostname();
36
36
  const projectLocalConfigFile = ".langfuse-ca.json";
37
37
  const projectLocalConfigCache = new Map();
38
+ const gitMetadataCache = new Map();
38
39
  const kimiFirepassRates = {
39
40
  input: 2,
40
41
  output: 8,
@@ -195,7 +196,7 @@ function projectMetadata(cwd) {
195
196
  projectFolder: projectName,
196
197
  };
197
198
  }
198
- function matchPathTags(cwd, config) {
199
+ function matchPathTags(cwd, config, homeDir) {
199
200
  if (!cwd)
200
201
  return { tags: [], metadata: {} };
201
202
  const normalizedCwd = normalizeRulePath(cwd);
@@ -217,7 +218,7 @@ function matchPathTags(cwd, config) {
217
218
  const overlays = [
218
219
  globalMatch,
219
220
  config.project,
220
- loadProjectLocalOverlay(cwd),
221
+ ...loadProjectLocalOverlays(cwd, homeDir),
221
222
  ].filter((overlay) => overlay !== undefined);
222
223
  return overlays.reduce((acc, overlay) => {
223
224
  if (!overlay)
@@ -235,17 +236,69 @@ function matchPathTags(cwd, config) {
235
236
  projectFolder: undefined,
236
237
  });
237
238
  }
238
- function mergeProjectMetadata(cwd, config) {
239
+ function mergeProjectMetadata(cwd, config, homeDir) {
239
240
  const project = projectMetadata(cwd);
240
- const pathTags = matchPathTags(cwd, config);
241
+ const pathTags = matchPathTags(cwd, config, homeDir);
241
242
  return {
242
243
  ...project,
243
244
  projectName: pathTags.projectName ?? project.projectName,
244
245
  projectFolder: pathTags.projectFolder ?? project.projectFolder,
245
246
  tags: pathTags.tags,
246
247
  metadata: pathTags.metadata,
248
+ git: loadGitMetadata(cwd),
247
249
  };
248
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
+ }
249
302
  function usage() {
250
303
  return `Usage: coding-agent-langfuse-backfill [options]
251
304
 
@@ -475,10 +528,12 @@ function loadPathTagsConfig(path) {
475
528
  });
476
529
  return { rules, project: normalizeProjectTagOverlay(root, path) };
477
530
  }
478
- function loadProjectLocalOverlay(cwd) {
479
- const configPath = findProjectLocalConfig(cwd);
480
- if (!configPath)
481
- return undefined;
531
+ function loadProjectLocalOverlays(cwd, homeDir) {
532
+ return findProjectLocalConfigs(cwd, homeDir)
533
+ .map((configPath) => loadProjectLocalOverlay(configPath))
534
+ .filter((overlay) => overlay !== undefined);
535
+ }
536
+ function loadProjectLocalOverlay(configPath) {
482
537
  const cached = projectLocalConfigCache.get(configPath);
483
538
  if (projectLocalConfigCache.has(configPath))
484
539
  return cached;
@@ -492,31 +547,26 @@ function loadProjectLocalOverlay(cwd) {
492
547
  projectLocalConfigCache.set(configPath, overlay);
493
548
  return overlay;
494
549
  }
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
- }
550
+ function findProjectLocalConfigs(cwd, homeDir) {
551
+ const start = nearestExistingDirectory(cwd);
552
+ if (!start)
553
+ return [];
554
+ const home = homeDir ? nearestExistingDirectory(homeDir) ?? homeDir : homedir();
555
+ const stopAtHome = isSameOrChildPath(start, home);
556
+ const configPaths = [];
557
+ let current = start;
511
558
  while (true) {
512
559
  const candidate = join(current, projectLocalConfigFile);
513
560
  if (existsSync(candidate))
514
- return candidate;
561
+ configPaths.push(candidate);
562
+ if (stopAtHome && sameNormalizedPath(current, home))
563
+ break;
515
564
  const parent = dirname(current);
516
565
  if (parent === current)
517
- return undefined;
566
+ break;
518
567
  current = parent;
519
568
  }
569
+ return configPaths.reverse();
520
570
  }
521
571
  function normalizeProjectTagOverlay(value, sourcePath) {
522
572
  const root = asRecord(value);
@@ -543,6 +593,15 @@ function normalizeRulePath(path) {
543
593
  const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
544
594
  return /^[a-z]:\//i.test(normalized) ? normalized.toLowerCase() : normalized;
545
595
  }
596
+ function sameNormalizedPath(left, right) {
597
+ return normalizeRulePath(left) === normalizeRulePath(right);
598
+ }
599
+ function isSameOrChildPath(child, parent) {
600
+ const normalizedChild = normalizeRulePath(child);
601
+ const normalizedParent = normalizeRulePath(parent);
602
+ return normalizedChild === normalizedParent ||
603
+ normalizedChild.startsWith(`${normalizedParent}/`);
604
+ }
546
605
  function loadCostCatalogFromEnv() {
547
606
  let catalog = { ...defaultCostRates };
548
607
  const path = process.env.CODING_AGENT_LANGFUSE_COST_RATES_PATH ??
@@ -1815,6 +1874,7 @@ function toOtlp(events, options = {}) {
1815
1874
  const maxFieldBytes = options.maxFieldBytes ?? defaultMaxFieldBytes;
1816
1875
  const costRates = options.costRates ?? loadCostCatalogFromEnv();
1817
1876
  const pathTagsConfig = options.pathTagsConfig ?? { rules: [] };
1877
+ const projectConfigHomeDir = options.homeDir ?? process.env.HOME ?? homedir();
1818
1878
  const spansByTrace = new Map();
1819
1879
  for (const rawEvent of events) {
1820
1880
  const event = limitEventPayload(rawEvent, maxFieldBytes);
@@ -1829,7 +1889,7 @@ function toOtlp(events, options = {}) {
1829
1889
  const traceStartMs = sortedEvents[0]?.startMs ?? Date.now();
1830
1890
  const traceEndMs = Math.max(...sortedEvents.map((event) => event.endMs ?? event.startMs + 1), traceStartMs + 1);
1831
1891
  const shouldEmitRootSpan = sortedEvents.some((event) => event.recordId === "session");
1832
- const firstProject = mergeProjectMetadata(first.cwd, pathTagsConfig);
1892
+ const firstProject = mergeProjectMetadata(first.cwd, pathTagsConfig, projectConfigHomeDir);
1833
1893
  const rootAttributes = [
1834
1894
  attr("service.name", `agent.${first.agent}`),
1835
1895
  attr("deployment.environment", "local"),
@@ -1854,6 +1914,9 @@ function toOtlp(events, options = {}) {
1854
1914
  attr("langfuse.trace.metadata.project_folder", firstProject.projectFolder),
1855
1915
  attr("langfuse.trace.metadata.project_tags", nonEmptyArray(firstProject.tags)),
1856
1916
  attr("langfuse.trace.metadata.path_tags", nonEmptyRecord(firstProject.metadata)),
1917
+ attr("langfuse.trace.metadata.git_worktree_path", firstProject.git.worktreePath),
1918
+ attr("langfuse.trace.metadata.git_branch", firstProject.git.branch),
1919
+ attr("langfuse.trace.metadata.git_commit", firstProject.git.commit),
1857
1920
  attr("langfuse.trace.tags", nonEmptyArray(firstProject.tags)),
1858
1921
  attr("langfuse.trace.metadata.import_payload_version", payloadVersion(first)),
1859
1922
  attr("langfuse.trace.metadata.import_state_identity", importIdentity(first)),
@@ -1870,6 +1933,9 @@ function toOtlp(events, options = {}) {
1870
1933
  attr("langfuse.observation.metadata.project_folder", firstProject.projectFolder),
1871
1934
  attr("langfuse.observation.metadata.project_tags", nonEmptyArray(firstProject.tags)),
1872
1935
  attr("langfuse.observation.metadata.path_tags", nonEmptyRecord(firstProject.metadata)),
1936
+ attr("langfuse.observation.metadata.git_worktree_path", firstProject.git.worktreePath),
1937
+ attr("langfuse.observation.metadata.git_branch", firstProject.git.branch),
1938
+ attr("langfuse.observation.metadata.git_commit", firstProject.git.commit),
1873
1939
  attr("langfuse.observation.metadata.import_payload_version", payloadVersion(first)),
1874
1940
  attr("langfuse.observation.metadata.import_state_identity", importIdentity(first)),
1875
1941
  attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
@@ -1881,6 +1947,9 @@ function toOtlp(events, options = {}) {
1881
1947
  attr("project.tags", nonEmptyArray(firstProject.tags)),
1882
1948
  attr("project.group", firstProject.metadata.project_group),
1883
1949
  attr("project.owner", firstProject.metadata.project_owner),
1950
+ attr("git.worktree.path", firstProject.git.worktreePath),
1951
+ attr("git.branch", firstProject.git.branch),
1952
+ attr("git.commit", firstProject.git.commit),
1884
1953
  ].filter((item) => Boolean(item));
1885
1954
  const rootSpan = {
1886
1955
  traceId: traceId(first),
@@ -1900,7 +1969,7 @@ function toOtlp(events, options = {}) {
1900
1969
  const generation = isGenerationEvent(event);
1901
1970
  const usage = usageDetails(event.usage);
1902
1971
  const cost = generation ? calculateCost(event, usage, costRates) : undefined;
1903
- const eventProject = mergeProjectMetadata(event.cwd, pathTagsConfig);
1972
+ const eventProject = mergeProjectMetadata(event.cwd, pathTagsConfig, projectConfigHomeDir);
1904
1973
  const attributes = [
1905
1974
  attr("service.name", `agent.${event.agent}`),
1906
1975
  attr("deployment.environment", "local"),
@@ -1927,6 +1996,9 @@ function toOtlp(events, options = {}) {
1927
1996
  attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
1928
1997
  attr("langfuse.observation.metadata.project_tags", nonEmptyArray(eventProject.tags)),
1929
1998
  attr("langfuse.observation.metadata.path_tags", nonEmptyRecord(eventProject.metadata)),
1999
+ attr("langfuse.observation.metadata.git_worktree_path", eventProject.git.worktreePath),
2000
+ attr("langfuse.observation.metadata.git_branch", eventProject.git.branch),
2001
+ attr("langfuse.observation.metadata.git_commit", eventProject.git.commit),
1930
2002
  attr("langfuse.observation.metadata.model", modelName ?? event.model),
1931
2003
  attr("langfuse.observation.metadata.provider", event.provider),
1932
2004
  attr("langfuse.observation.metadata.import_payload_version", payloadVersion(event)),
@@ -1953,6 +2025,9 @@ function toOtlp(events, options = {}) {
1953
2025
  attr("project.tags", nonEmptyArray(eventProject.tags)),
1954
2026
  attr("project.group", eventProject.metadata.project_group),
1955
2027
  attr("project.owner", eventProject.metadata.project_owner),
2028
+ attr("git.worktree.path", eventProject.git.worktreePath),
2029
+ attr("git.branch", eventProject.git.branch),
2030
+ attr("git.commit", eventProject.git.commit),
1956
2031
  attr("role", event.role),
1957
2032
  attr("agent.model", event.model),
1958
2033
  attr("agent.provider", event.provider),
@@ -2137,6 +2212,7 @@ async function run(options) {
2137
2212
  maxFieldBytes: options.maxFieldBytes,
2138
2213
  costRates: options.costRates,
2139
2214
  pathTagsConfig: options.pathTagsConfig,
2215
+ homeDir: options.homeDir,
2140
2216
  });
2141
2217
  }
2142
2218
  catch (error) {
@@ -2164,6 +2240,7 @@ async function run(options) {
2164
2240
  maxFieldBytes: options.maxFieldBytes,
2165
2241
  costRates: options.costRates,
2166
2242
  pathTagsConfig: options.pathTagsConfig,
2243
+ homeDir: options.homeDir,
2167
2244
  auth: options.auth,
2168
2245
  });
2169
2246
  for (const event of batch) {
package/dist/service.js CHANGED
@@ -36,11 +36,11 @@ Service options:
36
36
  `;
37
37
  }
38
38
  function parseServiceArgs(argv) {
39
- const action = parseServiceAction(argv[0]);
40
39
  if (argv.includes("--help") || argv.includes("-h")) {
41
40
  console.log(serviceUsage());
42
41
  process.exit(0);
43
42
  }
43
+ const action = parseServiceAction(argv[0]);
44
44
  let platform = currentServicePlatform();
45
45
  let agents = [...allAgents];
46
46
  let endpoint = process.env.LANGFUSE_BACKFILL_ENDPOINT ?? defaultEndpoint;
@@ -56,8 +56,7 @@ function parseServiceArgs(argv) {
56
56
  process.env.LANGFUSE_BACKFILL_COST_RATES_PATH;
57
57
  let costRatesJson = process.env.CODING_AGENT_LANGFUSE_COST_RATES_JSON ??
58
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;
59
+ let pathTagsConfigPath;
61
60
  let since;
62
61
  let dryRun = false;
63
62
  let start = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.47",
3
+ "version": "0.1.50",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",