oh-langfuse 0.1.41 → 0.1.43

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
@@ -10,12 +10,40 @@ import re
10
10
  import sys
11
11
  import time
12
12
  import hashlib
13
- from dataclasses import dataclass
14
- from datetime import datetime, timezone
15
- from pathlib import Path
16
- from typing import Any, Dict, List, Optional, Tuple
17
-
18
- # --- Langfuse import (fail-open) ---
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+ from urllib.parse import urlparse
18
+
19
+
20
+ def configure_langfuse_no_proxy() -> None:
21
+ hosts = ["localhost", "127.0.0.1"]
22
+ for key in ("LANGFUSE_HOST", "LANGFUSE_BASEURL", "CC_LANGFUSE_BASE_URL"):
23
+ value = os.environ.get(key)
24
+ if not value:
25
+ continue
26
+ parsed = urlparse(value if "://" in value else f"http://{value}")
27
+ if parsed.hostname:
28
+ hosts.append(parsed.hostname)
29
+ if parsed.netloc:
30
+ hosts.append(parsed.netloc)
31
+ existing = []
32
+ for key in ("NO_PROXY", "no_proxy"):
33
+ existing.extend([item.strip() for item in os.environ.get(key, "").split(",") if item.strip()])
34
+ merged = []
35
+ for item in [*existing, *hosts]:
36
+ if item and item not in merged:
37
+ merged.append(item)
38
+ if merged:
39
+ value = ",".join(merged)
40
+ os.environ["NO_PROXY"] = value
41
+ os.environ["no_proxy"] = value
42
+
43
+
44
+ configure_langfuse_no_proxy()
45
+
46
+ # --- Langfuse import (fail-open) ---
19
47
  try:
20
48
  from langfuse import Langfuse, propagate_attributes
21
49
  except Exception as e:
@@ -37,7 +65,8 @@ LOCK_FILE = STATE_DIR / "langfuse_state.lock"
37
65
 
38
66
  DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
39
67
  MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
40
- METRICS_SCHEMA_VERSION = "1.0"
68
+ METRICS_SCHEMA_VERSION = "1.1"
69
+ AGENT_NAME = "claude"
41
70
 
42
71
  # ----------------- Logging -----------------
43
72
  def _log(level: str, message: str) -> None:
@@ -318,11 +347,17 @@ def build_interaction_metadata(
318
347
  model: Optional[str],
319
348
  user_message_count: int = 1,
320
349
  assistant_message_count: int = 1,
350
+ skill_use_events: Optional[List[Dict[str, Any]]] = None,
321
351
  ) -> Dict[str, Any]:
322
352
  tokens = normalize_token_metrics(token_metrics)
323
353
  interaction_id = build_interaction_id(source, session_id, turn_number)
