oh-langfuse 0.1.63 → 0.1.65
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 +15 -0
- package/codex_langfuse_notify.py +159 -1
- package/langfuse_hook.py +216 -7
- package/package.json +1 -1
- package/scripts/opencode-langfuse-setup.mjs +146 -8
- package/scripts/real-self-verify.mjs +94 -74
package/README.md
CHANGED
|
@@ -142,6 +142,21 @@ metadata.skill_invocation_modes
|
|
|
142
142
|
metadata.skill_agent_paths
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
Repository context is also attached to each Agent Turn when the runtime can identify a current working directory. The hook performs only lightweight read-only git probes and stays silent on timeout or failure.
|
|
146
|
+
|
|
147
|
+
Common repository fields:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
metadata.cwd
|
|
151
|
+
metadata.cwd_source
|
|
152
|
+
metadata.git_context_available
|
|
153
|
+
metadata.is_git_repo
|
|
154
|
+
metadata.git_remote
|
|
155
|
+
metadata.git_repo_slug
|
|
156
|
+
metadata.git_branch
|
|
157
|
+
metadata.git_commit
|
|
158
|
+
```
|
|
159
|
+
|
|
145
160
|
Dashboard 统计建议:
|
|
146
161
|
|
|
147
162
|
```text
|
package/codex_langfuse_notify.py
CHANGED
|
@@ -10,6 +10,7 @@ emit the new assistant/user/tool events to Langfuse.
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
|
+
import subprocess
|
|
13
14
|
import sys
|
|
14
15
|
import time
|
|
15
16
|
import hashlib
|
|
@@ -87,6 +88,9 @@ MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
|
|
|
87
88
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
88
89
|
AGENT_NAME = "codex"
|
|
89
90
|
AGENT_TURN_NAME = "Codex Agent Turn"
|
|
91
|
+
REPO_CONTEXT_TTL_S = 30.0
|
|
92
|
+
REPO_CONTEXT_GIT_TIMEOUT_S = 0.5
|
|
93
|
+
_REPO_CONTEXT_CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
90
94
|
|
|
91
95
|
|
|
92
96
|
def log(level: str, message: str) -> None:
|
|
@@ -104,6 +108,158 @@ def debug(message: str) -> None:
|
|
|
104
108
|
log("DEBUG", message)
|
|
105
109
|
|
|
106
110
|
|
|
111
|
+
def empty_repo_context(
|
|
112
|
+
cwd: Optional[str] = None,
|
|
113
|
+
cwd_source: str = "unavailable",
|
|
114
|
+
available: bool = False,
|
|
115
|
+
error: str = "",
|
|
116
|
+
is_git_repo: bool = False,
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"cwd": str(cwd or ""),
|
|
120
|
+
"cwd_source": cwd_source or "unavailable",
|
|
121
|
+
"git_context_available": bool(available),
|
|
122
|
+
"git_context_error": error or "",
|
|
123
|
+
"is_git_repo": bool(is_git_repo),
|
|
124
|
+
"git_root": "",
|
|
125
|
+
"git_remote": "",
|
|
126
|
+
"git_remote_host": "",
|
|
127
|
+
"git_repo_owner": "",
|
|
128
|
+
"git_repo_name": "",
|
|
129
|
+
"git_repo_slug": "",
|
|
130
|
+
"git_branch": "",
|
|
131
|
+
"git_commit": "",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sanitize_git_remote(remote: Optional[str]) -> Dict[str, str]:
|
|
136
|
+
empty = {
|
|
137
|
+
"git_remote": "",
|
|
138
|
+
"git_remote_host": "",
|
|
139
|
+
"git_repo_owner": "",
|
|
140
|
+
"git_repo_name": "",
|
|
141
|
+
"git_repo_slug": "",
|
|
142
|
+
}
|
|
143
|
+
raw = str(remote or "").strip()
|
|
144
|
+
if not raw:
|
|
145
|
+
return empty
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if "://" in raw:
|
|
149
|
+
parsed = urlparse(raw)
|
|
150
|
+
host = parsed.hostname or ""
|
|
151
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
152
|
+
else:
|
|
153
|
+
scp_like = re.match(r"^[^@]+@([^:]+):(.+)$", raw)
|
|
154
|
+
if scp_like:
|
|
155
|
+
host = scp_like.group(1)
|
|
156
|
+
parts = [part for part in scp_like.group(2).strip("/").split("/") if part]
|
|
157
|
+
else:
|
|
158
|
+
parsed = urlparse(f"ssh://{raw}")
|
|
159
|
+
host = parsed.hostname or ""
|
|
160
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
161
|
+
|
|
162
|
+
if len(parts) < 2 or not host:
|
|
163
|
+
return empty
|
|
164
|
+
owner = parts[-2]
|
|
165
|
+
repo = parts[-1]
|
|
166
|
+
if repo.endswith(".git"):
|
|
167
|
+
repo = repo[:-4]
|
|
168
|
+
if not owner or not repo:
|
|
169
|
+
return empty
|
|
170
|
+
slug = f"{owner}/{repo}"
|
|
171
|
+
return {
|
|
172
|
+
"git_remote": f"{host}/{slug}",
|
|
173
|
+
"git_remote_host": host,
|
|
174
|
+
"git_repo_owner": owner,
|
|
175
|
+
"git_repo_name": repo,
|
|
176
|
+
"git_repo_slug": slug,
|
|
177
|
+
}
|
|
178
|
+
except Exception:
|
|
179
|
+
return empty
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_git_command(cwd: str, args: List[str], timeout_s: float = REPO_CONTEXT_GIT_TIMEOUT_S) -> Tuple[bool, str, str]:
|
|
183
|
+
try:
|
|
184
|
+
result = subprocess.run(
|
|
185
|
+
["git", "-C", cwd, *args],
|
|
186
|
+
text=True,
|
|
187
|
+
capture_output=True,
|
|
188
|
+
timeout=timeout_s,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
return result.returncode == 0, (result.stdout or "").strip(), (result.stderr or "").strip()
|
|
192
|
+
except subprocess.TimeoutExpired:
|
|
193
|
+
return False, "", "timeout"
|
|
194
|
+
except FileNotFoundError:
|
|
195
|
+
return False, "", "git_unavailable"
|
|
196
|
+
except Exception:
|
|
197
|
+
return False, "", "git_error"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def collect_repo_context(cwd: Optional[str], cwd_source: str = "unavailable") -> Dict[str, Any]:
|
|
201
|
+
cwd_text = str(cwd or "").strip()
|
|
202
|
+
if not cwd_text:
|
|
203
|
+
return empty_repo_context(error="missing_cwd")
|
|
204
|
+
|
|
205
|
+
path = Path(cwd_text)
|
|
206
|
+
if not path.exists():
|
|
207
|
+
return empty_repo_context(cwd_text, cwd_source, available=False, error="missing_cwd")
|
|
208
|
+
|
|
209
|
+
cache_key = str(path.resolve())
|
|
210
|
+
cached = _REPO_CONTEXT_CACHE.get(cache_key)
|
|
211
|
+
now = time.time()
|
|
212
|
+
if cached and now - cached[0] <= REPO_CONTEXT_TTL_S:
|
|
213
|
+
context = dict(cached[1])
|
|
214
|
+
context["cwd_source"] = cwd_source or context.get("cwd_source") or "unavailable"
|
|
215
|
+
return context
|
|
216
|
+
|
|
217
|
+
ok, inside, error = run_git_command(cwd_text, ["rev-parse", "--is-inside-work-tree"])
|
|
218
|
+
if not ok:
|
|
219
|
+
reason = error if error in ("timeout", "git_unavailable") else ""
|
|
220
|
+
if reason:
|
|
221
|
+
context = empty_repo_context(cwd_text, cwd_source, available=False, error=reason)
|
|
222
|
+
else:
|
|
223
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
224
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
225
|
+
return context
|
|
226
|
+
if inside.lower() != "true":
|
|
227
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
228
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
229
|
+
return context
|
|
230
|
+
|
|
231
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=True)
|
|
232
|
+
|
|
233
|
+
ok_root, root, root_error = run_git_command(cwd_text, ["rev-parse", "--show-toplevel"])
|
|
234
|
+
if not ok_root and root_error in ("timeout", "git_unavailable"):
|
|
235
|
+
context["git_context_available"] = False
|
|
236
|
+
context["git_context_error"] = root_error
|
|
237
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
238
|
+
return context
|
|
239
|
+
context["git_root"] = root
|
|
240
|
+
|
|
241
|
+
ok_remote, remote, remote_error = run_git_command(cwd_text, ["config", "--get", "remote.origin.url"])
|
|
242
|
+
if ok_remote and remote:
|
|
243
|
+
remote_context = sanitize_git_remote(remote)
|
|
244
|
+
context.update(remote_context)
|
|
245
|
+
if not remote_context["git_remote"]:
|
|
246
|
+
context["git_context_error"] = "remote_parse_failed"
|
|
247
|
+
elif remote_error in ("timeout", "git_unavailable"):
|
|
248
|
+
context["git_context_available"] = False
|
|
249
|
+
context["git_context_error"] = remote_error
|
|
250
|
+
|
|
251
|
+
ok_branch, branch, _branch_error = run_git_command(cwd_text, ["branch", "--show-current"])
|
|
252
|
+
if ok_branch:
|
|
253
|
+
context["git_branch"] = branch
|
|
254
|
+
|
|
255
|
+
ok_commit, commit, _commit_error = run_git_command(cwd_text, ["rev-parse", "--short", "HEAD"])
|
|
256
|
+
if ok_commit:
|
|
257
|
+
context["git_commit"] = commit
|
|
258
|
+
|
|
259
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
260
|
+
return context
|
|
261
|
+
|
|
262
|
+
|
|
107
263
|
class FileLock:
|
|
108
264
|
def __init__(self, path: Path, timeout_s: float = 2.0):
|
|
109
265
|
self.path = path
|
|
@@ -1198,6 +1354,7 @@ def emit_codex_turn(
|
|
|
1198
1354
|
skill_use_events=skill_use_events,
|
|
1199
1355
|
)
|
|
1200
1356
|
skill_summary = summarize_skill_usages(skill_usages)
|
|
1357
|
+
repo_context = dict(meta.get("repo_context") or collect_repo_context(first_string(meta.get("cwd")), "session_meta"))
|
|
1201
1358
|
|
|
1202
1359
|
with propagate_attributes(
|
|
1203
1360
|
user_id=user_id,
|
|
@@ -1216,7 +1373,7 @@ def emit_codex_turn(
|
|
|
1216
1373
|
"session_id": session_id,
|
|
1217
1374
|
"turn_number": turn_num,
|
|
1218
1375
|
"session_path": str(session_path),
|
|
1219
|
-
|
|
1376
|
+
**repo_context,
|
|
1220
1377
|
"originator": meta.get("originator"),
|
|
1221
1378
|
"cli_version": meta.get("cli_version"),
|
|
1222
1379
|
"user_text": user_meta,
|
|
@@ -1374,6 +1531,7 @@ def main() -> int:
|
|
|
1374
1531
|
meta = get_session_meta(rows, session_path)
|
|
1375
1532
|
meta.update({k: v for k, v in parsed.session_meta.items() if v not in (None, "")})
|
|
1376
1533
|
meta.setdefault("session_path", str(session_path))
|
|
1534
|
+
meta["repo_context"] = collect_repo_context(first_string(meta.get("cwd")), "session_meta")
|
|
1377
1535
|
session_id = first_string(str(meta.get("id")) if meta.get("id") else "", session_path.stem) or session_path.stem
|
|
1378
1536
|
uploaded = 0
|
|
1379
1537
|
|
package/langfuse_hook.py
CHANGED
|
@@ -7,6 +7,7 @@ Claude Code -> Langfuse hook
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
import time
|
|
12
13
|
import hashlib
|
|
@@ -66,8 +67,11 @@ LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
|
66
67
|
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
67
68
|
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
68
69
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
69
|
-
AGENT_NAME = "claude"
|
|
70
|
-
AGENT_TURN_NAME = "Claude Agent Turn"
|
|
70
|
+
AGENT_NAME = "claude"
|
|
71
|
+
AGENT_TURN_NAME = "Claude Agent Turn"
|
|
72
|
+
REPO_CONTEXT_TTL_S = 30.0
|
|
73
|
+
REPO_CONTEXT_GIT_TIMEOUT_S = 0.5
|
|
74
|
+
_REPO_CONTEXT_CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
71
75
|
|
|
72
76
|
# ----------------- Logging -----------------
|
|
73
77
|
def _log(level: str, message: str) -> None:
|
|
@@ -93,6 +97,158 @@ def warn(msg: str) -> None:
|
|
|
93
97
|
def error(msg: str) -> None:
|
|
94
98
|
_log("ERROR", msg)
|
|
95
99
|
|
|
100
|
+
|
|
101
|
+
def empty_repo_context(
|
|
102
|
+
cwd: Optional[str] = None,
|
|
103
|
+
cwd_source: str = "unavailable",
|
|
104
|
+
available: bool = False,
|
|
105
|
+
error: str = "",
|
|
106
|
+
is_git_repo: bool = False,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"cwd": str(cwd or ""),
|
|
110
|
+
"cwd_source": cwd_source or "unavailable",
|
|
111
|
+
"git_context_available": bool(available),
|
|
112
|
+
"git_context_error": error or "",
|
|
113
|
+
"is_git_repo": bool(is_git_repo),
|
|
114
|
+
"git_root": "",
|
|
115
|
+
"git_remote": "",
|
|
116
|
+
"git_remote_host": "",
|
|
117
|
+
"git_repo_owner": "",
|
|
118
|
+
"git_repo_name": "",
|
|
119
|
+
"git_repo_slug": "",
|
|
120
|
+
"git_branch": "",
|
|
121
|
+
"git_commit": "",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sanitize_git_remote(remote: Optional[str]) -> Dict[str, str]:
|
|
126
|
+
empty = {
|
|
127
|
+
"git_remote": "",
|
|
128
|
+
"git_remote_host": "",
|
|
129
|
+
"git_repo_owner": "",
|
|
130
|
+
"git_repo_name": "",
|
|
131
|
+
"git_repo_slug": "",
|
|
132
|
+
}
|
|
133
|
+
raw = str(remote or "").strip()
|
|
134
|
+
if not raw:
|
|
135
|
+
return empty
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
if "://" in raw:
|
|
139
|
+
parsed = urlparse(raw)
|
|
140
|
+
host = parsed.hostname or ""
|
|
141
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
142
|
+
else:
|
|
143
|
+
scp_like = re.match(r"^[^@]+@([^:]+):(.+)$", raw)
|
|
144
|
+
if scp_like:
|
|
145
|
+
host = scp_like.group(1)
|
|
146
|
+
parts = [part for part in scp_like.group(2).strip("/").split("/") if part]
|
|
147
|
+
else:
|
|
148
|
+
parsed = urlparse(f"ssh://{raw}")
|
|
149
|
+
host = parsed.hostname or ""
|
|
150
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
151
|
+
|
|
152
|
+
if len(parts) < 2 or not host:
|
|
153
|
+
return empty
|
|
154
|
+
owner = parts[-2]
|
|
155
|
+
repo = parts[-1]
|
|
156
|
+
if repo.endswith(".git"):
|
|
157
|
+
repo = repo[:-4]
|
|
158
|
+
if not owner or not repo:
|
|
159
|
+
return empty
|
|
160
|
+
slug = f"{owner}/{repo}"
|
|
161
|
+
return {
|
|
162
|
+
"git_remote": f"{host}/{slug}",
|
|
163
|
+
"git_remote_host": host,
|
|
164
|
+
"git_repo_owner": owner,
|
|
165
|
+
"git_repo_name": repo,
|
|
166
|
+
"git_repo_slug": slug,
|
|
167
|
+
}
|
|
168
|
+
except Exception:
|
|
169
|
+
return empty
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_git_command(cwd: str, args: List[str], timeout_s: float = REPO_CONTEXT_GIT_TIMEOUT_S) -> Tuple[bool, str, str]:
|
|
173
|
+
try:
|
|
174
|
+
result = subprocess.run(
|
|
175
|
+
["git", "-C", cwd, *args],
|
|
176
|
+
text=True,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
timeout=timeout_s,
|
|
179
|
+
check=False,
|
|
180
|
+
)
|
|
181
|
+
return result.returncode == 0, (result.stdout or "").strip(), (result.stderr or "").strip()
|
|
182
|
+
except subprocess.TimeoutExpired:
|
|
183
|
+
return False, "", "timeout"
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return False, "", "git_unavailable"
|
|
186
|
+
except Exception:
|
|
187
|
+
return False, "", "git_error"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def collect_repo_context(cwd: Optional[str], cwd_source: str = "unavailable") -> Dict[str, Any]:
|
|
191
|
+
cwd_text = str(cwd or "").strip()
|
|
192
|
+
if not cwd_text:
|
|
193
|
+
return empty_repo_context(error="missing_cwd")
|
|
194
|
+
|
|
195
|
+
path = Path(cwd_text)
|
|
196
|
+
if not path.exists():
|
|
197
|
+
return empty_repo_context(cwd_text, cwd_source, available=False, error="missing_cwd")
|
|
198
|
+
|
|
199
|
+
cache_key = str(path.resolve())
|
|
200
|
+
cached = _REPO_CONTEXT_CACHE.get(cache_key)
|
|
201
|
+
now = time.time()
|
|
202
|
+
if cached and now - cached[0] <= REPO_CONTEXT_TTL_S:
|
|
203
|
+
context = dict(cached[1])
|
|
204
|
+
context["cwd_source"] = cwd_source or context.get("cwd_source") or "unavailable"
|
|
205
|
+
return context
|
|
206
|
+
|
|
207
|
+
ok, inside, error = run_git_command(cwd_text, ["rev-parse", "--is-inside-work-tree"])
|
|
208
|
+
if not ok:
|
|
209
|
+
reason = error if error in ("timeout", "git_unavailable") else ""
|
|
210
|
+
if reason:
|
|
211
|
+
context = empty_repo_context(cwd_text, cwd_source, available=False, error=reason)
|
|
212
|
+
else:
|
|
213
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
214
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
215
|
+
return context
|
|
216
|
+
if inside.lower() != "true":
|
|
217
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
218
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
219
|
+
return context
|
|
220
|
+
|
|
221
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=True)
|
|
222
|
+
|
|
223
|
+
ok_root, root, root_error = run_git_command(cwd_text, ["rev-parse", "--show-toplevel"])
|
|
224
|
+
if not ok_root and root_error in ("timeout", "git_unavailable"):
|
|
225
|
+
context["git_context_available"] = False
|
|
226
|
+
context["git_context_error"] = root_error
|
|
227
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
228
|
+
return context
|
|
229
|
+
context["git_root"] = root
|
|
230
|
+
|
|
231
|
+
ok_remote, remote, remote_error = run_git_command(cwd_text, ["config", "--get", "remote.origin.url"])
|
|
232
|
+
if ok_remote and remote:
|
|
233
|
+
remote_context = sanitize_git_remote(remote)
|
|
234
|
+
context.update(remote_context)
|
|
235
|
+
if not remote_context["git_remote"]:
|
|
236
|
+
context["git_context_error"] = "remote_parse_failed"
|
|
237
|
+
elif remote_error in ("timeout", "git_unavailable"):
|
|
238
|
+
context["git_context_available"] = False
|
|
239
|
+
context["git_context_error"] = remote_error
|
|
240
|
+
|
|
241
|
+
ok_branch, branch, _branch_error = run_git_command(cwd_text, ["branch", "--show-current"])
|
|
242
|
+
if ok_branch:
|
|
243
|
+
context["git_branch"] = branch
|
|
244
|
+
|
|
245
|
+
ok_commit, commit, _commit_error = run_git_command(cwd_text, ["rev-parse", "--short", "HEAD"])
|
|
246
|
+
if ok_commit:
|
|
247
|
+
context["git_commit"] = commit
|
|
248
|
+
|
|
249
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
250
|
+
return context
|
|
251
|
+
|
|
96
252
|
# ----------------- State locking (best-effort) -----------------
|
|
97
253
|
class FileLock:
|
|
98
254
|
def __init__(self, path: Path, timeout_s: float = 2.0):
|
|
@@ -166,6 +322,53 @@ def read_hook_payload() -> Dict[str, Any]:
|
|
|
166
322
|
except Exception:
|
|
167
323
|
return {}
|
|
168
324
|
|
|
325
|
+
|
|
326
|
+
def first_string(*values: Any) -> Optional[str]:
|
|
327
|
+
for value in values:
|
|
328
|
+
if isinstance(value, str) and value.strip():
|
|
329
|
+
return value.strip()
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def find_value(obj: Any, keys: Tuple[str, ...]) -> Optional[str]:
|
|
334
|
+
if isinstance(obj, dict):
|
|
335
|
+
for key, value in obj.items():
|
|
336
|
+
if key in keys and isinstance(value, str) and value.strip():
|
|
337
|
+
return value.strip()
|
|
338
|
+
found = find_value(value, keys)
|
|
339
|
+
if found:
|
|
340
|
+
return found
|
|
341
|
+
elif isinstance(obj, list):
|
|
342
|
+
for item in obj:
|
|
343
|
+
found = find_value(item, keys)
|
|
344
|
+
if found:
|
|
345
|
+
return found
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def resolve_payload_cwd(payload: Dict[str, Any]) -> Tuple[Optional[str], str]:
|
|
350
|
+
candidate = find_value(
|
|
351
|
+
payload,
|
|
352
|
+
(
|
|
353
|
+
"cwd",
|
|
354
|
+
"currentWorkingDirectory",
|
|
355
|
+
"current_working_directory",
|
|
356
|
+
"workspace",
|
|
357
|
+
"workspace_path",
|
|
358
|
+
"workspacePath",
|
|
359
|
+
"project_dir",
|
|
360
|
+
"projectDir",
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
text = first_string(candidate)
|
|
364
|
+
if text:
|
|
365
|
+
return text, "payload"
|
|
366
|
+
try:
|
|
367
|
+
return os.getcwd(), "process"
|
|
368
|
+
except Exception:
|
|
369
|
+
return None, "unavailable"
|
|
370
|
+
|
|
371
|
+
|
|
169
372
|
def extract_session_transcript_and_user(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path], Optional[str]]:
|
|
170
373
|
"""
|
|
171
374
|
Tries a few plausible field names; exact keys can vary across hook types/versions.
|
|
@@ -734,6 +937,7 @@ class Turn:
|
|
|
734
937
|
assistant_msgs: List[Dict[str, Any]]
|
|
735
938
|
tool_results_by_id: Dict[str, Any]
|
|
736
939
|
context_msgs: List[Dict[str, Any]] = field(default_factory=list)
|
|
940
|
+
repo_context: Dict[str, Any] = field(default_factory=dict)
|
|
737
941
|
|
|
738
942
|
def is_skill_context_user_msg(msg: Dict[str, Any]) -> bool:
|
|
739
943
|
if get_role(msg) != "user" or is_tool_result(msg):
|
|
@@ -868,6 +1072,7 @@ def emit_turn(
|
|
|
868
1072
|
skill_use_events=skill_use_events,
|
|
869
1073
|
)
|
|
870
1074
|
skill_summary = summarize_skill_usages(skill_usages)
|
|
1075
|
+
repo_context = dict(getattr(turn, "repo_context", {}) or collect_repo_context(None))
|
|
871
1076
|
|
|
872
1077
|
# attach tool outputs
|
|
873
1078
|
for c in tool_calls:
|
|
@@ -883,11 +1088,11 @@ def emit_turn(
|
|
|
883
1088
|
with propagate_attributes(
|
|
884
1089
|
user_id=user_id,
|
|
885
1090
|
session_id=session_id,
|
|
886
|
-
trace_name=AGENT_TURN_NAME,
|
|
887
|
-
tags=[AGENT_NAME],
|
|
888
|
-
):
|
|
889
|
-
with langfuse.start_as_current_observation(
|
|
890
|
-
name=AGENT_TURN_NAME,
|
|
1091
|
+
trace_name=AGENT_TURN_NAME,
|
|
1092
|
+
tags=[AGENT_NAME],
|
|
1093
|
+
):
|
|
1094
|
+
with langfuse.start_as_current_observation(
|
|
1095
|
+
name=AGENT_TURN_NAME,
|
|
891
1096
|
input={"role": "user", "content": user_text},
|
|
892
1097
|
output={"role": "assistant", "content": assistant_text},
|
|
893
1098
|
metadata={
|
|
@@ -897,6 +1102,7 @@ def emit_turn(
|
|
|
897
1102
|
"session_id": session_id,
|
|
898
1103
|
"turn_number": turn_num,
|
|
899
1104
|
"transcript_path": str(transcript_path),
|
|
1105
|
+
**repo_context,
|
|
900
1106
|
"user_text": user_text_meta,
|
|
901
1107
|
"skills": skill_summary,
|
|
902
1108
|
},
|
|
@@ -1001,6 +1207,8 @@ def main() -> int:
|
|
|
1001
1207
|
return 0
|
|
1002
1208
|
|
|
1003
1209
|
turns = build_turns(msgs)
|
|
1210
|
+
cwd, cwd_source = resolve_payload_cwd(payload)
|
|
1211
|
+
repo_context = collect_repo_context(cwd, cwd_source)
|
|
1004
1212
|
if not turns:
|
|
1005
1213
|
write_session_state(state, key, ss)
|
|
1006
1214
|
save_state(state)
|
|
@@ -1011,6 +1219,7 @@ def main() -> int:
|
|
|
1011
1219
|
for t in turns:
|
|
1012
1220
|
emitted += 1
|
|
1013
1221
|
turn_num = ss.turn_count + emitted
|
|
1222
|
+
t.repo_context = repo_context
|
|
1014
1223
|
try:
|
|
1015
1224
|
emit_turn(langfuse, session_id, user_id, turn_num, t, transcript_path)
|
|
1016
1225
|
except Exception as e:
|
package/package.json
CHANGED
|
@@ -129,6 +129,7 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
129
129
|
'import { promises as fs } from "node:fs";',
|
|
130
130
|
'import os from "node:os";',
|
|
131
131
|
'import path from "node:path";',
|
|
132
|
+
'import { execFileSync } from "node:child_process";',
|
|
132
133
|
'import { NodeSDK } from "@opentelemetry/sdk-node";',
|
|
133
134
|
'import { trace } from "@opentelemetry/api";',
|
|
134
135
|
"",
|
|
@@ -226,6 +227,129 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
226
227
|
"",
|
|
227
228
|
"const writeMetric = (span, key, value) => writeSpanAttribute(span, `langfuse.observation.metadata.${key}`, value);",
|
|
228
229
|
"",
|
|
230
|
+
"const emptyRepoContext = (cwd = '', cwdSource = 'unavailable', available = false, error = '', isGitRepo = false) => ({",
|
|
231
|
+
" cwd: String(cwd || ''),",
|
|
232
|
+
" cwd_source: cwdSource || 'unavailable',",
|
|
233
|
+
" git_context_available: Boolean(available),",
|
|
234
|
+
" git_context_error: error || '',",
|
|
235
|
+
" is_git_repo: Boolean(isGitRepo),",
|
|
236
|
+
" git_root: '',",
|
|
237
|
+
" git_remote: '',",
|
|
238
|
+
" git_remote_host: '',",
|
|
239
|
+
" git_repo_owner: '',",
|
|
240
|
+
" git_repo_name: '',",
|
|
241
|
+
" git_repo_slug: '',",
|
|
242
|
+
" git_branch: '',",
|
|
243
|
+
" git_commit: '',",
|
|
244
|
+
"});",
|
|
245
|
+
"",
|
|
246
|
+
"const sanitizeGitRemote = (remote) => {",
|
|
247
|
+
" const empty = { git_remote: '', git_remote_host: '', git_repo_owner: '', git_repo_name: '', git_repo_slug: '' };",
|
|
248
|
+
" const raw = String(remote || '').trim();",
|
|
249
|
+
" if (!raw) return empty;",
|
|
250
|
+
" try {",
|
|
251
|
+
" let host = '';",
|
|
252
|
+
" let parts = [];",
|
|
253
|
+
" if (raw.includes('://')) {",
|
|
254
|
+
" const parsed = new URL(raw);",
|
|
255
|
+
" host = parsed.hostname;",
|
|
256
|
+
" parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
|
|
257
|
+
" } else {",
|
|
258
|
+
" const scp = raw.match(/^[^@]+@([^:]+):(.+)$/);",
|
|
259
|
+
" if (scp) {",
|
|
260
|
+
" host = scp[1];",
|
|
261
|
+
" parts = scp[2].replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
|
|
262
|
+
" } else {",
|
|
263
|
+
" const parsed = new URL(`ssh://${raw}`);",
|
|
264
|
+
" host = parsed.hostname;",
|
|
265
|
+
" parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
|
|
266
|
+
" }",
|
|
267
|
+
" }",
|
|
268
|
+
" if (!host || parts.length < 2) return empty;",
|
|
269
|
+
" const owner = parts[parts.length - 2];",
|
|
270
|
+
" const repo = parts[parts.length - 1].replace(/\\.git$/i, '');",
|
|
271
|
+
" if (!owner || !repo) return empty;",
|
|
272
|
+
" const slug = `${owner}/${repo}`;",
|
|
273
|
+
" return { git_remote: `${host}/${slug}`, git_remote_host: host, git_repo_owner: owner, git_repo_name: repo, git_repo_slug: slug };",
|
|
274
|
+
" } catch {",
|
|
275
|
+
" return empty;",
|
|
276
|
+
" }",
|
|
277
|
+
"};",
|
|
278
|
+
"",
|
|
279
|
+
"const runGit = (cwd, args) => {",
|
|
280
|
+
" try {",
|
|
281
|
+
" const stdout = execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 500, stdio: ['ignore', 'pipe', 'ignore'] });",
|
|
282
|
+
" return { ok: true, stdout: String(stdout || '').trim(), error: '' };",
|
|
283
|
+
" } catch (error) {",
|
|
284
|
+
" if (error && error.code === 'ETIMEDOUT') return { ok: false, stdout: '', error: 'timeout' };",
|
|
285
|
+
" if (error && error.code === 'ENOENT') return { ok: false, stdout: '', error: 'git_unavailable' };",
|
|
286
|
+
" return { ok: false, stdout: '', error: 'git_error' };",
|
|
287
|
+
" }",
|
|
288
|
+
"};",
|
|
289
|
+
"",
|
|
290
|
+
"const repoContextCache = new Map();",
|
|
291
|
+
'const collectRepoContext = (cwd, cwdSource = "process") => {',
|
|
292
|
+
" const cwdText = String(cwd || '').trim();",
|
|
293
|
+
" if (!cwdText) return emptyRepoContext('', 'unavailable', false, 'missing_cwd');",
|
|
294
|
+
" const now = Date.now();",
|
|
295
|
+
" const cached = repoContextCache.get(cwdText);",
|
|
296
|
+
" if (cached && now - cached.time <= 30000) return { ...cached.context, cwd_source: cwdSource || cached.context.cwd_source };",
|
|
297
|
+
" let context = emptyRepoContext(cwdText, cwdSource, false, '', false);",
|
|
298
|
+
" const inside = runGit(cwdText, ['rev-parse', '--is-inside-work-tree']);",
|
|
299
|
+
" if (!inside.ok) {",
|
|
300
|
+
" const reason = ['timeout', 'git_unavailable'].includes(inside.error) ? inside.error : '';",
|
|
301
|
+
" context = reason ? emptyRepoContext(cwdText, cwdSource, false, reason, false) : emptyRepoContext(cwdText, cwdSource, true, '', false);",
|
|
302
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
303
|
+
" return context;",
|
|
304
|
+
" }",
|
|
305
|
+
" if (inside.stdout.toLowerCase() !== 'true') {",
|
|
306
|
+
" context = emptyRepoContext(cwdText, cwdSource, true, '', false);",
|
|
307
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
308
|
+
" return context;",
|
|
309
|
+
" }",
|
|
310
|
+
" context = emptyRepoContext(cwdText, cwdSource, true, '', true);",
|
|
311
|
+
" const root = runGit(cwdText, ['rev-parse', '--show-toplevel']);",
|
|
312
|
+
" if (!root.ok && ['timeout', 'git_unavailable'].includes(root.error)) {",
|
|
313
|
+
" context.git_context_available = false;",
|
|
314
|
+
" context.git_context_error = root.error;",
|
|
315
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
316
|
+
" return context;",
|
|
317
|
+
" }",
|
|
318
|
+
" context.git_root = root.stdout || '';",
|
|
319
|
+
" const remote = runGit(cwdText, ['config', '--get', 'remote.origin.url']);",
|
|
320
|
+
" if (remote.ok && remote.stdout) {",
|
|
321
|
+
" const sanitized = sanitizeGitRemote(remote.stdout);",
|
|
322
|
+
" context = { ...context, ...sanitized };",
|
|
323
|
+
" if (!sanitized.git_remote) context.git_context_error = 'remote_parse_failed';",
|
|
324
|
+
" } else if (['timeout', 'git_unavailable'].includes(remote.error)) {",
|
|
325
|
+
" context.git_context_available = false;",
|
|
326
|
+
" context.git_context_error = remote.error;",
|
|
327
|
+
" }",
|
|
328
|
+
" const branch = runGit(cwdText, ['branch', '--show-current']);",
|
|
329
|
+
" if (branch.ok) context.git_branch = branch.stdout || '';",
|
|
330
|
+
" const commit = runGit(cwdText, ['rev-parse', '--short', 'HEAD']);",
|
|
331
|
+
" if (commit.ok) context.git_commit = commit.stdout || '';",
|
|
332
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
333
|
+
" return context;",
|
|
334
|
+
"};",
|
|
335
|
+
"",
|
|
336
|
+
"const writeRepoContextMetrics = (span, context) => {",
|
|
337
|
+
" const repoContext = context || {};",
|
|
338
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.cwd', repoContext.cwd);",
|
|
339
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.cwd_source', repoContext.cwd_source);",
|
|
340
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_available', repoContext.git_context_available);",
|
|
341
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_error', repoContext.git_context_error);",
|
|
342
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.is_git_repo', repoContext.is_git_repo);",
|
|
343
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_root', repoContext.git_root);",
|
|
344
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote', repoContext.git_remote);",
|
|
345
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote_host', repoContext.git_remote_host);",
|
|
346
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_owner', repoContext.git_repo_owner);",
|
|
347
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_name', repoContext.git_repo_name);",
|
|
348
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_slug', repoContext.git_repo_slug);",
|
|
349
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_branch', repoContext.git_branch);",
|
|
350
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_commit', repoContext.git_commit);",
|
|
351
|
+
"};",
|
|
352
|
+
"",
|
|
229
353
|
"const writeOpencodeMetricAttributes = (span, userId) => {",
|
|
230
354
|
" const attrs = span.attributes ?? {};",
|
|
231
355
|
' writeSpanAttribute(span, "oh.langfuse.source", "opencode");',
|
|
@@ -658,6 +782,7 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
658
782
|
' if (tokenMetrics.cacheRead !== undefined) span.setAttribute("langfuse.observation.metadata.cache_read_tokens", tokenMetrics.cacheRead);',
|
|
659
783
|
' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
|
|
660
784
|
' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
|
|
785
|
+
' writeRepoContextMetrics(span, collectRepoContext(process.cwd(), "process"));',
|
|
661
786
|
" span.end();",
|
|
662
787
|
" messageTextById.delete(messageId);",
|
|
663
788
|
" skillUsagesByMessageId.delete(messageId);",
|
|
@@ -719,7 +844,7 @@ function writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, u
|
|
|
719
844
|
cmd.push(" exit /b %ERRORLEVEL%");
|
|
720
845
|
cmd.push(")");
|
|
721
846
|
cmd.push("opencode %*");
|
|
722
|
-
fs.writeFileSync(p, cmd.join("\r\n") + "\r\n", "utf8");
|
|
847
|
+
fs.writeFileSync(p, cmd.join("\r\n") + "\r\n", "utf8");
|
|
723
848
|
return p;
|
|
724
849
|
}
|
|
725
850
|
|
|
@@ -754,7 +879,7 @@ function writeAutoUpdateHelper(target) {
|
|
|
754
879
|
"exit /b 0",
|
|
755
880
|
""
|
|
756
881
|
];
|
|
757
|
-
fs.writeFileSync(helper, lines.join(os.EOL), "utf8");
|
|
882
|
+
fs.writeFileSync(helper, lines.join(os.EOL), "utf8");
|
|
758
883
|
return helper;
|
|
759
884
|
}
|
|
760
885
|
|
|
@@ -784,6 +909,18 @@ function unixAutoUpdateCommand(target) {
|
|
|
784
909
|
return `${shQuote(writeAutoUpdateHelper(target))} || true`;
|
|
785
910
|
}
|
|
786
911
|
|
|
912
|
+
function windowsGitPathBootstrap() {
|
|
913
|
+
return [
|
|
914
|
+
"for /f \"delims=\" %%G in ('where.exe git.exe 2^>nul') do (",
|
|
915
|
+
" set \"OH_LANGFUSE_GIT_EXE=%%G\"",
|
|
916
|
+
" set \"npm_config_git=%%G\"",
|
|
917
|
+
" set \"PATH=%%~dpG;%PATH%\"",
|
|
918
|
+
" goto :oh_langfuse_git_ready",
|
|
919
|
+
")",
|
|
920
|
+
":oh_langfuse_git_ready"
|
|
921
|
+
];
|
|
922
|
+
}
|
|
923
|
+
|
|
787
924
|
function writeWindowsUpdateCheckScript(dir) {
|
|
788
925
|
if (process.platform !== "win32") return null;
|
|
789
926
|
ensureDir(dir);
|
|
@@ -817,10 +954,11 @@ function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl,
|
|
|
817
954
|
cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
|
|
818
955
|
cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
|
|
819
956
|
if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
|
|
957
|
+
cmd.push(...windowsGitPathBootstrap());
|
|
820
958
|
cmd.push(windowsAutoUpdateCommand("opencode"));
|
|
821
959
|
cmd.push(`call ${cmdQuote(realOpencodeCli)} %*`);
|
|
822
960
|
cmd.push("exit /b %ERRORLEVEL%");
|
|
823
|
-
fs.writeFileSync(shim, cmd.join("\r\n") + "\r\n", "utf8");
|
|
961
|
+
fs.writeFileSync(shim, cmd.join("\r\n") + "\r\n", "utf8");
|
|
824
962
|
return { shim, shimDir };
|
|
825
963
|
}
|
|
826
964
|
|
|
@@ -1080,7 +1218,7 @@ async function runNpmInstallOrThrow({ opencodeDir, pkgName = "opencode-plugin-la
|
|
|
1080
1218
|
);
|
|
1081
1219
|
}
|
|
1082
1220
|
|
|
1083
|
-
function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
|
|
1221
|
+
function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
|
|
1084
1222
|
const cmd = [
|
|
1085
1223
|
"$ErrorActionPreference = 'Stop';",
|
|
1086
1224
|
`[Environment]::SetEnvironmentVariable('LANGFUSE_PUBLIC_KEY', ${psQuote(publicKey)}, 'User');`,
|
|
@@ -1090,10 +1228,10 @@ function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
|
|
|
1090
1228
|
const r = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
|
|
1091
1229
|
if (r.status !== 0) {
|
|
1092
1230
|
throw new Error("写入用户级环境变量失败。");
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
function prependWindowsUserPath(dir) {
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function prependWindowsUserPath(dir) {
|
|
1097
1235
|
if (process.platform !== "win32") return false;
|
|
1098
1236
|
const cmd = [
|
|
1099
1237
|
"$ErrorActionPreference = 'Stop';",
|
|
@@ -9,10 +9,10 @@ import { resolveOpencodeCli } from "./resolve-opencode-cli.mjs";
|
|
|
9
9
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
11
11
|
|
|
12
|
-
const DEFAULT_LANGFUSE_BASE_URL = "http://120.46.221.227:3000";
|
|
13
|
-
const DEFAULT_LANGFUSE_PUBLIC_KEY = "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
|
|
14
|
-
const DEFAULT_LANGFUSE_SECRET_KEY = "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
|
|
15
|
-
const OPENCODE_NATIVE_AGENT_TURN_NAMES = new Set(["session.llm:ai.streamText.doStream"]);
|
|
12
|
+
const DEFAULT_LANGFUSE_BASE_URL = "http://120.46.221.227:3000";
|
|
13
|
+
const DEFAULT_LANGFUSE_PUBLIC_KEY = "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
|
|
14
|
+
const DEFAULT_LANGFUSE_SECRET_KEY = "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
|
|
15
|
+
const OPENCODE_NATIVE_AGENT_TURN_NAMES = new Set(["session.llm:ai.streamText.doStream"]);
|
|
16
16
|
|
|
17
17
|
function parseArgs(argv) {
|
|
18
18
|
const args = { _: [] };
|
|
@@ -323,34 +323,34 @@ function metadataValue(item, key) {
|
|
|
323
323
|
const attrs = metadata.attributes || {};
|
|
324
324
|
const prefixed = `langfuse.observation.metadata.${key}`;
|
|
325
325
|
if (attrs[prefixed] !== undefined) return attrs[prefixed];
|
|
326
|
-
const ohKey = `oh.langfuse.${key}`;
|
|
327
|
-
if (attrs[ohKey] !== undefined) return attrs[ohKey];
|
|
328
|
-
if (metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode") {
|
|
329
|
-
const usage = item?.usage || {};
|
|
330
|
-
if (key === "interaction_id") return `opencode:${metadata.user_id || attrs["oh.langfuse.user_id"] || "unknown"}:${item?.id || item?.traceId || item?.trace_id || "unknown"}`;
|
|
331
|
-
if (key === "interaction_count") return 1;
|
|
332
|
-
if (key === "token_metrics_available") {
|
|
333
|
-
return usage.input !== undefined || usage.output !== undefined || usage.total !== undefined || [
|
|
334
|
-
"ai.usage.inputTokens",
|
|
335
|
-
"ai.usage.outputTokens",
|
|
336
|
-
"ai.usage.totalTokens",
|
|
326
|
+
const ohKey = `oh.langfuse.${key}`;
|
|
327
|
+
if (attrs[ohKey] !== undefined) return attrs[ohKey];
|
|
328
|
+
if (metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode") {
|
|
329
|
+
const usage = item?.usage || {};
|
|
330
|
+
if (key === "interaction_id") return `opencode:${metadata.user_id || attrs["oh.langfuse.user_id"] || "unknown"}:${item?.id || item?.traceId || item?.trace_id || "unknown"}`;
|
|
331
|
+
if (key === "interaction_count") return 1;
|
|
332
|
+
if (key === "token_metrics_available") {
|
|
333
|
+
return usage.input !== undefined || usage.output !== undefined || usage.total !== undefined || [
|
|
334
|
+
"ai.usage.inputTokens",
|
|
335
|
+
"ai.usage.outputTokens",
|
|
336
|
+
"ai.usage.totalTokens",
|
|
337
337
|
"ai.usage.promptTokens",
|
|
338
338
|
"ai.usage.completionTokens",
|
|
339
339
|
].some((attrKey) => attrs[attrKey] !== undefined);
|
|
340
340
|
}
|
|
341
|
-
if (key === "tool_call_count" || key === "skill_use_count") return 0;
|
|
342
|
-
const tokenAttrMap = {
|
|
343
|
-
input_tokens: ["input", "ai.usage.inputTokens", "ai.usage.promptTokens"],
|
|
344
|
-
output_tokens: ["output", "ai.usage.outputTokens", "ai.usage.completionTokens"],
|
|
345
|
-
total_tokens: ["total", "ai.usage.totalTokens"],
|
|
346
|
-
cache_read_tokens: ["ai.usage.cachedInputTokens", "ai.usage.inputTokenDetails.cacheReadTokens"],
|
|
347
|
-
reasoning_tokens: ["ai.usage.reasoningTokens", "ai.usage.outputTokenDetails.reasoningTokens"],
|
|
348
|
-
};
|
|
349
|
-
for (const attrKey of tokenAttrMap[key] || []) {
|
|
350
|
-
if (usage[attrKey] !== undefined) return usage[attrKey];
|
|
351
|
-
if (attrs[attrKey] !== undefined) return attrs[attrKey];
|
|
352
|
-
}
|
|
353
|
-
}
|
|
341
|
+
if (key === "tool_call_count" || key === "skill_use_count") return 0;
|
|
342
|
+
const tokenAttrMap = {
|
|
343
|
+
input_tokens: ["input", "ai.usage.inputTokens", "ai.usage.promptTokens"],
|
|
344
|
+
output_tokens: ["output", "ai.usage.outputTokens", "ai.usage.completionTokens"],
|
|
345
|
+
total_tokens: ["total", "ai.usage.totalTokens"],
|
|
346
|
+
cache_read_tokens: ["ai.usage.cachedInputTokens", "ai.usage.inputTokenDetails.cacheReadTokens"],
|
|
347
|
+
reasoning_tokens: ["ai.usage.reasoningTokens", "ai.usage.outputTokenDetails.reasoningTokens"],
|
|
348
|
+
};
|
|
349
|
+
for (const attrKey of tokenAttrMap[key] || []) {
|
|
350
|
+
if (usage[attrKey] !== undefined) return usage[attrKey];
|
|
351
|
+
if (attrs[attrKey] !== undefined) return attrs[attrKey];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
354
|
return undefined;
|
|
355
355
|
}
|
|
356
356
|
|
|
@@ -369,18 +369,18 @@ function metricInteractionId(item, target) {
|
|
|
369
369
|
: metadataValue(item, "interaction_id");
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
function isAgentTurnObservation(item) {
|
|
373
|
-
const metadata = item?.metadata || {};
|
|
374
|
-
const attrs = metadata.attributes || {};
|
|
375
|
-
const isOpencode = metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode";
|
|
376
|
-
if (/^(Claude|OpenCode|Codex) Agent Turn$/.test(String(item?.name || "")) || item?.name === "Agent Turn" || (!isOpencode && (metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1"))) {
|
|
377
|
-
return true;
|
|
378
|
-
}
|
|
379
|
-
if (isOpencode && OPENCODE_NATIVE_AGENT_TURN_NAMES.has(String(item?.name || ""))) return true;
|
|
380
|
-
const hasModel = attrs["ai.model.id"] !== undefined || attrs["ai.model.provider"] !== undefined;
|
|
381
|
-
const isTool = attrs["ai.toolCall.name"] !== undefined || attrs["ai.toolCall.id"] !== undefined;
|
|
382
|
-
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
383
|
-
}
|
|
372
|
+
function isAgentTurnObservation(item) {
|
|
373
|
+
const metadata = item?.metadata || {};
|
|
374
|
+
const attrs = metadata.attributes || {};
|
|
375
|
+
const isOpencode = metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode";
|
|
376
|
+
if (/^(Claude|OpenCode|Codex) Agent Turn$/.test(String(item?.name || "")) || item?.name === "Agent Turn" || (!isOpencode && (metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1"))) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (isOpencode && OPENCODE_NATIVE_AGENT_TURN_NAMES.has(String(item?.name || ""))) return true;
|
|
380
|
+
const hasModel = attrs["ai.model.id"] !== undefined || attrs["ai.model.provider"] !== undefined;
|
|
381
|
+
const isTool = attrs["ai.toolCall.name"] !== undefined || attrs["ai.toolCall.id"] !== undefined;
|
|
382
|
+
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
383
|
+
}
|
|
384
384
|
|
|
385
385
|
function expectedAgentTurnName(target) {
|
|
386
386
|
if (target === "claude") return "Claude Agent Turn";
|
|
@@ -389,32 +389,32 @@ function expectedAgentTurnName(target) {
|
|
|
389
389
|
return "Agent Turn";
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
async function observationsForTrace(config, traceId, since) {
|
|
393
|
-
if (!traceId) return [];
|
|
394
|
-
const params = { limit: 100, fields: "core,basic,usage", traceId };
|
|
395
|
-
try {
|
|
396
|
-
return dataArray(await langfuseGet(config, "/v2/observations", params));
|
|
397
|
-
} catch (error) {
|
|
398
|
-
if (error.status !== 400 && error.status !== 404) throw error;
|
|
399
|
-
}
|
|
400
|
-
try {
|
|
401
|
-
return dataArray(await langfuseGet(config, "/observations", { limit: 100, traceId }));
|
|
402
|
-
} catch (error) {
|
|
403
|
-
if (error.status !== 400) throw error;
|
|
404
|
-
}
|
|
392
|
+
async function observationsForTrace(config, traceId, since) {
|
|
393
|
+
if (!traceId) return [];
|
|
394
|
+
const params = { limit: 100, fields: "core,basic,usage", traceId };
|
|
395
|
+
try {
|
|
396
|
+
return dataArray(await langfuseGet(config, "/v2/observations", params));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error.status !== 400 && error.status !== 404) throw error;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
return dataArray(await langfuseGet(config, "/observations", { limit: 100, traceId }));
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error.status !== 400) throw error;
|
|
404
|
+
}
|
|
405
405
|
const fallback = dataArray(await langfuseGetLenient(config, "/observations", { limit: 100, fromTimestamp: since.toISOString() }));
|
|
406
406
|
return fallback.filter((item) => item.traceId === traceId || item.trace_id === traceId);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
function mergeMetricCandidates(items) {
|
|
410
410
|
const out = [];
|
|
411
|
-
const seen = new Set();
|
|
412
|
-
for (const item of items || []) {
|
|
413
|
-
if (!item || typeof item !== "object") continue;
|
|
414
|
-
const key = idOf(item) || [item.name || "", item.traceId || item.trace_id || ""].join(":");
|
|
415
|
-
if (seen.has(key)) continue;
|
|
416
|
-
seen.add(key);
|
|
417
|
-
out.push(item);
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
for (const item of items || []) {
|
|
413
|
+
if (!item || typeof item !== "object") continue;
|
|
414
|
+
const key = idOf(item) || [item.name || "", item.traceId || item.trace_id || ""].join(":");
|
|
415
|
+
if (seen.has(key)) continue;
|
|
416
|
+
seen.add(key);
|
|
417
|
+
out.push(item);
|
|
418
418
|
}
|
|
419
419
|
return out;
|
|
420
420
|
}
|
|
@@ -464,13 +464,13 @@ async function verifyMetricObservations(config, found, { since, target, marker =
|
|
|
464
464
|
|
|
465
465
|
if (!interactions.length) {
|
|
466
466
|
throw new Error(`Metric verification failed for ${target}: Agent Turn observation was not found for trace ${traceId || found.id}.`);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const expectedName = expectedAgentTurnName(target);
|
|
470
|
-
if (target !== "opencode" && !interactions.some((item) => item?.name === expectedName)) {
|
|
471
|
-
const names = interactions.map((item) => item?.name || "<unnamed>").join(", ");
|
|
472
|
-
throw new Error(`Metric verification failed for ${target}: expected observation name ${expectedName}, found ${names}.`);
|
|
473
|
-
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const expectedName = expectedAgentTurnName(target);
|
|
470
|
+
if (target !== "opencode" && !interactions.some((item) => item?.name === expectedName)) {
|
|
471
|
+
const names = interactions.map((item) => item?.name || "<unnamed>").join(", ");
|
|
472
|
+
throw new Error(`Metric verification failed for ${target}: expected observation name ${expectedName}, found ${names}.`);
|
|
473
|
+
}
|
|
474
474
|
|
|
475
475
|
const byInteractionId = new Map();
|
|
476
476
|
const seenInteractionIds = new Set();
|
|
@@ -487,13 +487,33 @@ async function verifyMetricObservations(config, found, { since, target, marker =
|
|
|
487
487
|
throw new Error(`Metric verification failed for ${target}: Agent Turn is missing ${key}.`);
|
|
488
488
|
}
|
|
489
489
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
490
|
+
const repoContextKeys = [
|
|
491
|
+
"cwd",
|
|
492
|
+
"cwd_source",
|
|
493
|
+
"git_context_available",
|
|
494
|
+
"git_context_error",
|
|
495
|
+
"is_git_repo",
|
|
496
|
+
"git_root",
|
|
497
|
+
"git_remote",
|
|
498
|
+
"git_remote_host",
|
|
499
|
+
"git_repo_owner",
|
|
500
|
+
"git_repo_name",
|
|
501
|
+
"git_repo_slug",
|
|
502
|
+
"git_branch",
|
|
503
|
+
"git_commit",
|
|
504
|
+
];
|
|
505
|
+
for (const key of repoContextKeys) {
|
|
506
|
+
if (!hasMetadataKey(item, key)) {
|
|
507
|
+
throw new Error(`Metric verification failed for ${target}: Agent Turn is missing repo context ${key}.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (target === "opencode") {
|
|
511
|
+
for (const key of ["interaction_id", "interaction_count", "token_metrics_available", "tool_call_count", "skill_use_count", "input_tokens", "output_tokens", "total_tokens"]) {
|
|
512
|
+
if (metadataValue(item, key) === undefined) {
|
|
513
|
+
throw new Error(`Metric verification failed for ${target}: effective metadata is missing ${key}.`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
497
517
|
const tokenAvailable = metadataValue(item, "token_metrics_available");
|
|
498
518
|
for (const tokenKey of ["input_tokens", "output_tokens", "total_tokens"]) {
|
|
499
519
|
const value = metadataValue(item, tokenKey);
|