coze_lab 0.1.43 → 0.1.45
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 +48 -54
- package/index.js +135 -414
- package/package.json +1 -2
- package/scripts/claude-code/cozeloop_hook.py +6 -239
- package/scripts/codex/cozeloop_hook.py +7 -366
- package/scripts/openclaw/dist/cozeloop-exporter.js +13 -182
- package/scripts/openclaw/dist/index.js +2 -1
- package/scripts/openclaw/openclaw.plugin.json +2 -2
- package/scripts/openclaw/package.json +1 -1
- package/scripts/shared/cozeloop_refresh.py +0 -57
|
@@ -35,360 +35,22 @@ import os
|
|
|
35
35
|
import sys
|
|
36
36
|
import hashlib
|
|
37
37
|
import time
|
|
38
|
-
import urllib.request
|
|
39
|
-
import urllib.error
|
|
40
38
|
from datetime import datetime
|
|
41
39
|
from pathlib import Path
|
|
42
40
|
from typing import Optional, List, Dict, Any
|
|
43
41
|
|
|
44
|
-
# --- Token
|
|
45
|
-
_COZELOOP_CLIENT_ID = "08972682140163281554629748278108.app.coze"
|
|
46
|
-
_COZE_API = "https://api.coze.cn"
|
|
47
|
-
_REFRESH_THRESHOLD = 10 * 60
|
|
48
|
-
_FORCE_REFRESH_COOLDOWN_MS = 60 * 1000
|
|
49
|
-
_REFRESH_LOCK_STALE = 30
|
|
50
|
-
_DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
|
|
51
|
-
_OTEL_SUFFIX = "/v1/loop/opentelemetry"
|
|
42
|
+
# --- Token resolution -----------------------------------------------------
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
REALTIME_BATCH_INTERVAL = float(os.environ.get("COZELOOP_REALTIME_BATCH_INTERVAL", "5"))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# --- coze-context parsing -------------------------------------------------
|
|
59
|
-
# User messages may embed a block like:
|
|
60
|
-
# <coze-context>
|
|
61
|
-
# account_id: 0
|
|
62
|
-
# agent_id: 7644920552473395499
|
|
63
|
-
# session_id: 7644919579054997796
|
|
64
|
-
# message_id: 04dd5246-...
|
|
65
|
-
# </coze-context>
|
|
66
|
-
# We parse its key:value pairs and inject them into the trace.
|
|
67
|
-
|
|
68
|
-
_COZE_CTX_OPEN = "<coze-context>"
|
|
69
|
-
_COZE_CTX_CLOSE = "</coze-context>"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def parse_coze_context(text: str) -> Dict[str, str]:
|
|
73
|
-
"""Extract the LAST <coze-context> block's key:value pairs from text.
|
|
74
|
-
|
|
75
|
-
Returns {} if no block is present. Tag keys are prefixed with
|
|
76
|
-
'coze_' by the caller; here we return raw keys as written.
|
|
77
|
-
"""
|
|
78
|
-
if not text or _COZE_CTX_OPEN not in text:
|
|
79
|
-
return {}
|
|
80
|
-
# Take the last occurrence (latest context wins).
|
|
81
|
-
open_idx = text.rfind(_COZE_CTX_OPEN)
|
|
82
|
-
close_idx = text.find(_COZE_CTX_CLOSE, open_idx)
|
|
83
|
-
if close_idx == -1:
|
|
84
|
-
return {}
|
|
85
|
-
body = text[open_idx + len(_COZE_CTX_OPEN):close_idx]
|
|
86
|
-
# The block may arrive with real newlines, OR with literal backslash-n
|
|
87
|
-
# (e.g. when the whole message is an embedded JSON string that was never
|
|
88
|
-
# un-escaped). Normalize both forms before splitting into lines.
|
|
89
|
-
body = body.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
|
|
90
|
-
result: Dict[str, str] = {}
|
|
91
|
-
for line in body.splitlines():
|
|
92
|
-
line = line.strip()
|
|
93
|
-
if not line or ":" not in line:
|
|
94
|
-
continue
|
|
95
|
-
key, _, value = line.partition(":")
|
|
96
|
-
key = key.strip()
|
|
97
|
-
value = value.strip()
|
|
98
|
-
if key:
|
|
99
|
-
result[key] = value
|
|
100
|
-
return result
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def coze_context_tags(text: str) -> Dict[str, str]:
|
|
104
|
-
"""Return coze-context kv as trace tags, prefixed with 'coze_'."""
|
|
105
|
-
return {f"coze_{k}": v for k, v in parse_coze_context(text).items()}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def turn_coze_context(turn: Dict[str, Any]) -> Dict[str, str]:
|
|
109
|
-
"""Extract coze-context from a grouped turn with fallbacks for Codex rollout shapes."""
|
|
110
|
-
texts = [turn.get("user_message_text", "")]
|
|
111
|
-
for msg in turn.get("input_messages", []):
|
|
112
|
-
if isinstance(msg, dict):
|
|
113
|
-
texts.append(str(msg.get("content", "")))
|
|
114
|
-
user_payload = turn.get("user_message")
|
|
115
|
-
if isinstance(user_payload, dict):
|
|
116
|
-
texts.append(extract_message_content_text(user_payload))
|
|
117
|
-
for text in texts:
|
|
118
|
-
ctx = parse_coze_context(text)
|
|
119
|
-
if ctx:
|
|
120
|
-
return ctx
|
|
121
|
-
return {}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# --- trace upload failure / logid capture ---------------------------------
|
|
125
|
-
def _extract_logid(msg: str) -> str:
|
|
126
|
-
"""Pull the server logid out of an SDK error message, if present.
|
|
127
|
-
|
|
128
|
-
SDK failure messages embed it as 'logid=XXXX' (sometimes within brackets).
|
|
129
|
-
"""
|
|
130
|
-
if not msg:
|
|
131
|
-
return ""
|
|
132
|
-
marker = "logid="
|
|
133
|
-
idx = msg.find(marker)
|
|
134
|
-
if idx == -1:
|
|
135
|
-
return ""
|
|
136
|
-
rest = msg[idx + len(marker):]
|
|
137
|
-
logid = []
|
|
138
|
-
for ch in rest:
|
|
139
|
-
if ch.isalnum():
|
|
140
|
-
logid.append(ch)
|
|
141
|
-
else:
|
|
142
|
-
break
|
|
143
|
-
return "".join(logid)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
|
|
147
|
-
"""Return a trace_finish_event_processor that surfaces failures + logid.
|
|
148
|
-
|
|
149
|
-
The CozeLoop SDK calls this for each flush event; on failure we print the
|
|
150
|
-
server logid to stderr so it can be handed to platform support for tracing
|
|
151
|
-
the root cause (e.g. via `bytedcli log get-logid-log <logid>`).
|
|
152
|
-
"""
|
|
153
|
-
def _processor(info):
|
|
154
|
-
try:
|
|
155
|
-
if not getattr(info, "is_event_fail", False):
|
|
156
|
-
hook_log("upload success")
|
|
157
|
-
return
|
|
158
|
-
detail = getattr(info, "detail_msg", "") or ""
|
|
159
|
-
if upload_events is not None:
|
|
160
|
-
upload_events.append(detail or "trace export failed")
|
|
161
|
-
logid = _extract_logid(detail)
|
|
162
|
-
if logid:
|
|
163
|
-
hook_log(f"upload failed logid={logid} detail={detail[:500]}")
|
|
164
|
-
print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
|
|
165
|
-
else:
|
|
166
|
-
hook_log(f"upload failed detail={detail[:500]}")
|
|
167
|
-
print(f"[CozeLoop] 上报失败: {detail[:300]}", file=sys.stderr)
|
|
168
|
-
except Exception:
|
|
169
|
-
pass
|
|
170
|
-
return _processor
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _get_credentials_path() -> Path:
|
|
175
|
-
return Path.home() / ".cozeloop" / "credentials.json"
|
|
176
|
-
|
|
177
|
-
def _get_refresh_state_path() -> Path:
|
|
178
|
-
return Path.home() / ".cozeloop" / "refresh-state.json"
|
|
179
|
-
|
|
180
|
-
def _get_refresh_lock_path() -> Path:
|
|
181
|
-
return Path.home() / ".cozeloop" / "refresh-state.lock"
|
|
182
|
-
|
|
183
|
-
def _load_credentials():
|
|
184
|
-
path = _get_credentials_path()
|
|
185
|
-
if not path.exists():
|
|
186
|
-
return None
|
|
187
|
-
try:
|
|
188
|
-
return json.loads(path.read_text())
|
|
189
|
-
except Exception:
|
|
190
|
-
return None
|
|
191
|
-
|
|
192
|
-
def _save_credentials(creds):
|
|
193
|
-
path = _get_credentials_path()
|
|
194
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
-
path.write_text(json.dumps(creds, indent=2))
|
|
196
|
-
os.chmod(path, 0o600)
|
|
197
|
-
|
|
198
|
-
def _load_refresh_state():
|
|
199
|
-
path = _get_refresh_state_path()
|
|
200
|
-
if not path.exists():
|
|
201
|
-
return {}
|
|
202
|
-
try:
|
|
203
|
-
return json.loads(path.read_text())
|
|
204
|
-
except Exception:
|
|
205
|
-
return {}
|
|
206
|
-
|
|
207
|
-
def _save_refresh_state(patch):
|
|
208
|
-
path = _get_refresh_state_path()
|
|
209
|
-
try:
|
|
210
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
-
state = _load_refresh_state()
|
|
212
|
-
state.update(patch)
|
|
213
|
-
path.write_text(json.dumps(state, indent=2))
|
|
214
|
-
os.chmod(path, 0o600)
|
|
215
|
-
except Exception:
|
|
216
|
-
pass
|
|
217
|
-
|
|
218
|
-
def _with_refresh_lock(fn):
|
|
219
|
-
lock_path = _get_refresh_lock_path()
|
|
220
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
-
for _ in range(24):
|
|
222
|
-
fd = None
|
|
223
|
-
try:
|
|
224
|
-
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
225
|
-
try:
|
|
226
|
-
return fn()
|
|
227
|
-
finally:
|
|
228
|
-
try:
|
|
229
|
-
os.close(fd)
|
|
230
|
-
except Exception:
|
|
231
|
-
pass
|
|
232
|
-
try:
|
|
233
|
-
lock_path.unlink()
|
|
234
|
-
except Exception:
|
|
235
|
-
pass
|
|
236
|
-
except FileExistsError:
|
|
237
|
-
try:
|
|
238
|
-
if time.time() - lock_path.stat().st_mtime > _REFRESH_LOCK_STALE:
|
|
239
|
-
lock_path.unlink()
|
|
240
|
-
continue
|
|
241
|
-
except Exception:
|
|
242
|
-
pass
|
|
243
|
-
time.sleep(0.25)
|
|
244
|
-
hook_log("forced refresh skipped because refresh lock is busy")
|
|
245
|
-
return None
|
|
246
|
-
|
|
247
|
-
def _refresh_token(refresh_tok: str):
|
|
248
|
-
try:
|
|
249
|
-
payload = json.dumps({
|
|
250
|
-
"grant_type": "refresh_token",
|
|
251
|
-
"client_id": _COZELOOP_CLIENT_ID,
|
|
252
|
-
"refresh_token": refresh_tok,
|
|
253
|
-
}).encode()
|
|
254
|
-
req = urllib.request.Request(
|
|
255
|
-
f"{_COZE_API}/api/permission/oauth2/token",
|
|
256
|
-
data=payload,
|
|
257
|
-
headers={
|
|
258
|
-
"Content-Type": "application/json",
|
|
259
|
-
},
|
|
260
|
-
)
|
|
261
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
262
|
-
data = json.loads(resp.read())
|
|
263
|
-
if data.get("access_token"):
|
|
264
|
-
existing = _load_credentials() or {}
|
|
265
|
-
creds = {
|
|
266
|
-
"access_token": data["access_token"],
|
|
267
|
-
"refresh_token": data.get("refresh_token", refresh_tok),
|
|
268
|
-
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
269
|
-
"workspace_id": existing.get("workspace_id", ""),
|
|
270
|
-
}
|
|
271
|
-
_save_credentials(creds)
|
|
272
|
-
return creds["access_token"]
|
|
273
|
-
except Exception:
|
|
274
|
-
pass
|
|
275
|
-
return None
|
|
276
|
-
|
|
277
|
-
def force_refresh_token_after_upload_failure(reason: str = "upload_failure", current_token: Optional[str] = None):
|
|
278
|
-
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
279
|
-
if is_cloud:
|
|
280
|
-
hook_log("upload failure token refresh skipped in cloud mode")
|
|
281
|
-
return None
|
|
282
|
-
|
|
283
|
-
creds = _load_credentials()
|
|
284
|
-
if not creds or not creds.get("refresh_token"):
|
|
285
|
-
hook_log("upload failure token refresh skipped; no local refresh_token")
|
|
286
|
-
return None
|
|
287
|
-
|
|
288
|
-
cached = creds.get("access_token")
|
|
289
|
-
now_ms = int(time.time() * 1000)
|
|
290
|
-
last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
|
|
291
|
-
if last_ms and now_ms - last_ms < _FORCE_REFRESH_COOLDOWN_MS:
|
|
292
|
-
if cached and cached != current_token:
|
|
293
|
-
hook_log("forced refresh throttled; reuse newer cached token")
|
|
294
|
-
return cached
|
|
295
|
-
hook_log(f"forced refresh throttled ageMs={now_ms - last_ms}")
|
|
296
|
-
return None
|
|
297
|
-
|
|
298
|
-
def _locked_refresh():
|
|
299
|
-
locked_creds = _load_credentials()
|
|
300
|
-
if not locked_creds or not locked_creds.get("refresh_token"):
|
|
301
|
-
hook_log("forced refresh skipped in lock; credentials missing")
|
|
302
|
-
return None
|
|
303
|
-
locked_cached = locked_creds.get("access_token")
|
|
304
|
-
locked_now_ms = int(time.time() * 1000)
|
|
305
|
-
locked_last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
|
|
306
|
-
if locked_last_ms and locked_now_ms - locked_last_ms < _FORCE_REFRESH_COOLDOWN_MS:
|
|
307
|
-
if locked_cached and locked_cached != current_token:
|
|
308
|
-
hook_log("forced refresh already done by another process; reuse token")
|
|
309
|
-
return locked_cached
|
|
310
|
-
hook_log(f"forced refresh skipped in lock ageMs={locked_now_ms - locked_last_ms}")
|
|
311
|
-
return None
|
|
312
|
-
_save_refresh_state({
|
|
313
|
-
"last_forced_refresh_ms": locked_now_ms,
|
|
314
|
-
"last_reason": (reason or "upload_failure")[:120],
|
|
315
|
-
})
|
|
316
|
-
hook_log(f"forced token refresh after upload failure reason={(reason or 'upload_failure')[:120]}")
|
|
317
|
-
refreshed = _refresh_token(locked_creds["refresh_token"])
|
|
318
|
-
if refreshed:
|
|
319
|
-
hook_log("forced token refresh OK")
|
|
320
|
-
else:
|
|
321
|
-
hook_log("forced token refresh FAILED")
|
|
322
|
-
return refreshed
|
|
323
|
-
|
|
324
|
-
return _with_refresh_lock(_locked_refresh)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
def _normalize_api_base_url(url: str) -> str:
|
|
328
|
-
base = (url or "").strip().rstrip("/")
|
|
329
|
-
if not base:
|
|
330
|
-
return base
|
|
331
|
-
# 剥除已知 loop 路径后缀,还原纯 API base。
|
|
332
|
-
# 关键:含 /api 的变体必须连 /api 整体剥掉,否则残留 /api 会拼出 /api/v1/loop/... → 后端 400。
|
|
333
|
-
# 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免误匹配短后缀。
|
|
334
|
-
suffixes = (
|
|
335
|
-
"/api/v1/loop/opentelemetry/v1/traces",
|
|
336
|
-
_OTEL_SUFFIX + "/v1/traces",
|
|
337
|
-
"/api/v1/loop/opentelemetry",
|
|
338
|
-
_OTEL_SUFFIX,
|
|
339
|
-
"/api/v1",
|
|
340
|
-
"/v1",
|
|
341
|
-
)
|
|
342
|
-
for suffix in suffixes:
|
|
343
|
-
if base.endswith(suffix):
|
|
344
|
-
return base[:-len(suffix)].rstrip("/")
|
|
345
|
-
return base
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
def get_api_base_url() -> str:
|
|
349
|
-
return _normalize_api_base_url(
|
|
350
|
-
os.environ.get("COZELOOP_API_BASE_URL", "")
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _token_from_credentials():
|
|
355
|
-
creds = _load_credentials()
|
|
356
|
-
if not creds:
|
|
357
|
-
return None
|
|
358
|
-
# expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 _REFRESH_THRESHOLD(秒) 比较。
|
|
359
|
-
remaining = (creds.get("expires_at", 0) - time.time() * 1000) / 1000
|
|
360
|
-
if remaining > _REFRESH_THRESHOLD:
|
|
361
|
-
return creds.get("access_token")
|
|
362
|
-
if creds.get("refresh_token"):
|
|
363
|
-
new_token = _refresh_token(creds["refresh_token"])
|
|
364
|
-
if new_token:
|
|
365
|
-
return new_token
|
|
366
|
-
return None
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def get_fresh_token():
|
|
44
|
+
def get_fresh_token() -> Optional[str]:
|
|
45
|
+
"""Return the token injected by onboard. Do not read local credentials."""
|
|
370
46
|
env_token = os.environ.get("COZELOOP_API_TOKEN")
|
|
371
47
|
env_coze_token = os.environ.get("COZE_API_TOKEN")
|
|
372
|
-
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
373
|
-
if is_cloud:
|
|
374
|
-
return env_token or env_coze_token or _token_from_credentials()
|
|
375
|
-
|
|
376
|
-
# Local onboard used to write a short-lived access token into cozeloop.env.
|
|
377
|
-
# If credentials exist but cannot be refreshed, do not fall back to that stale
|
|
378
|
-
# env token; failing closed keeps state from advancing on a known-bad token.
|
|
379
|
-
creds = _load_credentials()
|
|
380
|
-
if creds:
|
|
381
|
-
return _token_from_credentials()
|
|
382
|
-
if env_token and not env_coze_token:
|
|
383
|
-
token = _token_from_credentials()
|
|
384
|
-
if token:
|
|
385
|
-
return token
|
|
386
|
-
return env_token
|
|
387
48
|
if env_token:
|
|
388
49
|
return env_token
|
|
389
|
-
if
|
|
50
|
+
if os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes"):
|
|
390
51
|
return env_coze_token
|
|
391
|
-
return
|
|
52
|
+
return None
|
|
53
|
+
|
|
392
54
|
# -------------------------------------------------------------------------
|
|
393
55
|
|
|
394
56
|
# --- SDK Import ---
|
|
@@ -1089,7 +751,6 @@ def _make_model_message(role: str, content: str = "", tool_calls: list = None,
|
|
|
1089
751
|
|
|
1090
752
|
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
|
|
1091
753
|
history_context: Optional[List[Dict[str, Any]]] = None,
|
|
1092
|
-
retry_on_auth_failure: bool = True,
|
|
1093
754
|
coze_tags_override: Optional[Dict[str, str]] = None) -> Optional[List[Dict[str, Any]]]:
|
|
1094
755
|
"""Send conversation turns to CozeLoop for tracing.
|
|
1095
756
|
|
|
@@ -1119,14 +780,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1119
780
|
f"api_base_url={bool(get_api_base_url())}"
|
|
1120
781
|
)
|
|
1121
782
|
print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
|
|
1122
|
-
|
|
1123
|
-
# 云端模式:sandbox 注入的 COZELOOP_WORKSPACE_ID 必须优先于 credentials.json。
|
|
1124
|
-
# 否则残留的本地 credentials.json workspace_id 会覆盖云端注入,导致 trace 上报错位。
|
|
1125
|
-
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
1126
|
-
if is_cloud:
|
|
1127
|
-
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or (creds or {}).get("workspace_id") or _DEFAULT_WORKSPACE_ID
|
|
1128
|
-
else:
|
|
1129
|
-
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
783
|
+
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
1130
784
|
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
1131
785
|
hook_log(f"workspace_id={workspace_id}")
|
|
1132
786
|
upload_events: List[str] = []
|
|
@@ -1548,19 +1202,6 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1548
1202
|
|
|
1549
1203
|
if upload_events:
|
|
1550
1204
|
hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
|
|
1551
|
-
if retry_on_auth_failure:
|
|
1552
|
-
new_token = force_refresh_token_after_upload_failure(upload_events[-1], token)
|
|
1553
|
-
if new_token:
|
|
1554
|
-
os.environ["COZELOOP_API_TOKEN"] = new_token
|
|
1555
|
-
hook_log("retry upload once after forced token refresh")
|
|
1556
|
-
return send_turns_to_cozeloop(
|
|
1557
|
-
turns,
|
|
1558
|
-
session_id,
|
|
1559
|
-
model_name,
|
|
1560
|
-
history_context,
|
|
1561
|
-
retry_on_auth_failure=False,
|
|
1562
|
-
coze_tags_override=coze_tags_override,
|
|
1563
|
-
)
|
|
1564
1205
|
return None
|
|
1565
1206
|
|
|
1566
1207
|
return ctx
|
|
@@ -3,10 +3,9 @@ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trac
|
|
|
3
3
|
import { Resource } from "@opentelemetry/resources";
|
|
4
4
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/semantic-conventions";
|
|
5
5
|
import { hostname } from "os";
|
|
6
|
-
import { basename
|
|
6
|
+
import { basename } from "path";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
|
-
import {
|
|
9
|
-
import { homedir } from "os";
|
|
8
|
+
import { appendFileSync } from "fs";
|
|
10
9
|
import http from "http";
|
|
11
10
|
import https from "https";
|
|
12
11
|
|
|
@@ -21,183 +20,14 @@ const CLIENT_USER_AGENT = {
|
|
|
21
20
|
source: "openapi",
|
|
22
21
|
};
|
|
23
22
|
|
|
24
|
-
// ── Token
|
|
25
|
-
const _CLIENT_ID = "08972682140163281554629748278108.app.coze";
|
|
23
|
+
// ── Token helpers ──────────────────────────────────────────────────────────
|
|
26
24
|
const _COZE_API = "https://api.coze.cn";
|
|
27
|
-
const _REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
|
|
28
|
-
const _FORCE_REFRESH_COOLDOWN_MS = 60 * 1000;
|
|
29
|
-
const _REFRESH_LOCK_STALE_MS = 30 * 1000;
|
|
30
|
-
const _CREDS_PATH = join(homedir(), ".cozeloop", "credentials.json");
|
|
31
|
-
const _REFRESH_STATE_PATH = join(homedir(), ".cozeloop", "refresh-state.json");
|
|
32
|
-
const _REFRESH_LOCK_PATH = join(homedir(), ".cozeloop", "refresh-state.lock");
|
|
33
|
-
|
|
34
|
-
function _loadCreds() {
|
|
35
|
-
try { return JSON.parse(readFileSync(_CREDS_PATH, "utf8")); }
|
|
36
|
-
catch { return null; }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function _saveCreds(c) {
|
|
40
|
-
try {
|
|
41
|
-
mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
|
|
42
|
-
writeFileSync(_CREDS_PATH, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
43
|
-
} catch { /* non-fatal */ }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function _loadRefreshState() {
|
|
47
|
-
try { return JSON.parse(readFileSync(_REFRESH_STATE_PATH, "utf8")); }
|
|
48
|
-
catch { return {}; }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function _saveRefreshState(patch) {
|
|
52
|
-
try {
|
|
53
|
-
mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
|
|
54
|
-
const state = { ..._loadRefreshState(), ...patch };
|
|
55
|
-
writeFileSync(_REFRESH_STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
56
|
-
} catch { /* non-fatal */ }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function _authorizationFromCreds(creds) {
|
|
60
|
-
return creds?.access_token ? `Bearer ${creds.access_token}` : null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function _sleep(ms) {
|
|
64
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function _withRefreshLock(logFile, fn) {
|
|
68
|
-
mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
|
|
69
|
-
for (let i = 0; i < 24; i++) {
|
|
70
|
-
let fd = null;
|
|
71
|
-
try {
|
|
72
|
-
fd = openSync(_REFRESH_LOCK_PATH, "wx", 0o600);
|
|
73
|
-
try {
|
|
74
|
-
return await fn();
|
|
75
|
-
}
|
|
76
|
-
finally {
|
|
77
|
-
try { if (fd !== null) closeSync(fd); } catch { /* ignore */ }
|
|
78
|
-
try { unlinkSync(_REFRESH_LOCK_PATH); } catch { /* ignore */ }
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
if (err?.code !== "EEXIST") {
|
|
83
|
-
fileLog(logFile, `[auth] refresh lock error=${err?.message || err}`);
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
try {
|
|
87
|
-
const ageMs = Date.now() - statSync(_REFRESH_LOCK_PATH).mtimeMs;
|
|
88
|
-
if (ageMs > _REFRESH_LOCK_STALE_MS) {
|
|
89
|
-
unlinkSync(_REFRESH_LOCK_PATH);
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
} catch { /* ignore */ }
|
|
93
|
-
await _sleep(250);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
fileLog(logFile, "[auth] refresh lock busy; skip forced refresh");
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function _refreshToken(refreshTok) {
|
|
101
|
-
return new Promise((resolve) => {
|
|
102
|
-
const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
|
|
103
|
-
const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
|
|
104
|
-
method: "POST",
|
|
105
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
106
|
-
}, (res) => {
|
|
107
|
-
let buf = "";
|
|
108
|
-
res.on("data", c => buf += c);
|
|
109
|
-
res.on("end", () => {
|
|
110
|
-
try {
|
|
111
|
-
const d = JSON.parse(buf);
|
|
112
|
-
if (d.access_token) {
|
|
113
|
-
const existing = _loadCreds() ?? {};
|
|
114
|
-
const creds = {
|
|
115
|
-
access_token: d.access_token,
|
|
116
|
-
refresh_token: d.refresh_token ?? refreshTok,
|
|
117
|
-
expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
|
|
118
|
-
workspace_id: existing.workspace_id ?? "",
|
|
119
|
-
};
|
|
120
|
-
_saveCreds(creds);
|
|
121
|
-
resolve(creds.access_token);
|
|
122
|
-
} else { resolve(null); }
|
|
123
|
-
} catch { resolve(null); }
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
req.on("error", () => resolve(null));
|
|
127
|
-
req.setTimeout(10000, () => { req.destroy(); resolve(null); });
|
|
128
|
-
req.write(body);
|
|
129
|
-
req.end();
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function _forceRefreshToken(currentAuthorization, opts = {}) {
|
|
134
|
-
const logFile = opts.logFile;
|
|
135
|
-
const creds = _loadCreds();
|
|
136
|
-
if (!creds?.refresh_token) {
|
|
137
|
-
fileLog(logFile, "[auth] upload failed but no local refresh_token is available");
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const cachedAuth = _authorizationFromCreds(creds);
|
|
142
|
-
const now = Date.now();
|
|
143
|
-
const state = _loadRefreshState();
|
|
144
|
-
const lastForced = Number(state.last_forced_refresh_ms || 0);
|
|
145
|
-
if (lastForced && now - lastForced < _FORCE_REFRESH_COOLDOWN_MS) {
|
|
146
|
-
if (cachedAuth && cachedAuth !== currentAuthorization) {
|
|
147
|
-
fileLog(logFile, `[auth] forced refresh throttled; reuse newer cached token tokenLength=${cachedAuth.length}`);
|
|
148
|
-
return cachedAuth;
|
|
149
|
-
}
|
|
150
|
-
fileLog(logFile, `[auth] forced refresh throttled ageMs=${now - lastForced}`);
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return _withRefreshLock(logFile, async () => {
|
|
155
|
-
const lockedCreds = _loadCreds();
|
|
156
|
-
if (!lockedCreds?.refresh_token) {
|
|
157
|
-
fileLog(logFile, "[auth] forced refresh skipped; credentials disappeared");
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
const lockedCachedAuth = _authorizationFromCreds(lockedCreds);
|
|
161
|
-
const lockedState = _loadRefreshState();
|
|
162
|
-
const lockedLastForced = Number(lockedState.last_forced_refresh_ms || 0);
|
|
163
|
-
const lockedNow = Date.now();
|
|
164
|
-
if (lockedLastForced && lockedNow - lockedLastForced < _FORCE_REFRESH_COOLDOWN_MS) {
|
|
165
|
-
if (lockedCachedAuth && lockedCachedAuth !== currentAuthorization) {
|
|
166
|
-
fileLog(logFile, `[auth] forced refresh already done by another process; reuse tokenLength=${lockedCachedAuth.length}`);
|
|
167
|
-
return lockedCachedAuth;
|
|
168
|
-
}
|
|
169
|
-
fileLog(logFile, `[auth] forced refresh skipped in lock ageMs=${lockedNow - lockedLastForced}`);
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
_saveRefreshState({
|
|
174
|
-
last_forced_refresh_ms: lockedNow,
|
|
175
|
-
last_reason: String(opts.reason || "upload_failure").slice(0, 120),
|
|
176
|
-
});
|
|
177
|
-
fileLog(logFile, `[auth] forced token refresh after local upload failure reason=${String(opts.reason || "upload_failure").slice(0, 120)}`);
|
|
178
|
-
const newToken = await _refreshToken(lockedCreds.refresh_token);
|
|
179
|
-
if (!newToken) {
|
|
180
|
-
fileLog(logFile, "[auth] forced token refresh FAILED");
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
const freshAuth = `Bearer ${newToken}`;
|
|
184
|
-
fileLog(logFile, `[auth] forced token refresh OK tokenLength=${freshAuth.length}`);
|
|
185
|
-
return freshAuth;
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
25
|
|
|
189
26
|
async function getRefreshedToken(currentAuthorization, opts = {}) {
|
|
190
|
-
if (opts.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const remaining = (creds.expires_at ?? 0) - Date.now();
|
|
195
|
-
if (remaining > _REFRESH_THRESHOLD_MS) return `Bearer ${creds.access_token}`;
|
|
196
|
-
if (creds.refresh_token) {
|
|
197
|
-
const newToken = await _refreshToken(creds.refresh_token);
|
|
198
|
-
if (newToken) return `Bearer ${newToken}`;
|
|
199
|
-
}
|
|
200
|
-
return null;
|
|
27
|
+
if (opts.force) {
|
|
28
|
+
fileLog(opts.logFile, "[auth] token refresh disabled; keep configured authorization");
|
|
29
|
+
}
|
|
30
|
+
return currentAuthorization;
|
|
201
31
|
}
|
|
202
32
|
// ─────────────────────────────────────────────────────────────────────────
|
|
203
33
|
|
|
@@ -444,19 +274,19 @@ class CozeloopIngestExporter {
|
|
|
444
274
|
throw err;
|
|
445
275
|
}
|
|
446
276
|
this.headers = { ...this.headers, Authorization: freshAuth };
|
|
447
|
-
fileLog(this.logFile, `[ingest] retry after
|
|
277
|
+
fileLog(this.logFile, `[ingest] retry after authorization update url=${this.url} spans=${body.spans.length}`);
|
|
448
278
|
try {
|
|
449
279
|
await this.postBody(body, true);
|
|
450
280
|
}
|
|
451
281
|
catch (retryErr) {
|
|
452
|
-
throw new Error(`retry after
|
|
282
|
+
throw new Error(`retry after authorization update failed: ${retryErr?.message || retryErr}`);
|
|
453
283
|
}
|
|
454
284
|
}
|
|
455
285
|
}
|
|
456
286
|
async refreshAuthorizationAfterFailure(err) {
|
|
457
287
|
if (typeof this.onAuthFailure !== "function")
|
|
458
288
|
return null;
|
|
459
|
-
fileLog(this.logFile, `[auth]
|
|
289
|
+
fileLog(this.logFile, `[auth] upload failure; token refresh disabled err=${String(err?.message || err).slice(0, 300)}`);
|
|
460
290
|
try {
|
|
461
291
|
const current = this.headers?.Authorization || "";
|
|
462
292
|
const fresh = await this.onAuthFailure(current, err);
|
|
@@ -565,6 +395,7 @@ export class CozeloopExporter {
|
|
|
565
395
|
async refreshAuthIfNeeded() {
|
|
566
396
|
const fresh = await getRefreshedToken(this.config.authorization, {
|
|
567
397
|
disableLocalCredentials: this.config.disableLocalCredentials,
|
|
398
|
+
tokenSource: this.config.tokenSource,
|
|
568
399
|
});
|
|
569
400
|
if (fresh && fresh !== this.config.authorization) {
|
|
570
401
|
this.api.logger.info("[CozeloopTrace] Token refreshed, re-initializing exporter...");
|
|
@@ -578,13 +409,13 @@ export class CozeloopExporter {
|
|
|
578
409
|
this.tracer = null;
|
|
579
410
|
}
|
|
580
411
|
} else if (fresh === null) {
|
|
581
|
-
this.api.logger.
|
|
582
|
-
this.config.authorization = "";
|
|
412
|
+
this.api.logger.info("[CozeloopTrace] Token refresh disabled; keeping configured authorization.");
|
|
583
413
|
}
|
|
584
414
|
}
|
|
585
415
|
async refreshAuthAfterUploadFailure(currentAuthorization, err) {
|
|
586
416
|
const fresh = await getRefreshedToken(currentAuthorization || this.config.authorization, {
|
|
587
417
|
disableLocalCredentials: this.config.disableLocalCredentials,
|
|
418
|
+
tokenSource: this.config.tokenSource,
|
|
588
419
|
force: true,
|
|
589
420
|
reason: err?.message || "upload_failure",
|
|
590
421
|
logFile: this.config.logFile,
|
|
@@ -540,6 +540,7 @@ const cozeloopTracePlugin = {
|
|
|
540
540
|
batchInterval: pluginConfig.batchInterval || 500,
|
|
541
541
|
enabledHooks: pluginConfig.enabledHooks,
|
|
542
542
|
disableLocalCredentials: pluginConfig.disableLocalCredentials === true,
|
|
543
|
+
tokenSource: pluginConfig.tokenSource,
|
|
543
544
|
logFile: pluginConfig.logFile,
|
|
544
545
|
};
|
|
545
546
|
const exporter = new CozeloopExporter(api, config);
|
|
@@ -1011,7 +1012,7 @@ const cozeloopTracePlugin = {
|
|
|
1011
1012
|
}
|
|
1012
1013
|
if (shouldHookEnabled("session_start")) {
|
|
1013
1014
|
on("session_start", async (event, hookCtx) => {
|
|
1014
|
-
//
|
|
1015
|
+
// No local refresh fallback; exporter keeps configured authorization.
|
|
1015
1016
|
await exporter.refreshAuthIfNeeded();
|
|
1016
1017
|
const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
|
|
1017
1018
|
if (config.debug) {
|