354
+ events = list(skill_use_events or [])
355
+ skill_names_all = [str(event.get("skill_name") or "") for event in events if event.get("skill_name")]
356
+ unique_skill_names = list(dict.fromkeys(skill_names_all))
357
+ effective_skill_count = len(events) if events else int(skill_use_count or 0)
324
358
  return {
325
359
  "source": source,
360
+ "agent": source,
326
361
  "user_id": user_id or "",
327
362
  "session_id": session_id,
328
363
  "interaction_id": interaction_id,
@@ -332,17 +367,23 @@ def build_interaction_metadata(
332
367
  "assistant_message_count": assistant_message_count,
333
368
  "tool_call_count": int(tool_call_count or 0),
334
369
  "tool_result_count": int(tool_result_count or 0),
335
- "skill_use_count": int(skill_use_count or 0),
370
+ "skill_use_count": effective_skill_count,
371
+ "unique_skill_count": len(unique_skill_names),
372
+ "repeated_skill_count": max(0, effective_skill_count - len(unique_skill_names)),
336
373
  **tokens,
337
374
  "model": model,
338
375
  "turn_number": int(turn_number or 0),
339
376
  "efficiency": {
340
377
  "tokens_per_interaction": tokens.get("total_tokens"),
341
378
  "tool_calls_per_interaction": int(tool_call_count or 0),
342
- "skills_per_interaction": int(skill_use_count or 0),
379
+ "skills_per_interaction": effective_skill_count,
343
380
  "output_input_token_ratio": _ratio(tokens.get("output_tokens"), tokens.get("input_tokens")),
344
381
  "tokens_per_tool_call": _ratio(tokens.get("total_tokens"), int(tool_call_count or 0)),
345
382
  },
383
+ **({
384
+ "skill_names": unique_skill_names,
385
+ "skill_names_all": skill_names_all,
386
+ } if events else {}),
346
387
  }
347
388
 
348
389
  def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
@@ -367,16 +408,31 @@ def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
367
408
  def _skill_namespace(name: str) -> str:
368
409
  return name.split(":", 1)[0] if ":" in name else ""
369
410
 
411
+ def _skill_event_type(detected_by: str) -> str:
412
+ return "invoked" if detected_by in ("tool_call", "plugin_event", "attribution_skill", "slash_command") else "detected"
413
+
414
+ def _skill_id_segment(name: str) -> str:
415
+ segment = re.sub(r"[^A-Za-z0-9_.:-]+", "-", str(name or "").strip()).strip("-")
416
+ return (segment or "unknown")[:96]
417
+
370
418
  def detect_skill_usages(tool_calls: List[Dict[str, Any]], known_skills: set) -> List[Dict[str, str]]:
371
- found: Dict[str, str] = {}
419
+ found: List[Dict[str, str]] = []
420
+ seen_call_ids: set = set()
372
421
  for call in tool_calls or []:
373
422
  tool_name = str(call.get("name") or "")
423
+ call_id = str(call.get("id") or call.get("call_id") or call.get("callId") or call.get("tool_call_id") or call.get("toolCallId") or "").strip()
374
424
  input_obj = call.get("input") if isinstance(call.get("input"), (dict, list, str)) else {}
375
425
  if tool_name.lower() == "skill" and isinstance(input_obj, dict):
376
426
  for key in ("skill_name", "skill", "name"):
377
427
  value = input_obj.get(key)
378
428
  if isinstance(value, str) and value.strip():
379
- found[value.strip()] = "tool_call"
429
+ name = value.strip()
430
+ if call_id:
431
+ dedupe_key = f"call:{call_id}"
432
+ if dedupe_key in seen_call_ids:
433
+ break
434
+ seen_call_ids.add(dedupe_key)
435
+ found.append({"name": name, "skill_namespace": _skill_namespace(name), "detected_by": "tool_call", "skill_call_id": call_id})
380
436
  break
381
437
  try:
382
438
  text = json.dumps(input_obj, ensure_ascii=False)
@@ -385,11 +441,143 @@ def detect_skill_usages(tool_calls: List[Dict[str, Any]], known_skills: set) ->
385
441
  for match in re.finditer(r"([A-Za-z]:)?[^\"'\n\r]*[\\/]+([^\\/\"'\n\r]+)[\\/]+SKILL\.md", text, re.IGNORECASE):
386
442
  candidate = match.group(2)
387
443
  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
- ]
444
+ found.append({"name": candidate, "skill_namespace": _skill_namespace(candidate), "detected_by": "skill_file_path"})
445
+ return found
446
+
447
+ def _skill_usage(name: str, detected_by: str, skill_call_id: str = "") -> Dict[str, str]:
448
+ clean = str(name or "").strip().lstrip("/")
449
+ return {
450
+ "name": clean,
451
+ "skill_namespace": _skill_namespace(clean),
452
+ "detected_by": detected_by,
453
+ "skill_call_id": str(skill_call_id or "").strip(),
454
+ }
455
+
456
+ def _accept_skill_candidate(name: Any, known_skills: set, trusted: bool = False) -> str:
457
+ clean = str(name or "").strip().lstrip("/")
458
+ if not clean:
459
+ return ""
460
+ if trusted or not known_skills or clean in known_skills:
461
+ return clean
462
+ return ""
463
+
464
+ def _detect_skill_usages_from_text(text: str, known_skills: set) -> List[Dict[str, str]]:
465
+ found: List[Dict[str, str]] = []
466
+ if not text:
467
+ return found
468
+
469
+ for pattern in (
470
+ r"<command-name>\s*/?([^<\s]+)\s*</command-name>",
471
+ r"<command-message>\s*/?([^<\s]+)\s*</command-message>",
472
+ ):
473
+ for match in re.finditer(pattern, text, re.IGNORECASE):
474
+ name = _accept_skill_candidate(match.group(1), known_skills)
475
+ if name:
476
+ found.append(_skill_usage(name, "slash_command"))
477
+
478
+ for match in re.finditer(r"Base directory for this skill:\s*([^\r\n]+)", text, re.IGNORECASE):
479
+ path_text = match.group(1)
480
+ path_match = re.search(r"[\\/](?:skills|skill)[\\/]([^\\/\"\r\n]+)", path_text, re.IGNORECASE)
481
+ if path_match:
482
+ name = _accept_skill_candidate(path_match.group(1), known_skills)
483
+ if name:
484
+ found.append(_skill_usage(name, "skill_file_path"))
485
+
486
+ return found
487
+
488
+ def _attribution_skill_from_row(row: Dict[str, Any]) -> str:
489
+ if not isinstance(row, dict):
490
+ return ""
491
+ value = row.get("attributionSkill") or row.get("attribution_skill")
492
+ if isinstance(value, str) and value.strip():
493
+ return value.strip()
494
+ message = row.get("message")
495
+ if isinstance(message, dict):
496
+ value = message.get("attributionSkill") or message.get("attribution_skill")
497
+ if isinstance(value, str) and value.strip():
498
+ return value.strip()
499
+ return ""
500
+
501
+ def _dedupe_turn_skill_usages(usages: List[Dict[str, str]]) -> List[Dict[str, str]]:
502
+ out: List[Dict[str, str]] = []
503
+ seen_call_ids: set = set()
504
+ seen_detected_names: set = set()
505
+ for usage in usages or []:
506
+ name = str(usage.get("name") or "").strip()
507
+ if not name:
508
+ continue
509
+ call_id = str(usage.get("skill_call_id") or "").strip()
510
+ if call_id:
511
+ key = f"call:{call_id}"
512
+ if key in seen_call_ids:
513
+ continue
514
+ seen_call_ids.add(key)
515
+ out.append(usage)
516
+ continue
517
+
518
+ detected_by = str(usage.get("detected_by") or "")
519
+ if detected_by in ("attribution_skill", "slash_command", "skill_file_path"):
520
+ key = f"name:{name}"
521
+ if key in seen_detected_names:
522
+ continue
523
+ seen_detected_names.add(key)
524
+ out.append(usage)
525
+ return out
526
+
527
+ def detect_turn_skill_usages(turn: "Turn", tool_calls: List[Dict[str, Any]], known_skills: set) -> List[Dict[str, str]]:
528
+ found = list(detect_skill_usages(tool_calls, known_skills))
529
+ rows = [turn.user_msg, *turn.assistant_msgs]
530
+
531
+ for row in rows:
532
+ attributed = _accept_skill_candidate(_attribution_skill_from_row(row), known_skills, trusted=True)
533
+ if attributed:
534
+ found.append(_skill_usage(attributed, "attribution_skill"))
535
+ found.extend(_detect_skill_usages_from_text(extract_text(get_content(row)), known_skills))
536
+
537
+ return _dedupe_turn_skill_usages(found)
538
+
539
+ def build_skill_use_events(interaction_id: str, skill_usages: List[Dict[str, str]]) -> List[Dict[str, Any]]:
540
+ events: List[Dict[str, Any]] = []
541
+ deduped: List[Dict[str, str]] = []
542
+ seen_call_ids: set = set()
543
+ for skill in skill_usages or []:
544
+ call_id = str(skill.get("skill_call_id") or "").strip()
545
+ if call_id:
546
+ dedupe_key = f"call:{call_id}"
547
+ if dedupe_key in seen_call_ids:
548
+ continue
549
+ seen_call_ids.add(dedupe_key)
550
+ deduped.append(skill)
551
+ total = len(deduped)
552
+ for index, skill in enumerate(deduped, start=1):
553
+ name = str(skill.get("name") or "").strip()
554
+ if not name:
555
+ continue
556
+ detected_by = str(skill.get("detected_by") or "metadata")
557
+ call_id = str(skill.get("skill_call_id") or "").strip()
558
+ events.append({
559
+ "skill_use_id": f"{interaction_id}:skill:{index}:{_skill_id_segment(name)}",
560
+ "skill_use_index": index,
561
+ "skill_use_count_in_interaction": total,
562
+ "skill_event_type": _skill_event_type(detected_by),
563
+ "skill_trigger": "unknown",
564
+ "skill_name": name,
565
+ "skill_use_count": 1,
566
+ "skill_namespace": skill.get("skill_namespace") or _skill_namespace(name),
567
+ "detected_by": detected_by,
568
+ **({"skill_call_id": call_id} if call_id else {}),
569
+ })
570
+ return events
571
+
572
+ def summarize_skill_usages(skill_usages: List[Dict[str, str]]) -> List[Dict[str, Any]]:
573
+ summary: Dict[str, Dict[str, Any]] = {}
574
+ for item in skill_usages or []:
575
+ name = item.get("name")
576
+ if not name:
577
+ continue
578
+ entry = summary.setdefault(name, {"name": name, "count": 0, "detected_by": item.get("detected_by")})
579
+ entry["count"] += 1
580
+ return list(summary.values())
393
581
 
