coze_lab 0.1.26 → 0.1.28
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/index.js +914 -95
- package/package.json +4 -1
- package/scripts/claude-code/cozeloop_hook.py +110 -31
- package/scripts/codex/cozeloop_hook.py +106 -30
- package/scripts/openclaw/dist/cozeloop-exporter.js +11 -3
- package/scripts/openclaw/dist/index.js +1 -0
- package/scripts/openclaw/openclaw.plugin.json +6 -1
- package/scripts/openclaw/package.json +1 -1
- package/scripts/shared/cozeloop_refresh.py +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coze_lab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cozeloop",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"bin": {
|
|
15
15
|
"coze_lab": "index.js"
|
|
16
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.js"
|
|
19
|
+
},
|
|
17
20
|
"files": [
|
|
18
21
|
"index.js",
|
|
19
22
|
"scripts/claude-code/cozeloop_hook.py",
|
|
@@ -29,19 +29,43 @@ from pathlib import Path
|
|
|
29
29
|
from typing import Optional, List, Dict, Any
|
|
30
30
|
|
|
31
31
|
# --- SDK Import ---
|
|
32
|
-
|
|
32
|
+
# 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
|
|
33
|
+
# 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
|
|
34
|
+
# _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
|
|
35
|
+
_MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cozeloop_capable():
|
|
39
|
+
"""已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
|
|
33
40
|
try:
|
|
34
41
|
import cozeloop # noqa: F401
|
|
35
|
-
return True
|
|
36
42
|
except ImportError:
|
|
37
|
-
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return hasattr(cozeloop.Span, "set_finish_time")
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ensure_cozeloop_sdk():
|
|
51
|
+
"""确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
|
|
52
|
+
|
|
53
|
+
返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
|
|
54
|
+
_set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
|
|
55
|
+
"""
|
|
56
|
+
capable = _cozeloop_capable()
|
|
57
|
+
if capable is True:
|
|
58
|
+
return True
|
|
59
|
+
# 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
|
|
38
60
|
import subprocess
|
|
39
61
|
import importlib
|
|
40
62
|
import site
|
|
63
|
+
pkg = _MIN_COZELOOP_SPEC
|
|
64
|
+
base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
|
|
41
65
|
attempts = (
|
|
42
|
-
[
|
|
43
|
-
[
|
|
44
|
-
[
|
|
66
|
+
[*base_flags, pkg],
|
|
67
|
+
[*base_flags, "--break-system-packages", pkg],
|
|
68
|
+
[*base_flags, "--break-system-packages", "--user", pkg],
|
|
45
69
|
)
|
|
46
70
|
for extra in attempts:
|
|
47
71
|
try:
|
|
@@ -60,11 +84,12 @@ def _ensure_cozeloop_sdk():
|
|
|
60
84
|
sys.path.insert(0, p)
|
|
61
85
|
importlib.invalidate_caches()
|
|
62
86
|
import cozeloop # noqa: F401
|
|
63
|
-
print("[CozeLoop] cozeloop SDK
|
|
87
|
+
print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
|
|
64
88
|
return True
|
|
65
89
|
except ImportError:
|
|
66
90
|
continue
|
|
67
|
-
|
|
91
|
+
# 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
|
|
92
|
+
return capable is False
|
|
68
93
|
|
|
69
94
|
|
|
70
95
|
if _ensure_cozeloop_sdk():
|
|
@@ -170,7 +195,7 @@ def _extract_logid(msg: str) -> str:
|
|
|
170
195
|
return "".join(logid)
|
|
171
196
|
|
|
172
197
|
|
|
173
|
-
def _make_finish_event_processor():
|
|
198
|
+
def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
|
|
174
199
|
"""Return a trace_finish_event_processor that surfaces failures + logid.
|
|
175
200
|
|
|
176
201
|
The CozeLoop SDK calls this for each flush event; on failure we print the
|
|
@@ -182,6 +207,8 @@ def _make_finish_event_processor():
|
|
|
182
207
|
if not getattr(info, "is_event_fail", False):
|
|
183
208
|
return
|
|
184
209
|
detail = getattr(info, "detail_msg", "") or ""
|
|
210
|
+
if upload_events is not None:
|
|
211
|
+
upload_events.append(detail or "trace export failed")
|
|
185
212
|
logid = _extract_logid(detail)
|
|
186
213
|
if logid:
|
|
187
214
|
print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
|
|
@@ -229,7 +256,11 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
|
|
|
229
256
|
req = urllib.request.Request(
|
|
230
257
|
f"{_COZE_API}/api/permission/oauth2/token",
|
|
231
258
|
data=payload,
|
|
232
|
-
headers={
|
|
259
|
+
headers={
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
"x-tt-env": "ppe_cozelab",
|
|
262
|
+
"x-use-ppe": "1",
|
|
263
|
+
},
|
|
233
264
|
)
|
|
234
265
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
235
266
|
data = json.loads(resp.read())
|
|
@@ -267,28 +298,48 @@ def get_api_base_url() -> str:
|
|
|
267
298
|
os.environ.get("COZELOOP_API_BASE_URL", "")
|
|
268
299
|
)
|
|
269
300
|
|
|
301
|
+
def _token_from_credentials() -> Optional[str]:
|
|
302
|
+
creds = _load_credentials()
|
|
303
|
+
if not creds:
|
|
304
|
+
return None
|
|
305
|
+
expires_at_sec = creds.get("expires_at", 0) / 1000
|
|
306
|
+
remaining = expires_at_sec - time.time()
|
|
307
|
+
if remaining > _REFRESH_THRESHOLD:
|
|
308
|
+
debug_log(f"Cached token valid, expires in {int(remaining)}s.")
|
|
309
|
+
return creds.get("access_token")
|
|
310
|
+
if creds.get("refresh_token"):
|
|
311
|
+
debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
|
|
312
|
+
new_token = _refresh_token(creds["refresh_token"])
|
|
313
|
+
if new_token:
|
|
314
|
+
return new_token
|
|
315
|
+
debug_log("Refresh failed.")
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
270
319
|
def get_fresh_token() -> Optional[str]:
|
|
271
|
-
"""Return a valid access token, refreshing if needed.
|
|
320
|
+
"""Return a valid access token, refreshing if needed."""
|
|
272
321
|
env_token = os.environ.get("COZELOOP_API_TOKEN")
|
|
273
322
|
env_coze_token = os.environ.get("COZE_API_TOKEN")
|
|
323
|
+
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
324
|
+
if is_cloud:
|
|
325
|
+
return env_token or env_coze_token or _token_from_credentials()
|
|
326
|
+
|
|
327
|
+
# Local onboard used to write a short-lived access token into settings.local.json.
|
|
328
|
+
# If credentials exist but cannot be refreshed, do not fall back to that stale
|
|
329
|
+
# env token; failing closed keeps state from advancing on a known-bad token.
|
|
330
|
+
creds = _load_credentials()
|
|
331
|
+
if creds:
|
|
332
|
+
return _token_from_credentials()
|
|
333
|
+
if env_token and not env_coze_token:
|
|
334
|
+
token = _token_from_credentials()
|
|
335
|
+
if token:
|
|
336
|
+
return token
|
|
337
|
+
return env_token
|
|
274
338
|
if env_token:
|
|
275
339
|
return env_token
|
|
276
340
|
if env_coze_token:
|
|
277
341
|
return env_coze_token
|
|
278
|
-
|
|
279
|
-
if creds:
|
|
280
|
-
expires_at_sec = creds.get("expires_at", 0) / 1000
|
|
281
|
-
remaining = expires_at_sec - time.time()
|
|
282
|
-
if remaining > _REFRESH_THRESHOLD:
|
|
283
|
-
debug_log(f"Cached token valid, expires in {int(remaining)}s.")
|
|
284
|
-
return creds["access_token"]
|
|
285
|
-
if creds.get("refresh_token"):
|
|
286
|
-
debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
|
|
287
|
-
new_token = _refresh_token(creds["refresh_token"])
|
|
288
|
-
if new_token:
|
|
289
|
-
return new_token
|
|
290
|
-
debug_log("Refresh failed, falling back to env var.")
|
|
291
|
-
return None
|
|
342
|
+
return _token_from_credentials()
|
|
292
343
|
|
|
293
344
|
# -------------------------------------------------------------------------
|
|
294
345
|
|
|
@@ -539,6 +590,23 @@ def _ts_ms(dt):
|
|
|
539
590
|
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
540
591
|
|
|
541
592
|
|
|
593
|
+
def _set_finish_time_safe(span, dt):
|
|
594
|
+
"""安全设置 span 结束时间。
|
|
595
|
+
|
|
596
|
+
set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
|
|
597
|
+
让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
|
|
598
|
+
"""
|
|
599
|
+
if dt is None:
|
|
600
|
+
return
|
|
601
|
+
fn = getattr(span, "set_finish_time", None)
|
|
602
|
+
if fn is None:
|
|
603
|
+
return
|
|
604
|
+
try:
|
|
605
|
+
fn(dt)
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
|
|
542
610
|
def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
543
611
|
"""Group messages into conversation turns (user -> assistant -> tool_results).
|
|
544
612
|
|
|
@@ -889,10 +957,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
889
957
|
creds = _load_credentials()
|
|
890
958
|
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
891
959
|
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
960
|
+
upload_events: List[str] = []
|
|
892
961
|
client_kwargs = {
|
|
893
962
|
"ultra_large_report": True,
|
|
894
963
|
"upload_timeout": 120,
|
|
895
|
-
"trace_finish_event_processor": _make_finish_event_processor(),
|
|
964
|
+
"trace_finish_event_processor": _make_finish_event_processor(upload_events),
|
|
896
965
|
}
|
|
897
966
|
if workspace_id:
|
|
898
967
|
client_kwargs["workspace_id"] = workspace_id
|
|
@@ -926,7 +995,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
926
995
|
}
|
|
927
996
|
# 真实耗时写 tag(SDK finish 不支持显式结束时间,duration 用 tag 兜底)
|
|
928
997
|
if root_start_dt is not None and root_end_dt is not None:
|
|
929
|
-
root_span
|
|
998
|
+
_set_finish_time_safe(root_span, root_end_dt)
|
|
930
999
|
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
931
1000
|
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
932
1001
|
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
@@ -985,7 +1054,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
985
1054
|
"source": "claude_code",
|
|
986
1055
|
}
|
|
987
1056
|
if turn_start_dt is not None and turn_end_dt is not None:
|
|
988
|
-
turn_span
|
|
1057
|
+
_set_finish_time_safe(turn_span, turn_end_dt)
|
|
989
1058
|
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
990
1059
|
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
991
1060
|
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
@@ -1019,7 +1088,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1019
1088
|
model_span.set_runtime(Runtime(library="claude-code"))
|
|
1020
1089
|
model_span.set_model_name(model_name)
|
|
1021
1090
|
if step_start_dt is not None and step_end_dt is not None:
|
|
1022
|
-
model_span
|
|
1091
|
+
_set_finish_time_safe(model_span, step_end_dt)
|
|
1023
1092
|
model_span.set_tags({
|
|
1024
1093
|
"real_start_ms": _ts_ms(step_start_dt),
|
|
1025
1094
|
"real_end_ms": _ts_ms(step_end_dt),
|
|
@@ -1132,7 +1201,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1132
1201
|
tool_span.set_runtime(Runtime(library="claude-code"))
|
|
1133
1202
|
# 设真实完成时间,让每个 tool 的 duration 反映实际耗时。
|
|
1134
1203
|
if tool_end_dt is not None:
|
|
1135
|
-
tool_span
|
|
1204
|
+
_set_finish_time_safe(tool_span, tool_end_dt)
|
|
1136
1205
|
tags = {
|
|
1137
1206
|
"tool_name": tool_name,
|
|
1138
1207
|
"tool_call_id": tool_call.get("id"),
|
|
@@ -1349,11 +1418,18 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1349
1418
|
except Exception as e:
|
|
1350
1419
|
print(f"[CozeLoop] 上报失败 ✗ {e}", file=sys.stderr)
|
|
1351
1420
|
debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
|
|
1421
|
+
return None
|
|
1352
1422
|
finally:
|
|
1353
1423
|
# Crucial: close the client to ensure all buffered traces are sent.
|
|
1354
1424
|
client.close()
|
|
1355
1425
|
debug_log("CozeLoop client closed.")
|
|
1356
1426
|
|
|
1427
|
+
if upload_events:
|
|
1428
|
+
debug_log(f"Upload failed, state not advanced. Last failure: {upload_events[-1][:500]}")
|
|
1429
|
+
return None
|
|
1430
|
+
|
|
1431
|
+
return True
|
|
1432
|
+
|
|
1357
1433
|
|
|
1358
1434
|
# --- Hook Input ---
|
|
1359
1435
|
|
|
@@ -1463,7 +1539,10 @@ def main():
|
|
|
1463
1539
|
debug_log("No coze-context found in any turn (incl. history), skipping upload.")
|
|
1464
1540
|
return
|
|
1465
1541
|
print(f"[CozeLoop] 开始上报: session={session_id}, turns={len(turns)}", file=sys.stderr)
|
|
1466
|
-
send_turns_to_cozeloop(turns, session_id, history_turns)
|
|
1542
|
+
uploaded = send_turns_to_cozeloop(turns, session_id, history_turns)
|
|
1543
|
+
if uploaded is None:
|
|
1544
|
+
debug_log("Send failed, state not advanced.")
|
|
1545
|
+
return
|
|
1467
1546
|
|
|
1468
1547
|
# Update state with the new last processed line number
|
|
1469
1548
|
last_line_in_batch = max(msg.get("_line_number", 0) for msg in new_messages)
|
|
@@ -137,7 +137,7 @@ def _extract_logid(msg: str) -> str:
|
|
|
137
137
|
return "".join(logid)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def _make_finish_event_processor():
|
|
140
|
+
def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
|
|
141
141
|
"""Return a trace_finish_event_processor that surfaces failures + logid.
|
|
142
142
|
|
|
143
143
|
The CozeLoop SDK calls this for each flush event; on failure we print the
|
|
@@ -150,6 +150,8 @@ def _make_finish_event_processor():
|
|
|
150
150
|
hook_log("upload success")
|
|
151
151
|
return
|
|
152
152
|
detail = getattr(info, "detail_msg", "") or ""
|
|
153
|
+
if upload_events is not None:
|
|
154
|
+
upload_events.append(detail or "trace export failed")
|
|
153
155
|
logid = _extract_logid(detail)
|
|
154
156
|
if logid:
|
|
155
157
|
hook_log(f"upload failed logid={logid} detail={detail[:500]}")
|
|
@@ -191,15 +193,21 @@ def _refresh_token(refresh_tok: str):
|
|
|
191
193
|
req = urllib.request.Request(
|
|
192
194
|
f"{_COZE_API}/api/permission/oauth2/token",
|
|
193
195
|
data=payload,
|
|
194
|
-
headers={
|
|
196
|
+
headers={
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
"x-tt-env": "ppe_cozelab",
|
|
199
|
+
"x-use-ppe": "1",
|
|
200
|
+
},
|
|
195
201
|
)
|
|
196
202
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
197
203
|
data = json.loads(resp.read())
|
|
198
204
|
if data.get("access_token"):
|
|
205
|
+
existing = _load_credentials() or {}
|
|
199
206
|
creds = {
|
|
200
207
|
"access_token": data["access_token"],
|
|
201
208
|
"refresh_token": data.get("refresh_token", refresh_tok),
|
|
202
|
-
"expires_at": data.get("expires_in", 0) * 1000 # unix timestamp in seconds
|
|
209
|
+
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
210
|
+
"workspace_id": existing.get("workspace_id", ""),
|
|
203
211
|
}
|
|
204
212
|
_save_credentials(creds)
|
|
205
213
|
return creds["access_token"]
|
|
@@ -229,39 +237,84 @@ def get_api_base_url() -> str:
|
|
|
229
237
|
)
|
|
230
238
|
|
|
231
239
|
|
|
240
|
+
def _token_from_credentials():
|
|
241
|
+
creds = _load_credentials()
|
|
242
|
+
if not creds:
|
|
243
|
+
return None
|
|
244
|
+
remaining = creds.get("expires_at", 0) / 1000 - time.time()
|
|
245
|
+
if remaining > _REFRESH_THRESHOLD:
|
|
246
|
+
return creds.get("access_token")
|
|
247
|
+
if creds.get("refresh_token"):
|
|
248
|
+
new_token = _refresh_token(creds["refresh_token"])
|
|
249
|
+
if new_token:
|
|
250
|
+
return new_token
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
232
254
|
def get_fresh_token():
|
|
233
255
|
env_token = os.environ.get("COZELOOP_API_TOKEN")
|
|
234
256
|
env_coze_token = os.environ.get("COZE_API_TOKEN")
|
|
257
|
+
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
258
|
+
if is_cloud:
|
|
259
|
+
return env_token or env_coze_token or _token_from_credentials()
|
|
260
|
+
|
|
261
|
+
# Local onboard used to write a short-lived access token into cozeloop.env.
|
|
262
|
+
# If credentials exist but cannot be refreshed, do not fall back to that stale
|
|
263
|
+
# env token; failing closed keeps state from advancing on a known-bad token.
|
|
264
|
+
creds = _load_credentials()
|
|
265
|
+
if creds:
|
|
266
|
+
return _token_from_credentials()
|
|
267
|
+
if env_token and not env_coze_token:
|
|
268
|
+
token = _token_from_credentials()
|
|
269
|
+
if token:
|
|
270
|
+
return token
|
|
271
|
+
return env_token
|
|
235
272
|
if env_token:
|
|
236
273
|
return env_token
|
|
237
274
|
if env_coze_token:
|
|
238
275
|
return env_coze_token
|
|
239
|
-
|
|
240
|
-
if creds:
|
|
241
|
-
remaining = creds.get("expires_at", 0) / 1000 - time.time()
|
|
242
|
-
if remaining > _REFRESH_THRESHOLD:
|
|
243
|
-
return creds["access_token"]
|
|
244
|
-
if creds.get("refresh_token"):
|
|
245
|
-
new_token = _refresh_token(creds["refresh_token"])
|
|
246
|
-
if new_token:
|
|
247
|
-
return new_token
|
|
248
|
-
return None
|
|
276
|
+
return _token_from_credentials()
|
|
249
277
|
# -------------------------------------------------------------------------
|
|
250
278
|
|
|
251
279
|
# --- SDK Import ---
|
|
252
|
-
|
|
280
|
+
# 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
|
|
281
|
+
# 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
|
|
282
|
+
# _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
|
|
283
|
+
_MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cozeloop_capable():
|
|
287
|
+
"""已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
|
|
253
288
|
try:
|
|
254
289
|
import cozeloop # noqa: F401
|
|
255
|
-
return True
|
|
256
290
|
except ImportError:
|
|
257
|
-
|
|
291
|
+
return None
|
|
292
|
+
try:
|
|
293
|
+
return hasattr(cozeloop.Span, "set_finish_time")
|
|
294
|
+
except Exception:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _ensure_cozeloop_sdk():
|
|
299
|
+
"""确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
|
|
300
|
+
|
|
301
|
+
返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
|
|
302
|
+
_set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
|
|
303
|
+
"""
|
|
304
|
+
capable = _cozeloop_capable()
|
|
305
|
+
if capable is True:
|
|
306
|
+
return True
|
|
307
|
+
# 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
|
|
258
308
|
import subprocess
|
|
259
309
|
import importlib
|
|
260
310
|
import site
|
|
311
|
+
# 能力不足时强制升级到带下限的版本;未装时直接装下限版本。
|
|
312
|
+
pkg = _MIN_COZELOOP_SPEC
|
|
313
|
+
base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
|
|
261
314
|
attempts = (
|
|
262
|
-
[
|
|
263
|
-
[
|
|
264
|
-
[
|
|
315
|
+
[*base_flags, pkg],
|
|
316
|
+
[*base_flags, "--break-system-packages", pkg],
|
|
317
|
+
[*base_flags, "--break-system-packages", "--user", pkg],
|
|
265
318
|
)
|
|
266
319
|
for extra in attempts:
|
|
267
320
|
try:
|
|
@@ -280,11 +333,12 @@ def _ensure_cozeloop_sdk():
|
|
|
280
333
|
sys.path.insert(0, p)
|
|
281
334
|
importlib.invalidate_caches()
|
|
282
335
|
import cozeloop # noqa: F401
|
|
283
|
-
print("[CozeLoop] cozeloop SDK
|
|
336
|
+
print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
|
|
284
337
|
return True
|
|
285
338
|
except ImportError:
|
|
286
339
|
continue
|
|
287
|
-
|
|
340
|
+
# 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
|
|
341
|
+
return capable is False
|
|
288
342
|
|
|
289
343
|
|
|
290
344
|
if _ensure_cozeloop_sdk():
|
|
@@ -640,6 +694,23 @@ def _ts_ms(dt):
|
|
|
640
694
|
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
641
695
|
|
|
642
696
|
|
|
697
|
+
def _set_finish_time_safe(span, dt):
|
|
698
|
+
"""安全设置 span 结束时间。
|
|
699
|
+
|
|
700
|
+
set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
|
|
701
|
+
让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
|
|
702
|
+
"""
|
|
703
|
+
if dt is None:
|
|
704
|
+
return
|
|
705
|
+
fn = getattr(span, "set_finish_time", None)
|
|
706
|
+
if fn is None:
|
|
707
|
+
return
|
|
708
|
+
try:
|
|
709
|
+
fn(dt)
|
|
710
|
+
except Exception:
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
|
|
643
714
|
def _max_dt(*values):
|
|
644
715
|
result = None
|
|
645
716
|
for value in values:
|
|
@@ -923,10 +994,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
923
994
|
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
924
995
|
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
925
996
|
hook_log(f"workspace_id={workspace_id}")
|
|
997
|
+
upload_events: List[str] = []
|
|
926
998
|
client_kwargs = {
|
|
927
999
|
"ultra_large_report": True,
|
|
928
1000
|
"upload_timeout": 120,
|
|
929
|
-
"trace_finish_event_processor": _make_finish_event_processor(),
|
|
1001
|
+
"trace_finish_event_processor": _make_finish_event_processor(upload_events),
|
|
930
1002
|
}
|
|
931
1003
|
api_base_url = get_api_base_url()
|
|
932
1004
|
if api_base_url:
|
|
@@ -955,7 +1027,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
955
1027
|
"source": "codex_cli",
|
|
956
1028
|
}
|
|
957
1029
|
if root_start_dt is not None and root_end_dt is not None:
|
|
958
|
-
root_span
|
|
1030
|
+
_set_finish_time_safe(root_span, root_end_dt)
|
|
959
1031
|
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
960
1032
|
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
961
1033
|
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
@@ -1008,7 +1080,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1008
1080
|
"source": "codex_cli",
|
|
1009
1081
|
}
|
|
1010
1082
|
if turn_start_dt is not None and turn_end_dt is not None:
|
|
1011
|
-
turn_span
|
|
1083
|
+
_set_finish_time_safe(turn_span, turn_end_dt)
|
|
1012
1084
|
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
1013
1085
|
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
1014
1086
|
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
@@ -1026,7 +1098,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1026
1098
|
model_span.set_runtime(Runtime(library="codex-cli"))
|
|
1027
1099
|
model_span.set_model_name(model_name)
|
|
1028
1100
|
if _model_start_dt is not None and _model_end_dt is not None:
|
|
1029
|
-
model_span
|
|
1101
|
+
_set_finish_time_safe(model_span, _model_end_dt)
|
|
1030
1102
|
model_span.set_tags({
|
|
1031
1103
|
"real_start_ms": _ts_ms(_model_start_dt),
|
|
1032
1104
|
"real_end_ms": _ts_ms(_model_end_dt),
|
|
@@ -1114,7 +1186,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1114
1186
|
"call_id": call_id,
|
|
1115
1187
|
}
|
|
1116
1188
|
if tool_start_dt is not None and tool_finish_dt is not None:
|
|
1117
|
-
tool_span
|
|
1189
|
+
_set_finish_time_safe(tool_span, tool_finish_dt)
|
|
1118
1190
|
tool_tags["real_start_ms"] = _ts_ms(tool_start_dt)
|
|
1119
1191
|
tool_tags["real_end_ms"] = _ts_ms(tool_finish_dt)
|
|
1120
1192
|
tool_tags["latency_ms"] = _ts_ms(tool_finish_dt) - _ts_ms(tool_start_dt)
|
|
@@ -1148,7 +1220,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1148
1220
|
"agent_model": sc.get("model") or "",
|
|
1149
1221
|
}
|
|
1150
1222
|
if subagent_start_dt is not None and subagent_finish_dt is not None:
|
|
1151
|
-
subagent_span
|
|
1223
|
+
_set_finish_time_safe(subagent_span, subagent_finish_dt)
|
|
1152
1224
|
subagent_tags["real_start_ms"] = _ts_ms(subagent_start_dt)
|
|
1153
1225
|
subagent_tags["real_end_ms"] = _ts_ms(subagent_finish_dt)
|
|
1154
1226
|
subagent_tags["latency_ms"] = _ts_ms(subagent_finish_dt) - _ts_ms(subagent_start_dt)
|
|
@@ -1177,7 +1249,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1177
1249
|
"agent_name": nickname,
|
|
1178
1250
|
}
|
|
1179
1251
|
if sa_turn_start_dt is not None and sa_turn_finish_dt is not None:
|
|
1180
|
-
sa_turn_span
|
|
1252
|
+
_set_finish_time_safe(sa_turn_span, sa_turn_finish_dt)
|
|
1181
1253
|
sa_turn_tags["real_start_ms"] = _ts_ms(sa_turn_start_dt)
|
|
1182
1254
|
sa_turn_tags["real_end_ms"] = _ts_ms(sa_turn_finish_dt)
|
|
1183
1255
|
sa_turn_tags["latency_ms"] = _ts_ms(sa_turn_finish_dt) - _ts_ms(sa_turn_start_dt)
|
|
@@ -1196,7 +1268,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1196
1268
|
sa_model_span.set_model_name(sa_model)
|
|
1197
1269
|
sa_model_tags = {"agent_name": nickname}
|
|
1198
1270
|
if sa_model_start_dt is not None and sa_model_finish_dt is not None:
|
|
1199
|
-
sa_model_span
|
|
1271
|
+
_set_finish_time_safe(sa_model_span, sa_model_finish_dt)
|
|
1200
1272
|
sa_model_tags["real_start_ms"] = _ts_ms(sa_model_start_dt)
|
|
1201
1273
|
sa_model_tags["real_end_ms"] = _ts_ms(sa_model_finish_dt)
|
|
1202
1274
|
sa_model_tags["latency_ms"] = _ts_ms(sa_model_finish_dt) - _ts_ms(sa_model_start_dt)
|
|
@@ -1260,7 +1332,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1260
1332
|
"agent_name": nickname,
|
|
1261
1333
|
}
|
|
1262
1334
|
if sa_tool_start_dt is not None and sa_tool_finish_dt is not None:
|
|
1263
|
-
sa_tool_span
|
|
1335
|
+
_set_finish_time_safe(sa_tool_span, sa_tool_finish_dt)
|
|
1264
1336
|
sa_tool_tags["real_start_ms"] = _ts_ms(sa_tool_start_dt)
|
|
1265
1337
|
sa_tool_tags["real_end_ms"] = _ts_ms(sa_tool_finish_dt)
|
|
1266
1338
|
sa_tool_tags["latency_ms"] = _ts_ms(sa_tool_finish_dt) - _ts_ms(sa_tool_start_dt)
|
|
@@ -1309,6 +1381,10 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1309
1381
|
hook_log("client closed")
|
|
1310
1382
|
debug_log("CozeLoop client closed.")
|
|
1311
1383
|
|
|
1384
|
+
if upload_events:
|
|
1385
|
+
hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
|
|
1386
|
+
return None
|
|
1387
|
+
|
|
1312
1388
|
return ctx
|
|
1313
1389
|
|
|
1314
1390
|
|
|
@@ -44,10 +44,12 @@ async function _refreshToken(refreshTok) {
|
|
|
44
44
|
try {
|
|
45
45
|
const d = JSON.parse(buf);
|
|
46
46
|
if (d.access_token) {
|
|
47
|
+
const existing = _loadCreds() ?? {};
|
|
47
48
|
const creds = {
|
|
48
49
|
access_token: d.access_token,
|
|
49
50
|
refresh_token: d.refresh_token ?? refreshTok,
|
|
50
51
|
expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
|
|
52
|
+
workspace_id: existing.workspace_id ?? "",
|
|
51
53
|
};
|
|
52
54
|
_saveCreds(creds);
|
|
53
55
|
resolve(creds.access_token);
|
|
@@ -62,7 +64,8 @@ async function _refreshToken(refreshTok) {
|
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
async function getRefreshedToken(currentAuthorization) {
|
|
67
|
+
async function getRefreshedToken(currentAuthorization, opts = {}) {
|
|
68
|
+
if (opts.disableLocalCredentials) return currentAuthorization;
|
|
66
69
|
const creds = _loadCreds();
|
|
67
70
|
if (!creds) return currentAuthorization; // no creds file, keep as-is
|
|
68
71
|
const remaining = (creds.expires_at ?? 0) - Date.now();
|
|
@@ -71,7 +74,7 @@ async function getRefreshedToken(currentAuthorization) {
|
|
|
71
74
|
const newToken = await _refreshToken(creds.refresh_token);
|
|
72
75
|
if (newToken) return `Bearer ${newToken}`;
|
|
73
76
|
}
|
|
74
|
-
return
|
|
77
|
+
return null;
|
|
75
78
|
}
|
|
76
79
|
// ─────────────────────────────────────────────────────────────────────────
|
|
77
80
|
|
|
@@ -131,7 +134,9 @@ export class CozeloopExporter {
|
|
|
131
134
|
return attrs;
|
|
132
135
|
}
|
|
133
136
|
async refreshAuthIfNeeded() {
|
|
134
|
-
const fresh = await getRefreshedToken(this.config.authorization
|
|
137
|
+
const fresh = await getRefreshedToken(this.config.authorization, {
|
|
138
|
+
disableLocalCredentials: this.config.disableLocalCredentials,
|
|
139
|
+
});
|
|
135
140
|
if (fresh && fresh !== this.config.authorization) {
|
|
136
141
|
this.api.logger.info("[CozeloopTrace] Token refreshed, re-initializing exporter...");
|
|
137
142
|
this.config.authorization = fresh;
|
|
@@ -143,6 +148,9 @@ export class CozeloopExporter {
|
|
|
143
148
|
this.provider = null;
|
|
144
149
|
this.tracer = null;
|
|
145
150
|
}
|
|
151
|
+
} else if (fresh === null) {
|
|
152
|
+
this.api.logger.error("[CozeloopTrace] Local credentials exist but token refresh failed; refusing to reuse stale authorization.");
|
|
153
|
+
this.config.authorization = "";
|
|
146
154
|
}
|
|
147
155
|
}
|
|
148
156
|
async ensureInitialized() {
|
|
@@ -513,6 +513,7 @@ const cozeloopTracePlugin = {
|
|
|
513
513
|
batchSize: pluginConfig.batchSize || 10,
|
|
514
514
|
batchInterval: pluginConfig.batchInterval || 5000,
|
|
515
515
|
enabledHooks: pluginConfig.enabledHooks,
|
|
516
|
+
disableLocalCredentials: pluginConfig.disableLocalCredentials === true,
|
|
516
517
|
};
|
|
517
518
|
const exporter = new CozeloopExporter(api, config);
|
|
518
519
|
// per-agent trace 放行:traceAgentIds 为 onboard 写入的 allowlist(小写归一)。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cozeloop-trace",
|
|
3
3
|
"name": "OpenClaw CozeLoop Trace",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.13",
|
|
5
5
|
"description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
|
|
6
6
|
"type": "plugin",
|
|
7
7
|
"entry": "./dist/index.js",
|
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
"type": "string"
|
|
50
50
|
},
|
|
51
51
|
"description": "List of hooks to enable (if not set, all hooks are enabled)"
|
|
52
|
+
},
|
|
53
|
+
"disableLocalCredentials": {
|
|
54
|
+
"type": "boolean",
|
|
55
|
+
"default": false,
|
|
56
|
+
"description": "Disable ~/.cozeloop credentials refresh and use configured authorization only"
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
}
|