oh-langfuse 0.1.7
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/CODEX_LANGFUSE_PLAN.md +62 -0
- package/README.md +153 -0
- package/bin/cli.js +728 -0
- package/codex_langfuse_notify.py +591 -0
- package/langfuse_hook.py +603 -0
- package/package.json +41 -0
- package/scripts/codex-langfuse-check.mjs +101 -0
- package/scripts/codex-langfuse-setup.mjs +177 -0
- package/scripts/langfuse-check.mjs +90 -0
- package/scripts/langfuse-setup.mjs +274 -0
- package/scripts/opencode-langfuse-check.mjs +94 -0
- package/scripts/opencode-langfuse-run.mjs +96 -0
- package/scripts/opencode-langfuse-setup.mjs +478 -0
- package/scripts/resolve-opencode-cli.mjs +58 -0
- package/setup-langfuse.bat +163 -0
- package/setup-langfuse.sh +130 -0
package/langfuse_hook.py
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code -> Langfuse hook
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import hashlib
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
# --- Langfuse import (fail-open) ---
|
|
18
|
+
try:
|
|
19
|
+
from langfuse import Langfuse, propagate_attributes
|
|
20
|
+
except Exception:
|
|
21
|
+
sys.exit(0)
|
|
22
|
+
|
|
23
|
+
# --- Paths ---
|
|
24
|
+
STATE_DIR = Path.home() / ".claude" / "state"
|
|
25
|
+
LOG_FILE = STATE_DIR / "langfuse_hook.log"
|
|
26
|
+
STATE_FILE = STATE_DIR / "langfuse_state.json"
|
|
27
|
+
LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
28
|
+
|
|
29
|
+
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
30
|
+
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
31
|
+
|
|
32
|
+
# ----------------- Logging -----------------
|
|
33
|
+
def _log(level: str, message: str) -> None:
|
|
34
|
+
try:
|
|
35
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
37
|
+
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
38
|
+
f.write(f"{ts} [{level}] {message}\n")
|
|
39
|
+
except Exception:
|
|
40
|
+
# Never block
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def debug(msg: str) -> None:
|
|
44
|
+
if DEBUG:
|
|
45
|
+
_log("DEBUG", msg)
|
|
46
|
+
|
|
47
|
+
def info(msg: str) -> None:
|
|
48
|
+
_log("INFO", msg)
|
|
49
|
+
|
|
50
|
+
def warn(msg: str) -> None:
|
|
51
|
+
_log("WARN", msg)
|
|
52
|
+
|
|
53
|
+
def error(msg: str) -> None:
|
|
54
|
+
_log("ERROR", msg)
|
|
55
|
+
|
|
56
|
+
# ----------------- State locking (best-effort) -----------------
|
|
57
|
+
class FileLock:
|
|
58
|
+
def __init__(self, path: Path, timeout_s: float = 2.0):
|
|
59
|
+
self.path = path
|
|
60
|
+
self.timeout_s = timeout_s
|
|
61
|
+
self._fh = None
|
|
62
|
+
|
|
63
|
+
def __enter__(self):
|
|
64
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self._fh = open(self.path, "a+", encoding="utf-8")
|
|
66
|
+
try:
|
|
67
|
+
import fcntl # Unix only
|
|
68
|
+
deadline = time.time() + self.timeout_s
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
72
|
+
break
|
|
73
|
+
except BlockingIOError:
|
|
74
|
+
if time.time() > deadline:
|
|
75
|
+
break
|
|
76
|
+
time.sleep(0.05)
|
|
77
|
+
except Exception:
|
|
78
|
+
# If locking isn't available, proceed without it.
|
|
79
|
+
pass
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(self, exc_type, exc, tb):
|
|
83
|
+
try:
|
|
84
|
+
import fcntl
|
|
85
|
+
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
try:
|
|
89
|
+
self._fh.close()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def load_state() -> Dict[str, Any]:
|
|
94
|
+
try:
|
|
95
|
+
if not STATE_FILE.exists():
|
|
96
|
+
return {}
|
|
97
|
+
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
98
|
+
except Exception:
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
def save_state(state: Dict[str, Any]) -> None:
|
|
102
|
+
try:
|
|
103
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
105
|
+
tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
|
106
|
+
os.replace(tmp, STATE_FILE)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
debug(f"save_state failed: {e}")
|
|
109
|
+
|
|
110
|
+
def state_key(session_id: str, transcript_path: str) -> str:
|
|
111
|
+
# stable key even if session_id collides
|
|
112
|
+
raw = f"{session_id}::{transcript_path}"
|
|
113
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
114
|
+
|
|
115
|
+
# ----------------- Hook payload -----------------
|
|
116
|
+
def read_hook_payload() -> Dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Claude Code hooks pass a JSON payload on stdin.
|
|
119
|
+
This script tolerates missing/empty stdin by returning {}.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
data = sys.stdin.read()
|
|
123
|
+
if not data.strip():
|
|
124
|
+
return {}
|
|
125
|
+
return json.loads(data)
|
|
126
|
+
except Exception:
|
|
127
|
+
return {}
|
|
128
|
+
|
|
129
|
+
def extract_session_transcript_and_user(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path], Optional[str]]:
|
|
130
|
+
"""
|
|
131
|
+
Tries a few plausible field names; exact keys can vary across hook types/versions.
|
|
132
|
+
Prefer structured values from stdin over heuristics.
|
|
133
|
+
"""
|
|
134
|
+
session_id = (
|
|
135
|
+
payload.get("sessionId")
|
|
136
|
+
or payload.get("session_id")
|
|
137
|
+
or payload.get("session", {}).get("id")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
transcript = (
|
|
141
|
+
payload.get("transcriptPath")
|
|
142
|
+
or payload.get("transcript_path")
|
|
143
|
+
or payload.get("transcript", {}).get("path")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if transcript:
|
|
147
|
+
try:
|
|
148
|
+
transcript_path = Path(transcript).expanduser().resolve()
|
|
149
|
+
except Exception:
|
|
150
|
+
transcript_path = None
|
|
151
|
+
else:
|
|
152
|
+
transcript_path = None
|
|
153
|
+
|
|
154
|
+
user_id = (
|
|
155
|
+
payload.get("userId")
|
|
156
|
+
or payload.get("user_id")
|
|
157
|
+
or payload.get("user", {}).get("id")
|
|
158
|
+
or payload.get("username")
|
|
159
|
+
or payload.get("userName")
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not user_id:
|
|
163
|
+
# Best-effort fallback on local username for CLI sessions.
|
|
164
|
+
user_id = (
|
|
165
|
+
os.environ.get("CC_LANGFUSE_USER_ID")
|
|
166
|
+
or os.environ.get("CLAUDE_USER_ID")
|
|
167
|
+
or os.environ.get("CC_USER_ID")
|
|
168
|
+
or os.environ.get("LANGFUSE_USER_ID")
|
|
169
|
+
or os.environ.get("USERNAME")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return session_id, transcript_path, user_id
|
|
173
|
+
|
|
174
|
+
# ----------------- Transcript parsing helpers -----------------
|
|
175
|
+
def get_content(msg: Dict[str, Any]) -> Any:
|
|
176
|
+
if not isinstance(msg, dict):
|
|
177
|
+
return None
|
|
178
|
+
if "message" in msg and isinstance(msg.get("message"), dict):
|
|
179
|
+
return msg["message"].get("content")
|
|
180
|
+
return msg.get("content")
|
|
181
|
+
|
|
182
|
+
def get_role(msg: Dict[str, Any]) -> Optional[str]:
|
|
183
|
+
# Claude Code transcript lines commonly have type=user/assistant OR message.role
|
|
184
|
+
t = msg.get("type")
|
|
185
|
+
if t in ("user", "assistant"):
|
|
186
|
+
return t
|
|
187
|
+
m = msg.get("message")
|
|
188
|
+
if isinstance(m, dict):
|
|
189
|
+
r = m.get("role")
|
|
190
|
+
if r in ("user", "assistant"):
|
|
191
|
+
return r
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def is_tool_result(msg: Dict[str, Any]) -> bool:
|
|
195
|
+
role = get_role(msg)
|
|
196
|
+
if role != "user":
|
|
197
|
+
return False
|
|
198
|
+
content = get_content(msg)
|
|
199
|
+
if isinstance(content, list):
|
|
200
|
+
return any(isinstance(x, dict) and x.get("type") == "tool_result" for x in content)
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def iter_tool_results(content: Any) -> List[Dict[str, Any]]:
|
|
204
|
+
out: List[Dict[str, Any]] = []
|
|
205
|
+
if isinstance(content, list):
|
|
206
|
+
for x in content:
|
|
207
|
+
if isinstance(x, dict) and x.get("type") == "tool_result":
|
|
208
|
+
out.append(x)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
def iter_tool_uses(content: Any) -> List[Dict[str, Any]]:
|
|
212
|
+
out: List[Dict[str, Any]] = []
|
|
213
|
+
if isinstance(content, list):
|
|
214
|
+
for x in content:
|
|
215
|
+
if isinstance(x, dict) and x.get("type") == "tool_use":
|
|
216
|
+
out.append(x)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def extract_text(content: Any) -> str:
|
|
220
|
+
if isinstance(content, str):
|
|
221
|
+
return content
|
|
222
|
+
if isinstance(content, list):
|
|
223
|
+
parts: List[str] = []
|
|
224
|
+
for x in content:
|
|
225
|
+
if isinstance(x, dict) and x.get("type") == "text":
|
|
226
|
+
parts.append(x.get("text", ""))
|
|
227
|
+
elif isinstance(x, str):
|
|
228
|
+
parts.append(x)
|
|
229
|
+
return "\n".join([p for p in parts if p])
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
|
|
233
|
+
if s is None:
|
|
234
|
+
return "", {"truncated": False, "orig_len": 0}
|
|
235
|
+
orig_len = len(s)
|
|
236
|
+
if orig_len <= max_chars:
|
|
237
|
+
return s, {"truncated": False, "orig_len": orig_len}
|
|
238
|
+
head = s[:max_chars]
|
|
239
|
+
return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
|
|
240
|
+
|
|
241
|
+
def get_model(msg: Dict[str, Any]) -> str:
|
|
242
|
+
m = msg.get("message")
|
|
243
|
+
if isinstance(m, dict):
|
|
244
|
+
return m.get("model") or "claude"
|
|
245
|
+
return "claude"
|
|
246
|
+
|
|
247
|
+
def get_usage(msg: Dict[str, Any]) -> Dict[str, int]:
|
|
248
|
+
"""
|
|
249
|
+
Extract token usage from Claude transcript message shape:
|
|
250
|
+
msg.message.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}
|
|
251
|
+
"""
|
|
252
|
+
m = msg.get("message")
|
|
253
|
+
usage = m.get("usage") if isinstance(m, dict) else None
|
|
254
|
+
if not isinstance(usage, dict):
|
|
255
|
+
return {}
|
|
256
|
+
|
|
257
|
+
out: Dict[str, int] = {}
|
|
258
|
+
field_map = {
|
|
259
|
+
"input_tokens": "input",
|
|
260
|
+
"output_tokens": "output",
|
|
261
|
+
"cache_read_input_tokens": "cache_read_input_tokens",
|
|
262
|
+
"cache_creation_input_tokens": "cache_creation_input_tokens",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for src_key, dst_key in field_map.items():
|
|
266
|
+
val = usage.get(src_key)
|
|
267
|
+
if isinstance(val, int) and val >= 0:
|
|
268
|
+
out[dst_key] = val
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
def get_message_id(msg: Dict[str, Any]) -> Optional[str]:
|
|
272
|
+
m = msg.get("message")
|
|
273
|
+
if isinstance(m, dict):
|
|
274
|
+
mid = m.get("id")
|
|
275
|
+
if isinstance(mid, str) and mid:
|
|
276
|
+
return mid
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
# ----------------- Incremental reader -----------------
|
|
280
|
+
@dataclass
|
|
281
|
+
class SessionState:
|
|
282
|
+
offset: int = 0
|
|
283
|
+
buffer: str = ""
|
|
284
|
+
turn_count: int = 0
|
|
285
|
+
|
|
286
|
+
def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState:
|
|
287
|
+
s = global_state.get(key, {})
|
|
288
|
+
return SessionState(
|
|
289
|
+
offset=int(s.get("offset", 0)),
|
|
290
|
+
buffer=str(s.get("buffer", "")),
|
|
291
|
+
turn_count=int(s.get("turn_count", 0)),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None:
|
|
295
|
+
global_state[key] = {
|
|
296
|
+
"offset": ss.offset,
|
|
297
|
+
"buffer": ss.buffer,
|
|
298
|
+
"turn_count": ss.turn_count,
|
|
299
|
+
"updated": datetime.now(timezone.utc).isoformat(),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
|
|
303
|
+
"""
|
|
304
|
+
Reads only new bytes since ss.offset. Keeps ss.buffer for partial last line.
|
|
305
|
+
Returns parsed JSON lines (best-effort) and updated state.
|
|
306
|
+
"""
|
|
307
|
+
if not transcript_path.exists():
|
|
308
|
+
return [], ss
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
with open(transcript_path, "rb") as f:
|
|
312
|
+
f.seek(ss.offset)
|
|
313
|
+
chunk = f.read()
|
|
314
|
+
new_offset = f.tell()
|
|
315
|
+
except Exception as e:
|
|
316
|
+
debug(f"read_new_jsonl failed: {e}")
|
|
317
|
+
return [], ss
|
|
318
|
+
|
|
319
|
+
if not chunk:
|
|
320
|
+
return [], ss
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
324
|
+
except Exception:
|
|
325
|
+
text = chunk.decode(errors="replace")
|
|
326
|
+
|
|
327
|
+
combined = ss.buffer + text
|
|
328
|
+
lines = combined.split("\n")
|
|
329
|
+
# last element may be incomplete
|
|
330
|
+
ss.buffer = lines[-1]
|
|
331
|
+
ss.offset = new_offset
|
|
332
|
+
|
|
333
|
+
msgs: List[Dict[str, Any]] = []
|
|
334
|
+
for line in lines[:-1]:
|
|
335
|
+
line = line.strip()
|
|
336
|
+
if not line:
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
msgs.append(json.loads(line))
|
|
340
|
+
except Exception:
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
return msgs, ss
|
|
344
|
+
|
|
345
|
+
# ----------------- Turn assembly -----------------
|
|
346
|
+
@dataclass
|
|
347
|
+
class Turn:
|
|
348
|
+
user_msg: Dict[str, Any]
|
|
349
|
+
assistant_msgs: List[Dict[str, Any]]
|
|
350
|
+
tool_results_by_id: Dict[str, Any]
|
|
351
|
+
|
|
352
|
+
def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
|
|
353
|
+
"""
|
|
354
|
+
Groups incremental transcript rows into turns:
|
|
355
|
+
user (non-tool-result) -> assistant messages -> (tool_result rows, possibly interleaved)
|
|
356
|
+
Uses:
|
|
357
|
+
- assistant message dedupe by message.id (latest row wins)
|
|
358
|
+
- tool results dedupe by tool_use_id (latest wins)
|
|
359
|
+
"""
|
|
360
|
+
turns: List[Turn] = []
|
|
361
|
+
current_user: Optional[Dict[str, Any]] = None
|
|
362
|
+
|
|
363
|
+
# assistant messages for current turn:
|
|
364
|
+
assistant_order: List[str] = [] # message ids in order of first appearance (or synthetic)
|
|
365
|
+
assistant_latest: Dict[str, Dict[str, Any]] = {} # id -> latest msg
|
|
366
|
+
|
|
367
|
+
tool_results_by_id: Dict[str, Any] = {} # tool_use_id -> content
|
|
368
|
+
|
|
369
|
+
def flush_turn():
|
|
370
|
+
nonlocal current_user, assistant_order, assistant_latest, tool_results_by_id, turns
|
|
371
|
+
if current_user is None:
|
|
372
|
+
return
|
|
373
|
+
if not assistant_latest:
|
|
374
|
+
return
|
|
375
|
+
assistants = [assistant_latest[mid] for mid in assistant_order if mid in assistant_latest]
|
|
376
|
+
turns.append(Turn(user_msg=current_user, assistant_msgs=assistants, tool_results_by_id=dict(tool_results_by_id)))
|
|
377
|
+
|
|
378
|
+
for msg in messages:
|
|
379
|
+
role = get_role(msg)
|
|
380
|
+
|
|
381
|
+
# tool_result rows show up as role=user with content blocks of type tool_result
|
|
382
|
+
if is_tool_result(msg):
|
|
383
|
+
for tr in iter_tool_results(get_content(msg)):
|
|
384
|
+
tid = tr.get("tool_use_id")
|
|
385
|
+
if tid:
|
|
386
|
+
tool_results_by_id[str(tid)] = tr.get("content")
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
if role == "user":
|
|
390
|
+
# new user message -> finalize previous turn
|
|
391
|
+
flush_turn()
|
|
392
|
+
|
|
393
|
+
# start a new turn
|
|
394
|
+
current_user = msg
|
|
395
|
+
assistant_order = []
|
|
396
|
+
assistant_latest = {}
|
|
397
|
+
tool_results_by_id = {}
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
if role == "assistant":
|
|
401
|
+
if current_user is None:
|
|
402
|
+
# ignore assistant rows until we see a user message
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
mid = get_message_id(msg) or f"noid:{len(assistant_order)}"
|
|
406
|
+
if mid not in assistant_latest:
|
|
407
|
+
assistant_order.append(mid)
|
|
408
|
+
assistant_latest[mid] = msg
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# ignore unknown rows
|
|
412
|
+
|
|
413
|
+
# flush last
|
|
414
|
+
flush_turn()
|
|
415
|
+
return turns
|
|
416
|
+
|
|
417
|
+
# ----------------- Langfuse emit -----------------
|
|
418
|
+
def _tool_calls_from_assistants(assistant_msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
419
|
+
calls: List[Dict[str, Any]] = []
|
|
420
|
+
for am in assistant_msgs:
|
|
421
|
+
for tu in iter_tool_uses(get_content(am)):
|
|
422
|
+
tid = tu.get("id") or ""
|
|
423
|
+
calls.append({
|
|
424
|
+
"id": str(tid),
|
|
425
|
+
"name": tu.get("name") or "unknown",
|
|
426
|
+
"input": tu.get("input") if isinstance(tu.get("input"), (dict, list, str, int, float, bool)) else {},
|
|
427
|
+
})
|
|
428
|
+
return calls
|
|
429
|
+
|
|
430
|
+
def emit_turn(
|
|
431
|
+
langfuse: Langfuse,
|
|
432
|
+
session_id: str,
|
|
433
|
+
user_id: Optional[str],
|
|
434
|
+
turn_num: int,
|
|
435
|
+
turn: Turn,
|
|
436
|
+
transcript_path: Path,
|
|
437
|
+
) -> None:
|
|
438
|
+
user_text_raw = extract_text(get_content(turn.user_msg))
|
|
439
|
+
user_text, user_text_meta = truncate_text(user_text_raw)
|
|
440
|
+
|
|
441
|
+
last_assistant = turn.assistant_msgs[-1]
|
|
442
|
+
assistant_text_raw = extract_text(get_content(last_assistant))
|
|
443
|
+
assistant_text, assistant_text_meta = truncate_text(assistant_text_raw)
|
|
444
|
+
|
|
445
|
+
model = get_model(turn.assistant_msgs[0])
|
|
446
|
+
usage_details = get_usage(last_assistant)
|
|
447
|
+
|
|
448
|
+
tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
|
|
449
|
+
|
|
450
|
+
# attach tool outputs
|
|
451
|
+
for c in tool_calls:
|
|
452
|
+
if c["id"] and c["id"] in turn.tool_results_by_id:
|
|
453
|
+
out_raw = turn.tool_results_by_id[c["id"]]
|
|
454
|
+
out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False)
|
|
455
|
+
out_trunc, out_meta = truncate_text(out_str)
|
|
456
|
+
c["output"] = out_trunc
|
|
457
|
+
c["output_meta"] = out_meta
|
|
458
|
+
else:
|
|
459
|
+
c["output"] = None
|
|
460
|
+
|
|
461
|
+
with propagate_attributes(
|
|
462
|
+
user_id=user_id,
|
|
463
|
+
session_id=session_id,
|
|
464
|
+
trace_name=f"Claude Code - Turn {turn_num}",
|
|
465
|
+
tags=["claude-code"],
|
|
466
|
+
):
|
|
467
|
+
with langfuse.start_as_current_observation(
|
|
468
|
+
name=f"Claude Code - Turn {turn_num}",
|
|
469
|
+
input={"role": "user", "content": user_text},
|
|
470
|
+
metadata={
|
|
471
|
+
"source": "claude-code",
|
|
472
|
+
"session_id": session_id,
|
|
473
|
+
"turn_number": turn_num,
|
|
474
|
+
"transcript_path": str(transcript_path),
|
|
475
|
+
"user_text": user_text_meta,
|
|
476
|
+
},
|
|
477
|
+
) as trace_span:
|
|
478
|
+
# LLM generation
|
|
479
|
+
with langfuse.start_as_current_observation(
|
|
480
|
+
name="Claude Response",
|
|
481
|
+
as_type="generation",
|
|
482
|
+
model=model,
|
|
483
|
+
input={"role": "user", "content": user_text},
|
|
484
|
+
output={"role": "assistant", "content": assistant_text},
|
|
485
|
+
usage_details=usage_details or None,
|
|
486
|
+
metadata={
|
|
487
|
+
"assistant_text": assistant_text_meta,
|
|
488
|
+
"tool_count": len(tool_calls),
|
|
489
|
+
"usage_details": usage_details,
|
|
490
|
+
},
|
|
491
|
+
):
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
# Tool observations
|
|
495
|
+
for tc in tool_calls:
|
|
496
|
+
in_obj = tc["input"]
|
|
497
|
+
# truncate tool input if it's a large string payload
|
|
498
|
+
if isinstance(in_obj, str):
|
|
499
|
+
in_obj, in_meta = truncate_text(in_obj)
|
|
500
|
+
else:
|
|
501
|
+
in_meta = None
|
|
502
|
+
|
|
503
|
+
with langfuse.start_as_current_observation(
|
|
504
|
+
name=f"Tool: {tc['name']}",
|
|
505
|
+
as_type="tool",
|
|
506
|
+
input=in_obj,
|
|
507
|
+
metadata={
|
|
508
|
+
"tool_name": tc["name"],
|
|
509
|
+
"tool_id": tc["id"],
|
|
510
|
+
"input_meta": in_meta,
|
|
511
|
+
"output_meta": tc.get("output_meta"),
|
|
512
|
+
},
|
|
513
|
+
) as tool_obs:
|
|
514
|
+
tool_obs.update(output=tc.get("output"))
|
|
515
|
+
|
|
516
|
+
trace_span.update(output={"role": "assistant", "content": assistant_text})
|
|
517
|
+
|
|
518
|
+
# ----------------- Main -----------------
|
|
519
|
+
def main() -> int:
|
|
520
|
+
start = time.time()
|
|
521
|
+
debug("Hook started")
|
|
522
|
+
|
|
523
|
+
if os.environ.get("TRACE_TO_LANGFUSE", "").lower() != "true":
|
|
524
|
+
return 0
|
|
525
|
+
|
|
526
|
+
public_key = os.environ.get("CC_LANGFUSE_PUBLIC_KEY") or os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
527
|
+
secret_key = os.environ.get("CC_LANGFUSE_SECRET_KEY") or os.environ.get("LANGFUSE_SECRET_KEY")
|
|
528
|
+
host = os.environ.get("CC_LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_BASEURL") or "https://cloud.langfuse.com"
|
|
529
|
+
|
|
530
|
+
if not public_key or not secret_key:
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
payload = read_hook_payload()
|
|
534
|
+
session_id, transcript_path, user_id = extract_session_transcript_and_user(payload)
|
|
535
|
+
|
|
536
|
+
if not session_id or not transcript_path:
|
|
537
|
+
# No structured payload; fail open (do not guess)
|
|
538
|
+
debug("Missing session_id or transcript_path from hook payload; exiting.")
|
|
539
|
+
return 0
|
|
540
|
+
|
|
541
|
+
if not transcript_path.exists():
|
|
542
|
+
debug(f"Transcript path does not exist: {transcript_path}")
|
|
543
|
+
return 0
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
|
|
547
|
+
except Exception:
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
with FileLock(LOCK_FILE):
|
|
552
|
+
state = load_state()
|
|
553
|
+
key = state_key(session_id, str(transcript_path))
|
|
554
|
+
ss = load_session_state(state, key)
|
|
555
|
+
|
|
556
|
+
msgs, ss = read_new_jsonl(transcript_path, ss)
|
|
557
|
+
if not msgs:
|
|
558
|
+
write_session_state(state, key, ss)
|
|
559
|
+
save_state(state)
|
|
560
|
+
return 0
|
|
561
|
+
|
|
562
|
+
turns = build_turns(msgs)
|
|
563
|
+
if not turns:
|
|
564
|
+
write_session_state(state, key, ss)
|
|
565
|
+
save_state(state)
|
|
566
|
+
return 0
|
|
567
|
+
|
|
568
|
+
# emit turns
|
|
569
|
+
emitted = 0
|
|
570
|
+
for t in turns:
|
|
571
|
+
emitted += 1
|
|
572
|
+
turn_num = ss.turn_count + emitted
|
|
573
|
+
try:
|
|
574
|
+
emit_turn(langfuse, session_id, user_id, turn_num, t, transcript_path)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
debug(f"emit_turn failed: {e}")
|
|
577
|
+
# continue emitting other turns
|
|
578
|
+
|
|
579
|
+
ss.turn_count += emitted
|
|
580
|
+
write_session_state(state, key, ss)
|
|
581
|
+
save_state(state)
|
|
582
|
+
|
|
583
|
+
try:
|
|
584
|
+
langfuse.flush()
|
|
585
|
+
except Exception:
|
|
586
|
+
pass
|
|
587
|
+
|
|
588
|
+
dur = time.time() - start
|
|
589
|
+
info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})")
|
|
590
|
+
return 0
|
|
591
|
+
|
|
592
|
+
except Exception as e:
|
|
593
|
+
debug(f"Unexpected failure: {e}")
|
|
594
|
+
return 0
|
|
595
|
+
|
|
596
|
+
finally:
|
|
597
|
+
try:
|
|
598
|
+
langfuse.shutdown()
|
|
599
|
+
except Exception:
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
if __name__ == "__main__":
|
|
603
|
+
sys.exit(main())
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-langfuse",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"oh-langfuse": "bin/cli.js",
|
|
9
|
+
"code-tool-langfuse": "bin/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"scripts",
|
|
14
|
+
"langfuse_hook.py",
|
|
15
|
+
"codex_langfuse_notify.py",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CODEX_LANGFUSE_PLAN.md",
|
|
18
|
+
"setup-langfuse.bat",
|
|
19
|
+
"setup-langfuse.sh"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "node bin/cli.js",
|
|
23
|
+
"check": "node --check bin/cli.js",
|
|
24
|
+
"pack:check": "npm pack --dry-run",
|
|
25
|
+
"claude:setup": "node scripts/langfuse-setup.mjs",
|
|
26
|
+
"claude:check": "node scripts/langfuse-check.mjs",
|
|
27
|
+
"langfuse:setup": "node scripts/langfuse-setup.mjs",
|
|
28
|
+
"langfuse:check": "node scripts/langfuse-check.mjs",
|
|
29
|
+
"opencode:run": "node scripts/opencode-langfuse-run.mjs",
|
|
30
|
+
"opencode:setup": "node scripts/opencode-langfuse-setup.mjs",
|
|
31
|
+
"opencode:check": "node scripts/opencode-langfuse-check.mjs",
|
|
32
|
+
"opencode:langfuse:setup": "node scripts/opencode-langfuse-setup.mjs",
|
|
33
|
+
"opencode:langfuse:check": "node scripts/opencode-langfuse-check.mjs",
|
|
34
|
+
"opencode:langfuse:run": "node scripts/opencode-langfuse-run.mjs",
|
|
35
|
+
"codex:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
36
|
+
"codex:check": "node scripts/codex-langfuse-check.mjs",
|
|
37
|
+
"codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
38
|
+
"codex:langfuse:check": "node scripts/codex-langfuse-check.mjs"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {}
|
|
41
|
+
}
|