394
582
  def get_model(msg: Dict[str, Any]) -> str:
395
583
  m = msg.get("message")
@@ -609,7 +797,9 @@ def emit_turn(
609
797
  usage_details = get_usage(last_assistant)
610
798
 
611
799
  tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
612
- skill_usages = detect_skill_usages(tool_calls, discover_known_skills())
800
+ skill_usages = detect_turn_skill_usages(turn, tool_calls, discover_known_skills())
801
+ interaction_id = build_interaction_id("claude", session_id, turn_num)
802
+ skill_use_events = build_skill_use_events(interaction_id, skill_usages)
613
803
  interaction_meta = build_interaction_metadata(
614
804
  "claude",
615
805
  user_id,
@@ -618,15 +808,13 @@ def emit_turn(
618
808
  usage_details,
619
809
  len(tool_calls),
620
810
  len(turn.tool_results_by_id),
621
- len(skill_usages),
811
+ len(skill_use_events),
622
812
  model,
623
813
  user_message_count=1,
624
814
  assistant_message_count=len(turn.assistant_msgs),
815
+ skill_use_events=skill_use_events,
625
816
  )
626
- skill_summary = [
627
- {"name": item["name"], "count": 1, "detected_by": item["detected_by"]}
628
- for item in skill_usages
629
- ]
817
+ skill_summary = summarize_skill_usages(skill_usages)
630
818
 
631
819
  # attach tool outputs
632
820
  for c in tool_calls:
@@ -642,15 +830,17 @@ def emit_turn(
642
830
  with propagate_attributes(
643
831
  user_id=user_id,
644
832
  session_id=session_id,
645
- trace_name=f"Claude Code - Turn {turn_num}",
646
- tags=["claude-code"],
833
+ trace_name="Agent Turn",
834
+ tags=[AGENT_NAME],
647
835
  ):
648
- with langfuse.start_as_current_observation(
649
- name=f"Claude Code - Turn {turn_num}",
836
+ with langfuse.start_as_current_observation(
837
+ name="Agent Turn",
650
838
  input={"role": "user", "content": user_text},
839
+ output={"role": "assistant", "content": assistant_text},
651
840
  metadata={
652
841
  **interaction_meta,
653
- "source": "claude",
842
+ "source": AGENT_NAME,
843
+ "agent": AGENT_NAME,
654
844
  "session_id": session_id,
655
845
  "turn_number": turn_num,
656
846
  "transcript_path": str(transcript_path),
@@ -658,17 +848,9 @@ def emit_turn(
658
848
  "skills": skill_summary,
659
849
  },
660
850
  ) 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
851
  # LLM generation
670
852
  with langfuse.start_as_current_observation(
671
- name="Claude Response",
853
+ name="Agent Response",
672
854
  as_type="generation",
673
855
  model=model,
674
856
  input={"role": "user", "content": user_text},
@@ -678,7 +860,8 @@ def emit_turn(
678
860
  "assistant_text": assistant_text_meta,
679
861
  "tool_count": len(tool_calls),
680
862
  "usage_details": usage_details,
681
- "source": "claude",
863
+ "source": AGENT_NAME,
864
+ "agent": AGENT_NAME,
682
865
  "user_id": user_id or "",
683
866
  "session_id": session_id,
684
867
  "interaction_id": interaction_meta["interaction_id"],
@@ -687,24 +870,6 @@ def emit_turn(
687
870
  ):
688
871
  pass
689
872
 
690
- for skill in skill_usages:
691
- with langfuse.start_as_current_observation(
692
- name="Skill Use",
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_use_count": 1,
700
- "skill_namespace": skill["skill_namespace"],
701
- "detected_by": skill["detected_by"],
702
- "turn_number": turn_num,
703
- "metrics_schema_version": METRICS_SCHEMA_VERSION,
704
- },
705
- ):
706
- pass
707
-
708
873
  # Tool observations
709
874
  for tc in tool_calls:
710
875
  in_obj = tc["input"]
@@ -715,11 +880,12 @@ def emit_turn(
715
880
  in_meta = None
716
881
 
717
882
  with langfuse.start_as_current_observation(
718
- name=f"Tool: {tc['name']}",
883
+ name="Tool Call",
719
884
  as_type="tool",
720
885
  input=in_obj,
721
886
  metadata={
722
- "source": "claude",
887
+ "source": AGENT_NAME,
888
+ "agent": AGENT_NAME,
723
889
  "user_id": user_id or "",
724
890
  "session_id": session_id,
725
891
  "interaction_id": interaction_meta["interaction_id"],
package/package.json CHANGED
@@ -1,47 +1,47 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
7
- "engines": {
8
- "node": ">=16"
9
- },
10
- "bin": {
6
+ "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
7
+ "engines": {
8
+ "node": ">=16"
9
+ },
10
+ "bin": {
11
11
  "oh-langfuse": "bin/cli.js",
12
12
  "code-tool-langfuse": "bin/cli.js"
13
13
  },
14
14
  "files": [
15
- "bin",
16
- "scripts/auto-update-runtime.mjs",
17
- "scripts/codex-langfuse-check.mjs",
18
- "scripts/codex-langfuse-setup.mjs",
19
- "scripts/json-utils.mjs",
20
- "scripts/langfuse-check.mjs",
21
- "scripts/langfuse-setup.mjs",
22
- "scripts/opencode-langfuse-check.mjs",
23
- "scripts/opencode-langfuse-run.mjs",
24
- "scripts/opencode-langfuse-setup.mjs",
25
- "scripts/resolve-opencode-cli.mjs",
26
- "scripts/real-self-verify.mjs",
27
- "scripts/log-filter-utils.mjs",
28
- "scripts/metrics-utils.mjs",
29
- "scripts/runtime-state-utils.mjs",
30
- "scripts/update-langfuse-runtime.mjs",
31
- "scripts/update-utils.mjs",
15
+ "bin",
16
+ "scripts/auto-update-runtime.mjs",
17
+ "scripts/codex-langfuse-check.mjs",
18
+ "scripts/codex-langfuse-setup.mjs",
19
+ "scripts/json-utils.mjs",
20
+ "scripts/langfuse-check.mjs",
21
+ "scripts/langfuse-setup.mjs",
22
+ "scripts/opencode-langfuse-check.mjs",
23
+ "scripts/opencode-langfuse-run.mjs",
24
+ "scripts/opencode-langfuse-setup.mjs",
25
+ "scripts/resolve-opencode-cli.mjs",
26
+ "scripts/real-self-verify.mjs",
27
+ "scripts/log-filter-utils.mjs",
28
+ "scripts/metrics-utils.mjs",
29
+ "scripts/runtime-state-utils.mjs",
30
+ "scripts/update-langfuse-runtime.mjs",
31
+ "scripts/update-utils.mjs",
32
32
  "langfuse_hook.py",
33
- "codex_langfuse_notify.py",
33
+ "codex_langfuse_notify.py",
34
34
  "README.md",
35
35
  "SELF_VERIFY.md",
36
- "CODEX_LANGFUSE_PLAN.md",
36
+ "CODEX_LANGFUSE_PLAN.md",
37
37
  "setup-langfuse.bat",
38
38
  "setup-langfuse.sh"
39
39
  ],
40
- "scripts": {
41
- "start": "node bin/cli.js",
42
- "check": "node --check bin/cli.js",
43
- "test": "node --test tests/*.test.mjs",
44
- "pack:check": "npm pack --dry-run",
40
+ "scripts": {
41
+ "start": "node bin/cli.js",
42
+ "check": "node --check bin/cli.js",
43
+ "test": "node --test tests/*.test.mjs",
44
+ "pack:check": "npm pack --dry-run",
45
45
  "claude:setup": "node scripts/langfuse-setup.mjs",
46
46
  "claude:check": "node scripts/langfuse-check.mjs",
47
47
  "langfuse:setup": "node scripts/langfuse-setup.mjs",
@@ -54,10 +54,10 @@
54
54
  "opencode:langfuse:run": "node scripts/opencode-langfuse-run.mjs",
55
55
  "codex:setup": "node scripts/codex-langfuse-setup.mjs",
56
56
  "codex:check": "node scripts/codex-langfuse-check.mjs",
57
- "codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
58
- "codex:langfuse:check": "node scripts/codex-langfuse-check.mjs",
59
- "update": "node scripts/update-langfuse-runtime.mjs",
60
- "self:verify": "node scripts/real-self-verify.mjs"
61
- },
57
+ "codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
58
+ "codex:langfuse:check": "node scripts/codex-langfuse-check.mjs",
59
+ "update": "node scripts/update-langfuse-runtime.mjs",
60
+ "self:verify": "node scripts/real-self-verify.mjs"
61
+ },
62
62
  "dependencies": {}
63
63
  }
@@ -112,8 +112,10 @@ async function main() {
112
112
  }
113
113
 
114
114
  main()
115
- .then((code) => process.exit(code))
115
+ .then((code) => {
116
+ process.exitCode = code;
117
+ })
116
118
  .catch((error) => {
117
119
  console.error(`[WARN] oh-langfuse auto-update skipped: ${error?.message || String(error)}`);
118
- process.exit(0);
120
+ process.exitCode = 0;
119
121
  });