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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.26",
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
- def _ensure_cozeloop_sdk():
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
- pass
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
- ["--quiet", "--disable-pip-version-check", "cozeloop"],
43
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "cozeloop"],
44
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "--user", "cozeloop"],
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 auto-installed at runtime.", file=sys.stderr)
87
+ print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
64
88
  return True
65
89
  except ImportError:
66
90
  continue
67
- return False
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={"Content-Type": "application/json"},
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. Falls back to env var."""
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
- creds = _load_credentials()
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.set_finish_time(root_end_dt)
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.set_finish_time(turn_end_dt)
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.set_finish_time(step_end_dt)
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.set_finish_time(tool_end_dt)
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={"Content-Type": "application/json"},
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
- creds = _load_credentials()
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
- def _ensure_cozeloop_sdk():
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
- pass
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
- ["--quiet", "--disable-pip-version-check", "cozeloop"],
263
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "cozeloop"],
264
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "--user", "cozeloop"],
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 auto-installed at runtime.", file=sys.stderr)
336
+ print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
284
337
  return True
285
338
  except ImportError:
286
339
  continue
287
- return False
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.set_finish_time(root_end_dt)
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.set_finish_time(turn_end_dt)
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.set_finish_time(_model_end_dt)
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.set_finish_time(tool_finish_dt)
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.set_finish_time(subagent_finish_dt)
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.set_finish_time(sa_turn_finish_dt)
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.set_finish_time(sa_model_finish_dt)
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.set_finish_time(sa_tool_finish_dt)
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 currentAuthorization; // fallback
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.12",
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",