oh-langfuse 0.1.25 → 0.1.26

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/langfuse_hook.py CHANGED
@@ -4,11 +4,12 @@ Claude Code -> Langfuse hook
4
4
 
5
5
  """
6
6
 
7
- import json
8
- import os
9
- import sys
10
- import time
11
- import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import time
12
+ import hashlib
12
13
  from dataclasses import dataclass
13
14
  from datetime import datetime, timezone
14
15
  from pathlib import Path
@@ -34,8 +35,9 @@ LOG_FILE = STATE_DIR / "langfuse_hook.log"
34
35
  STATE_FILE = STATE_DIR / "langfuse_state.json"
35
36
  LOCK_FILE = STATE_DIR / "langfuse_state.lock"
36
37
 
37
- DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
38
- MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
38
+ DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
39
+ MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
40
+ METRICS_SCHEMA_VERSION = "1.0"
39
41
 
40
42
  # ----------------- Logging -----------------
41
43
  def _log(level: str, message: str) -> None:
@@ -237,14 +239,157 @@ def extract_text(content: Any) -> str:
237
239
  return "\n".join([p for p in parts if p])
238
240
  return ""
239
241
 
240
- def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
242
+ def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
241
243
  if s is None:
242
244
  return "", {"truncated": False, "orig_len": 0}
243
245
  orig_len = len(s)
244
246
  if orig_len <= max_chars:
245
247
  return s, {"truncated": False, "orig_len": orig_len}
246
- head = s[:max_chars]
247
- return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
248
+ head = s[:max_chars]
249
+ return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
250
+
251
+ def build_interaction_id(source: str, session_id: str, turn_number: int) -> str:
252
+ return f"{source or 'unknown'}:{session_id or 'unknown'}:{int(turn_number or 0)}"
253
+
254
+ def _num_or_none(value: Any) -> Optional[int]:
255
+ if isinstance(value, bool):
256
+ return None
257
+ if isinstance(value, int) and value >= 0:
258
+ return value
259
+ if isinstance(value, float) and value >= 0:
260
+ return int(value)
261
+ if isinstance(value, str):
262
+ try:
263
+ n = int(value)
264
+ return n if n >= 0 else None
265
+ except Exception:
266
+ return None
267
+ return None
268
+
269
+ def _first_num(raw: Dict[str, Any], *keys: str) -> Optional[int]:
270
+ for key in keys:
271
+ if key in raw:
272
+ value = _num_or_none(raw.get(key))
273
+ if value is not None:
274
+ return value
275
+ return None
276
+
277
+ def normalize_token_metrics(raw: Optional[Dict[str, Any]]) -> Dict[str, Any]:
278
+ if not isinstance(raw, dict) or not raw:
279
+ return {
280
+ "token_metrics_available": False,
281
+ "input_tokens": None,
282
+ "output_tokens": None,
283
+ "total_tokens": None,
284
+ "cache_read_tokens": None,
285
+ "reasoning_tokens": None,
286
+ }
287
+ input_tokens = _first_num(raw, "input", "input_tokens", "inputTokens")
288
+ output_tokens = _first_num(raw, "output", "output_tokens", "outputTokens")
289
+ total_tokens = _first_num(raw, "total", "total_tokens", "totalTokens")
290
+ if total_tokens is None and input_tokens is not None and output_tokens is not None:
291
+ total_tokens = input_tokens + output_tokens
292
+ cache_read_tokens = _first_num(raw, "cache_read_tokens", "cachedInputTokens", "cacheRead")
293
+ reasoning_tokens = _first_num(raw, "reasoning_tokens", "reasoningTokens", "reasoning")
294
+ available = any(v is not None for v in [input_tokens, output_tokens, total_tokens, cache_read_tokens, reasoning_tokens])
295
+ return {
296
+ "token_metrics_available": available,
297
+ "input_tokens": input_tokens if available else None,
298
+ "output_tokens": output_tokens if available else None,
299
+ "total_tokens": total_tokens if available else None,
300
+ "cache_read_tokens": cache_read_tokens if available else None,
301
+ "reasoning_tokens": reasoning_tokens if available else None,
302
+ }
303
+
304
+ def _ratio(numerator: Optional[int], denominator: Optional[int]) -> Optional[float]:
305
+ if numerator is None or denominator in (None, 0):
306
+ return None
307
+ return numerator / denominator
308
+
309
+ def build_interaction_metadata(
310
+ source: str,
311
+ user_id: Optional[str],
312
+ session_id: str,
313
+ turn_number: int,
314
+ token_metrics: Optional[Dict[str, Any]],
315
+ tool_call_count: int,
316
+ tool_result_count: int,
317
+ skill_use_count: int,
318
+ model: Optional[str],
319
+ user_message_count: int = 1,
320
+ assistant_message_count: int = 1,
321
+ ) -> Dict[str, Any]:
322
+ tokens = normalize_token_metrics(token_metrics)
323
+ interaction_id = build_interaction_id(source, session_id, turn_number)
324
+ return {
325
+ "source": source,
326
+ "user_id": user_id or "",
327
+ "session_id": session_id,
328
+ "interaction_id": interaction_id,
329
+ "metrics_schema_version": METRICS_SCHEMA_VERSION,
330
+ "interaction_count": 1,
331
+ "user_message_count": user_message_count,
332
+ "assistant_message_count": assistant_message_count,
333
+ "tool_call_count": int(tool_call_count or 0),
334
+ "tool_result_count": int(tool_result_count or 0),
335
+ "skill_use_count": int(skill_use_count or 0),
336
+ **tokens,
337
+ "model": model,
338
+ "turn_number": int(turn_number or 0),
339
+ "efficiency": {
340
+ "tokens_per_interaction": tokens.get("total_tokens"),
341
+ "tool_calls_per_interaction": int(tool_call_count or 0),
342
+ "skills_per_interaction": int(skill_use_count or 0),
343
+ "output_input_token_ratio": _ratio(tokens.get("output_tokens"), tokens.get("input_tokens")),
344
+ "tokens_per_tool_call": _ratio(tokens.get("total_tokens"), int(tool_call_count or 0)),
345
+ },
346
+ }
347
+
348
+ def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
349
+ roots = [
350
+ Path.home() / ".codex" / "skills",
351
+ Path.home() / ".claude" / "skills",
352
+ Path.home() / ".config" / "opencode" / "skill",
353
+ ]
354
+ if extra_roots:
355
+ roots.extend(extra_roots)
356
+ names = set()
357
+ for root in roots:
358
+ try:
359
+ if not root.exists():
360
+ continue
361
+ for skill_file in root.rglob("SKILL.md"):
362
+ names.add(skill_file.parent.name)
363
+ except Exception:
364
+ continue
365
+ return names
366
+
367
+ def _skill_namespace(name: str) -> str:
368
+ return name.split(":", 1)[0] if ":" in name else ""
369
+
370
+ def detect_skill_usages(tool_calls: List[Dict[str, Any]], known_skills: set) -> List[Dict[str, str]]:
371
+ found: Dict[str, str] = {}
372
+ for call in tool_calls or []:
373
+ tool_name = str(call.get("name") or "")
374
+ input_obj = call.get("input") if isinstance(call.get("input"), (dict, list, str)) else {}
375
+ if tool_name.lower() == "skill" and isinstance(input_obj, dict):
376
+ for key in ("skill_name", "skill", "name"):
377
+ value = input_obj.get(key)
378
+ if isinstance(value, str) and value.strip():
379
+ found[value.strip()] = "tool_call"
380
+ break
381
+ try:
382
+ text = json.dumps(input_obj, ensure_ascii=False)
383
+ except Exception:
384
+ text = str(input_obj)
385
+ for match in re.finditer(r"([A-Za-z]:)?[^\"'\n\r]*[\\/]+([^\\/\"'\n\r]+)[\\/]+SKILL\.md", text, re.IGNORECASE):
386
+ candidate = match.group(2)
387
+ if candidate and (candidate in known_skills or not known_skills):
388
+ found[candidate] = "skill_file_path"
389
+ return [
390
+ {"name": name, "skill_namespace": _skill_namespace(name), "detected_by": detected_by}
391
+ for name, detected_by in sorted(found.items())
392
+ ]
248
393
 
249
394
  def get_model(msg: Dict[str, Any]) -> str:
250
395
  m = msg.get("message")
@@ -461,13 +606,31 @@ def emit_turn(
461
606
  assistant_text, assistant_text_meta = truncate_text(assistant_text_raw)
462
607
 
463
608
  model = get_model(turn.assistant_msgs[0])
464
- usage_details = get_usage(last_assistant)
465
-
466
- tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
467
-
468
- # attach tool outputs
469
- for c in tool_calls:
470
- if c["id"] and c["id"] in turn.tool_results_by_id:
609
+ usage_details = get_usage(last_assistant)
610
+
611
+ tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
612
+ skill_usages = detect_skill_usages(tool_calls, discover_known_skills())
613
+ interaction_meta = build_interaction_metadata(
614
+ "claude",
615
+ user_id,
616
+ session_id,
617
+ turn_num,
618
+ usage_details,
619
+ len(tool_calls),
620
+ len(turn.tool_results_by_id),
621
+ len(skill_usages),
622
+ model,
623
+ user_message_count=1,
624
+ assistant_message_count=len(turn.assistant_msgs),
625
+ )
626
+ skill_summary = [
627
+ {"name": item["name"], "count": 1, "detected_by": item["detected_by"]}
628
+ for item in skill_usages
629
+ ]
630
+
631
+ # attach tool outputs
632
+ for c in tool_calls:
633
+ if c["id"] and c["id"] in turn.tool_results_by_id:
471
634
  out_raw = turn.tool_results_by_id[c["id"]]
472
635
  out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False)
473
636
  out_trunc, out_meta = truncate_text(out_str)
@@ -484,33 +647,65 @@ def emit_turn(
484
647
  ):
485
648
  with langfuse.start_as_current_observation(
486
649
  name=f"Claude Code - Turn {turn_num}",
487
- input={"role": "user", "content": user_text},
488
- metadata={
489
- "source": "claude-code",
490
- "session_id": session_id,
491
- "turn_number": turn_num,
492
- "transcript_path": str(transcript_path),
493
- "user_text": user_text_meta,
494
- },
495
- ) as trace_span:
496
- # LLM generation
497
- with langfuse.start_as_current_observation(
498
- name="Claude Response",
650
+ input={"role": "user", "content": user_text},
651
+ metadata={
652
+ **interaction_meta,
653
+ "source": "claude",
654
+ "session_id": session_id,
655
+ "turn_number": turn_num,
656
+ "transcript_path": str(transcript_path),
657
+ "user_text": user_text_meta,
658
+ "skills": skill_summary,
659
+ },
660
+ ) as trace_span:
661
+ with langfuse.start_as_current_observation(
662
+ name="AI Interaction",
663
+ input={"role": "user", "content": user_text},
664
+ output={"role": "assistant", "content": assistant_text},
665
+ metadata=interaction_meta,
666
+ ):
667
+ pass
668
+
669
+ # LLM generation
670
+ with langfuse.start_as_current_observation(
671
+ name="Claude Response",
499
672
  as_type="generation",
500
673
  model=model,
501
674
  input={"role": "user", "content": user_text},
502
675
  output={"role": "assistant", "content": assistant_text},
503
676
  usage_details=usage_details or None,
504
677
  metadata={
505
- "assistant_text": assistant_text_meta,
506
- "tool_count": len(tool_calls),
507
- "usage_details": usage_details,
508
- },
509
- ):
510
- pass
511
-
512
- # Tool observations
513
- for tc in tool_calls:
678
+ "assistant_text": assistant_text_meta,
679
+ "tool_count": len(tool_calls),
680
+ "usage_details": usage_details,
681
+ "source": "claude",
682
+ "user_id": user_id or "",
683
+ "session_id": session_id,
684
+ "interaction_id": interaction_meta["interaction_id"],
685
+ "turn_number": turn_num,
686
+ },
687
+ ):
688
+ pass
689
+
690
+ for skill in skill_usages:
691
+ with langfuse.start_as_current_observation(
692
+ name=f"Skill Use: {skill['name']}",
693
+ metadata={
694
+ "source": "claude",
695
+ "user_id": user_id or "",
696
+ "session_id": session_id,
697
+ "interaction_id": interaction_meta["interaction_id"],
698
+ "skill_name": skill["name"],
699
+ "skill_namespace": skill["skill_namespace"],
700
+ "detected_by": skill["detected_by"],
701
+ "turn_number": turn_num,
702
+ "metrics_schema_version": METRICS_SCHEMA_VERSION,
703
+ },
704
+ ):
705
+ pass
706
+
707
+ # Tool observations
708
+ for tc in tool_calls:
514
709
  in_obj = tc["input"]
515
710
  # truncate tool input if it's a large string payload
516
711
  if isinstance(in_obj, str):
@@ -522,14 +717,20 @@ def emit_turn(
522
717
  name=f"Tool: {tc['name']}",
523
718
  as_type="tool",
524
719
  input=in_obj,
525
- metadata={
526
- "tool_name": tc["name"],
527
- "tool_id": tc["id"],
528
- "input_meta": in_meta,
529
- "output_meta": tc.get("output_meta"),
530
- },
531
- ) as tool_obs:
532
- tool_obs.update(output=tc.get("output"))
720
+ metadata={
721
+ "source": "claude",
722
+ "user_id": user_id or "",
723
+ "session_id": session_id,
724
+ "interaction_id": interaction_meta["interaction_id"],
725
+ "tool_name": tc["name"],
726
+ "tool_id": tc["id"],
727
+ "turn_number": turn_num,
728
+ "input_meta": in_meta,
729
+ "output_meta": tc.get("output_meta"),
730
+ "metrics_schema_version": METRICS_SCHEMA_VERSION,
731
+ },
732
+ ) as tool_obs:
733
+ tool_obs.update(output=tc.get("output"))
533
734
 
534
735
  trace_span.update(output={"role": "assistant", "content": assistant_text})
535
736
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -21,8 +21,11 @@
21
21
  "scripts/opencode-langfuse-check.mjs",
22
22
  "scripts/opencode-langfuse-run.mjs",
23
23
  "scripts/opencode-langfuse-setup.mjs",
24
- "scripts/resolve-opencode-cli.mjs",
24
+ "scripts/resolve-opencode-cli.mjs",
25
25
  "scripts/real-self-verify.mjs",
26
+ "scripts/metrics-utils.mjs",
27
+ "scripts/update-langfuse-runtime.mjs",
28
+ "scripts/update-utils.mjs",
26
29
  "langfuse_hook.py",
27
30
  "codex_langfuse_notify.py",
28
31
  "README.md",
@@ -31,9 +34,10 @@
31
34
  "setup-langfuse.bat",
32
35
  "setup-langfuse.sh"
33
36
  ],
34
- "scripts": {
35
- "start": "node bin/cli.js",
37
+ "scripts": {
38
+ "start": "node bin/cli.js",
36
39
  "check": "node --check bin/cli.js",
40
+ "test": "node --test tests/*.test.mjs",
37
41
  "pack:check": "npm pack --dry-run",
38
42
  "claude:setup": "node scripts/langfuse-setup.mjs",
39
43
  "claude:check": "node scripts/langfuse-check.mjs",
@@ -47,8 +51,9 @@
47
51
  "opencode:langfuse:run": "node scripts/opencode-langfuse-run.mjs",
48
52
  "codex:setup": "node scripts/codex-langfuse-setup.mjs",
49
53
  "codex:check": "node scripts/codex-langfuse-check.mjs",
50
- "codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
54
+ "codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
51
55
  "codex:langfuse:check": "node scripts/codex-langfuse-check.mjs",
56
+ "update": "node scripts/update-langfuse-runtime.mjs",
52
57
  "self:verify": "node scripts/real-self-verify.mjs"
53
58
  },
54
59
  "dependencies": {}
@@ -0,0 +1,126 @@
1
+ export const METRICS_SCHEMA_VERSION = "1.0";
2
+
3
+ function numberOrNull(value) {
4
+ if (typeof value === "string" && value.trim().startsWith("{")) {
5
+ try {
6
+ const parsed = JSON.parse(value);
7
+ return numberOrNull(parsed.intValue ?? parsed.doubleValue ?? parsed.value);
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+ const n = Number(value);
13
+ return Number.isFinite(n) && n >= 0 ? n : null;
14
+ }
15
+
16
+ export function buildInteractionId(source, sessionId, turnNumber) {
17
+ return `${String(source || "unknown")}:${String(sessionId || "unknown")}:${Number(turnNumber) || 0}`;
18
+ }
19
+
20
+ export function normalizeTokenMetrics(raw) {
21
+ if (!raw || typeof raw !== "object") {
22
+ return {
23
+ token_metrics_available: false,
24
+ input_tokens: null,
25
+ output_tokens: null,
26
+ total_tokens: null,
27
+ cache_read_tokens: null,
28
+ reasoning_tokens: null,
29
+ };
30
+ }
31
+
32
+ const input = numberOrNull(raw.input ?? raw.input_tokens ?? raw.inputTokens);
33
+ const output = numberOrNull(raw.output ?? raw.output_tokens ?? raw.outputTokens);
34
+ const total = numberOrNull(raw.total ?? raw.total_tokens ?? raw.totalTokens ?? (input != null && output != null ? input + output : null));
35
+ const cacheRead = numberOrNull(raw.cacheRead ?? raw.cache_read_tokens ?? raw.cachedInputTokens);
36
+ const reasoning = numberOrNull(raw.reasoning ?? raw.reasoning_tokens ?? raw.reasoningTokens);
37
+
38
+ const available = [input, output, total, cacheRead, reasoning].some((value) => value != null);
39
+ return {
40
+ token_metrics_available: available,
41
+ input_tokens: available ? input : null,
42
+ output_tokens: available ? output : null,
43
+ total_tokens: available ? total : null,
44
+ cache_read_tokens: available ? cacheRead : null,
45
+ reasoning_tokens: available ? reasoning : null,
46
+ };
47
+ }
48
+
49
+ function ratio(numerator, denominator) {
50
+ if (numerator == null || denominator == null || denominator === 0) return null;
51
+ return numerator / denominator;
52
+ }
53
+
54
+ export function buildInteractionMetadata(options = {}) {
55
+ const source = String(options.source || "unknown");
56
+ const sessionId = String(options.sessionId || options.session_id || "unknown");
57
+ const turnNumber = Number(options.turnNumber ?? options.turn_number ?? 0) || 0;
58
+ const tokenMetrics = normalizeTokenMetrics(options.tokenMetrics);
59
+ const toolCallCount = Number(options.toolCallCount ?? options.tool_call_count ?? 0) || 0;
60
+ const toolResultCount = Number(options.toolResultCount ?? options.tool_result_count ?? 0) || 0;
61
+ const skillUseCount = Number(options.skillUseCount ?? options.skill_use_count ?? 0) || 0;
62
+
63
+ return {
64
+ source,
65
+ user_id: String(options.userId || options.user_id || ""),
66
+ session_id: sessionId,
67
+ interaction_id: options.interactionId || buildInteractionId(source, sessionId, turnNumber),
68
+ metrics_schema_version: METRICS_SCHEMA_VERSION,
69
+ interaction_count: 1,
70
+ user_message_count: Number(options.userMessageCount ?? options.user_message_count ?? 1) || 1,
71
+ assistant_message_count: Number(options.assistantMessageCount ?? options.assistant_message_count ?? 1) || 1,
72
+ tool_call_count: toolCallCount,
73
+ tool_result_count: toolResultCount,
74
+ skill_use_count: skillUseCount,
75
+ ...tokenMetrics,
76
+ model: options.model || null,
77
+ turn_number: turnNumber,
78
+ efficiency: {
79
+ tokens_per_interaction: tokenMetrics.total_tokens,
80
+ tool_calls_per_interaction: toolCallCount,
81
+ skills_per_interaction: skillUseCount,
82
+ output_input_token_ratio: ratio(tokenMetrics.output_tokens, tokenMetrics.input_tokens),
83
+ tokens_per_tool_call: ratio(tokenMetrics.total_tokens, toolCallCount),
84
+ },
85
+ };
86
+ }
87
+
88
+ export function buildOpencodeMetricAttributes(options = {}) {
89
+ const attrs = options.attributes || {};
90
+ const userId = String(options.userId || attrs["oh.langfuse.user_id"] || attrs["langfuse.user.id"] || "");
91
+ const sessionId = String(attrs["ai.request.headers.x-opencode-session"] || options.sessionId || options.session_id || "unknown");
92
+ const requestId = String(attrs["ai.request.headers.x-opencode-request"] || options.requestId || options.request_id || "unknown");
93
+ const spanId = String(options.spanId || attrs["span.id"] || "unknown");
94
+ const provider = attrs["ai.model.provider"] || attrs["gen_ai.system"] || "";
95
+ const modelId = attrs["ai.model.id"] || attrs["gen_ai.request.model"] || attrs["ai.response.model"] || "";
96
+ const model = provider && modelId ? `${provider}/${modelId}` : provider || modelId || null;
97
+ const tokenMetrics = normalizeTokenMetrics({
98
+ input: attrs["ai.usage.inputTokens"] ?? attrs["ai.usage.promptTokens"] ?? attrs["gen_ai.usage.input_tokens"],
99
+ output: attrs["ai.usage.outputTokens"] ?? attrs["ai.usage.completionTokens"] ?? attrs["gen_ai.usage.output_tokens"],
100
+ total: attrs["ai.usage.totalTokens"],
101
+ cacheRead: attrs["ai.usage.cachedInputTokens"] ?? attrs["ai.usage.inputTokenDetails.cacheReadTokens"],
102
+ reasoning: attrs["ai.usage.reasoningTokens"] ?? attrs["ai.usage.outputTokenDetails.reasoningTokens"],
103
+ });
104
+
105
+ const out = {
106
+ "langfuse.observation.metadata.source": "opencode",
107
+ "langfuse.observation.metadata.user_id": userId,
108
+ "langfuse.observation.metadata.session_id": sessionId,
109
+ "langfuse.observation.metadata.interaction_id": `opencode:${userId || "unknown"}:${sessionId}:${requestId}:${spanId}`,
110
+ "langfuse.observation.metadata.metrics_schema_version": METRICS_SCHEMA_VERSION,
111
+ "langfuse.observation.metadata.interaction_count": 1,
112
+ "langfuse.observation.metadata.user_message_count": 1,
113
+ "langfuse.observation.metadata.assistant_message_count": 1,
114
+ "langfuse.observation.metadata.tool_call_count": 0,
115
+ "langfuse.observation.metadata.tool_result_count": 0,
116
+ "langfuse.observation.metadata.skill_use_count": 0,
117
+ "langfuse.observation.metadata.token_metrics_available": tokenMetrics.token_metrics_available,
118
+ "langfuse.observation.metadata.model": model,
119
+ };
120
+
121
+ for (const key of ["input_tokens", "output_tokens", "total_tokens", "cache_read_tokens", "reasoning_tokens"]) {
122
+ if (tokenMetrics[key] != null) out[`langfuse.observation.metadata.${key}`] = tokenMetrics[key];
123
+ }
124
+
125
+ return out;
126
+ }