coze_lab 0.1.33 → 0.1.35

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 CHANGED
@@ -5172,6 +5172,7 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
5172
5172
  const hooksDir = path.join(os.homedir(), '.claude', 'hooks');
5173
5173
  const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
5174
5174
  const baseDir = configBaseDir || process.cwd();
5175
+ const logFile = path.join(baseDir, '.claude', 'hooks', 'cozeloop.log');
5175
5176
  // per-agent 时 settings.json 写进 agent 的 .claude;全局时仍写 ~/.claude/settings.json。
5176
5177
  const claudeDir = configBaseDir
5177
5178
  ? path.join(baseDir, '.claude')
@@ -5218,9 +5219,11 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
5218
5219
 
5219
5220
  // 3. Write credentials into <baseDir>/.claude/settings.local.json
5220
5221
  ensureDir(path.join(baseDir, '.claude'));
5222
+ ensureDir(path.dirname(logFile));
5221
5223
  const localSettings = mergeJson(localSettingsPath, (existing) => {
5222
5224
  if (!existing.env) existing.env = {};
5223
5225
  existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
5226
+ existing.env.COZELOOP_HOOK_LOG = logFile;
5224
5227
  if (cloud) {
5225
5228
  const loopToken = readEnv('COZELOOP_API_TOKEN');
5226
5229
  const cozeToken = readEnv('COZE_API_TOKEN');
@@ -5259,7 +5262,7 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
5259
5262
  ok(`Credentials written to ${localSettingsPath}`);
5260
5263
 
5261
5264
 
5262
- return { hookScript, settingsPath, localSettingsPath };
5265
+ return { hookScript, settingsPath, localSettingsPath, logFile };
5263
5266
  }
5264
5267
 
5265
5268
  // writeCodexHook 把 hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
@@ -5386,33 +5389,32 @@ function normalizeTraceAgentIds(ids) {
5386
5389
  }
5387
5390
 
5388
5391
  function getCloudCozeloopApiBaseUrl() {
5389
- const raw = process.env.COZELOOP_API_BASE_URL || process.env.OTEL_ENDPOINT || '';
5392
+ const raw = process.env.COZELOOP_API_BASE_URL || '';
5390
5393
  return normalizeCozeloopApiBaseUrl(raw);
5391
5394
  }
5392
5395
 
5393
5396
  function normalizeCozeloopApiBaseUrl(raw) {
5394
- const base = raw.trim().replace(/\/+$/, '');
5397
+ const base = (raw || '').trim().replace(/\/+$/, '');
5395
5398
  if (!base) return '';
5396
- if (base.endsWith('/v1/loop/traces/ingest')) {
5397
- return base.slice(0, -'/v1/loop/traces/ingest'.length).replace(/\/+$/, '');
5398
- }
5399
- if (base.endsWith('/api/v1/loop/traces/ingest')) {
5400
- return base.slice(0, -'/v1/loop/traces/ingest'.length).replace(/\/+$/, '');
5401
- }
5402
- if (base.endsWith('/v1/loop/opentelemetry/v1/traces')) {
5403
- return base.slice(0, -'/v1/loop/opentelemetry/v1/traces'.length).replace(/\/+$/, '');
5404
- }
5405
- if (base.endsWith('/api/v1/loop/opentelemetry/v1/traces')) {
5406
- return base.slice(0, -'/v1/loop/opentelemetry/v1/traces'.length).replace(/\/+$/, '');
5407
- }
5408
- if (base.endsWith('/v1/loop/opentelemetry')) {
5409
- return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
5410
- }
5411
- if (base.endsWith('/api/v1/loop/opentelemetry')) {
5412
- return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
5413
- }
5414
- if (base.endsWith('/api/v1')) {
5415
- return base.slice(0, -'/v1'.length).replace(/\/+$/, '');
5399
+ // 剥除已知 loop 路径后缀,还原出纯 API base。
5400
+ // 关键:cozeloop ingest 正确端点是 <host>/v1/loop/traces/ingest,【不带 /api 前缀】。
5401
+ // 含 /api 的变体必须连 /api 一起剥掉 —— 否则残留 /api 会拼出 /api/v1/loop/traces/ingest,
5402
+ // 命中不处理 ingest 协议的网关入口 → 后端 400 "unknown event type"。
5403
+ // 顺序约定:更长 //api 的变体在前,最短的裸 /v1 在最后,避免 endsWith 误匹配短后缀。
5404
+ const suffixes = [
5405
+ '/api/v1/loop/traces/ingest',
5406
+ '/v1/loop/traces/ingest',
5407
+ '/api/v1/loop/opentelemetry/v1/traces',
5408
+ '/v1/loop/opentelemetry/v1/traces',
5409
+ '/api/v1/loop/opentelemetry',
5410
+ '/v1/loop/opentelemetry',
5411
+ '/api/v1',
5412
+ '/v1',
5413
+ ];
5414
+ for (const suffix of suffixes) {
5415
+ if (base.endsWith(suffix)) {
5416
+ return base.slice(0, -suffix.length).replace(/\/+$/, '');
5417
+ }
5416
5418
  }
5417
5419
  return base;
5418
5420
  }
@@ -5445,7 +5447,41 @@ function getCozeloopIngestUrl(cloud) {
5445
5447
  return getCozeloopIngestUrlFromBase(getCozeloopApiBaseUrl(cloud));
5446
5448
  }
5447
5449
 
5448
- function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud) {
5450
+ function buildOpenClawSelfcheckTags(pcfg, workspaceId, pair) {
5451
+ const tags = {
5452
+ pair_code: pair,
5453
+ source: 'cozelab-onboard-openclaw',
5454
+ };
5455
+ const allowlist = normalizeTraceAgentIds(pcfg?.traceAgentIds);
5456
+ if (allowlist[0]) {
5457
+ tags.coze_agent_id = allowlist[0];
5458
+ }
5459
+ const sessionId = readEnv('COZELOOP_SELF_CHECK_SESSION_ID')
5460
+ || readEnv('COZE_SESSION_ID')
5461
+ || readEnv('COZELOOP_SESSION_ID');
5462
+ if (sessionId) {
5463
+ tags.coze_session_id = sessionId;
5464
+ }
5465
+ const messageId = readEnv('COZELOOP_SELF_CHECK_MESSAGE_ID')
5466
+ || readEnv('COZE_MESSAGE_ID')
5467
+ || readEnv('COZELOOP_MESSAGE_ID')
5468
+ || `cozelab-onboard-${pair}`;
5469
+ tags.coze_message_id = messageId;
5470
+ const groupId = readEnv('COZE_GROUP_ID') || readEnv('COZELOOP_GROUP_ID');
5471
+ if (groupId) {
5472
+ tags.coze_group_id = groupId;
5473
+ }
5474
+ const accountId = readEnv('COZE_ACCOUNT_ID') || readEnv('COZELOOP_ACCOUNT_ID');
5475
+ if (accountId) {
5476
+ tags.coze_account_id = accountId;
5477
+ }
5478
+ if (workspaceId) {
5479
+ tags.coze_workspace_id = String(workspaceId);
5480
+ }
5481
+ return tags;
5482
+ }
5483
+
5484
+ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud, logFile) {
5449
5485
  if (!existing.plugins) existing.plugins = {};
5450
5486
  if (!existing.plugins.allow) existing.plugins.allow = [];
5451
5487
  if (!existing.plugins.entries) existing.plugins.entries = {};
@@ -5469,6 +5505,9 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
5469
5505
  pcfg.endpoint = getCozeloopApiBaseUrl(cloud);
5470
5506
  pcfg.workspaceId = workspaceId;
5471
5507
  pcfg.debug = true;
5508
+ if (logFile) {
5509
+ pcfg.logFile = logFile;
5510
+ }
5472
5511
  pcfg.disableLocalCredentials = !!cloud;
5473
5512
  // 插件代码版本:参与幂等比对,升级插件(bump scripts/openclaw/package.json version)后
5474
5513
  // 强制触发重写+重装+重启,避免云端 pluginDir 滞留旧插件 dist。
@@ -5487,7 +5526,7 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
5487
5526
  return existing;
5488
5527
  }
5489
5528
 
5490
- function isOpenClawAlreadyInjected(configPath, pluginDir, token, workspaceId, agentId, cloud) {
5529
+ function isOpenClawAlreadyInjected(configPath, pluginDir, token, workspaceId, agentId, cloud, logFile) {
5491
5530
  if (!fs.existsSync(pluginDir)) return false;
5492
5531
  let existing;
5493
5532
  try {
@@ -5501,14 +5540,16 @@ function isOpenClawAlreadyInjected(configPath, pluginDir, token, workspaceId, ag
5501
5540
  workspaceId,
5502
5541
  agentId,
5503
5542
  cloud,
5543
+ logFile,
5504
5544
  );
5505
5545
  return JSON.stringify(existing) === JSON.stringify(desired);
5506
5546
  }
5507
5547
 
5508
- function writeOpenClawHook(token, workspaceId, agentId, cloud) {
5548
+ function writeOpenClawHook(token, workspaceId, agentId, cloud, force) {
5509
5549
  const home = resolveHomeDir(cloud);
5510
5550
  const configPath = path.join(home, '.openclaw', 'openclaw.json');
5511
5551
  const pluginDir = path.join(home, '.cozeloop', 'openclaw-plugin');
5552
+ const logFile = path.join(pluginDir, 'cozeloop.log');
5512
5553
 
5513
5554
  if (!fs.existsSync(configPath)) {
5514
5555
  errorBox([
@@ -5519,10 +5560,13 @@ function writeOpenClawHook(token, workspaceId, agentId, cloud) {
5519
5560
  ]);
5520
5561
  }
5521
5562
 
5522
- if (isOpenClawAlreadyInjected(configPath, pluginDir, token, workspaceId, agentId, cloud)) {
5563
+ if (!force && isOpenClawAlreadyInjected(configPath, pluginDir, token, workspaceId, agentId, cloud, logFile)) {
5523
5564
  ok(`OpenClaw plugin already configured in ${configPath}`);
5524
5565
  info('OpenClaw gateway restart skipped (configuration unchanged).');
5525
- return { configPath, pluginDir, unchanged: true, gatewayRestarted: false, gatewayRestartSkipped: true };
5566
+ return { configPath, pluginDir, logFile, unchanged: true, gatewayRestarted: false, gatewayRestartSkipped: true };
5567
+ }
5568
+ if (force) {
5569
+ info('OpenClaw force mode enabled; rewriting plugin files and reinstalling.');
5526
5570
  }
5527
5571
 
5528
5572
  // 1. Write plugin files to ~/.cozeloop/openclaw-plugin/
@@ -5575,7 +5619,7 @@ function writeOpenClawHook(token, workspaceId, agentId, cloud) {
5575
5619
 
5576
5620
  // 4. Update openclaw.json with token and workspace
5577
5621
  const config = mergeJson(configPath, (existing) => {
5578
- return applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud);
5622
+ return applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud, logFile);
5579
5623
  });
5580
5624
 
5581
5625
  try {
@@ -5591,10 +5635,10 @@ function writeOpenClawHook(token, workspaceId, agentId, cloud) {
5591
5635
  info('Restarting OpenClaw gateway to apply hook changes...');
5592
5636
  execSync('openclaw gateway restart', { stdio: 'pipe' });
5593
5637
  ok('OpenClaw gateway restarted');
5594
- return { configPath, pluginDir, gatewayRestarted: true };
5638
+ return { configPath, pluginDir, logFile, gatewayRestarted: true };
5595
5639
  } catch (e) {
5596
5640
  warn(`gateway restart 失败,请手动执行: openclaw gateway restart(${e.message})`);
5597
- return { configPath, pluginDir, gatewayRestarted: false, gatewayRestartError: e.message };
5641
+ return { configPath, pluginDir, logFile, gatewayRestarted: false, gatewayRestartError: e.message };
5598
5642
  }
5599
5643
  }
5600
5644
 
@@ -5749,6 +5793,8 @@ except Exception as e:
5749
5793
  x_use_ppe: PPE_USE_PPE,
5750
5794
  };
5751
5795
  if (apiBase) env.COZELOOP_API_BASE_URL = apiBase;
5796
+ // 打出 SDK 实际使用的 ingest base,便于核对云端注入的 api_base_url 是否正确(无 /api 残留)。
5797
+ info(`trace 上报 URL: ${getCozeloopIngestUrlFromBase(apiBase || COZE_API)} (api_base_url=${apiBase || '(default)'})`);
5752
5798
  const result = await runCommand({
5753
5799
  cmd: pythonCmd || 'python3',
5754
5800
  args: ['-c', script],
@@ -5812,6 +5858,7 @@ async function verifyTraceReport(token, workspaceId, pairCode, tracesUrl) {
5812
5858
 
5813
5859
  let res;
5814
5860
  const url = tracesUrl || getOtelTracesUrl(false);
5861
+ info(`trace 上报 URL: ${url}`);
5815
5862
  try {
5816
5863
  res = await httpsPost(
5817
5864
  url,
@@ -5867,6 +5914,8 @@ async function verifyOpenClawTraceLink(cloud) {
5867
5914
  // 1) 用插件【实际配置的】token 打一条最小 CozeLoop ingest trace ——这才是运行时真实用的那个 token。
5868
5915
  const authHeader = pcfg.authorization; // 形如 "Bearer czu_xxx"
5869
5916
  const tracesUrl = getCozeloopIngestUrlFromBase(normalizeCozeloopApiBaseUrl(pcfg.endpoint || getCozeloopApiBaseUrl(cloud)));
5917
+ // 打出实际 ingest URL —— 云端只能看 stdout,借此立即发现 URL 是否仍残留 /api 前缀(400 根因)。
5918
+ info(`openclaw ingest URL: ${tracesUrl} (plugin endpoint=${pcfg.endpoint || '(default)'})`);
5870
5919
  const workspaceId = pcfg.workspaceId || WORKSPACE_ID;
5871
5920
  const traceId = crypto.randomBytes(16).toString('hex');
5872
5921
  const spanId = crypto.randomBytes(8).toString('hex');
@@ -5880,7 +5929,7 @@ async function verifyOpenClawTraceLink(cloud) {
5880
5929
  parent_id: '0',
5881
5930
  trace_id: traceId,
5882
5931
  duration_micros: 1,
5883
- service_name: 'cozelab-onboard-openclaw',
5932
+ service_name: '',
5884
5933
  workspace_id: String(workspaceId),
5885
5934
  span_name: 'cozelab-onboard-openclaw-selfcheck',
5886
5935
  span_type: 'main',
@@ -5889,14 +5938,16 @@ async function verifyOpenClawTraceLink(cloud) {
5889
5938
  output: 'ok',
5890
5939
  object_storage: '',
5891
5940
  system_tags_string: {
5892
- runtime: JSON.stringify({ language: 'nodejs', library: 'openclaw', scene: process.env.COZELOOP_SCENE || 'custom' }),
5941
+ runtime: JSON.stringify({
5942
+ language: 'nodejs',
5943
+ library: 'openclaw',
5944
+ scene: process.env.COZELOOP_SCENE || 'custom',
5945
+ loop_sdk_version: `coze_lab@${PACKAGE_VERSION}`,
5946
+ }),
5893
5947
  },
5894
5948
  system_tags_long: {},
5895
5949
  system_tags_double: {},
5896
- tags_string: {
5897
- pair_code: pair,
5898
- source: 'cozelab-onboard-openclaw',
5899
- },
5950
+ tags_string: buildOpenClawSelfcheckTags(pcfg, workspaceId, pair),
5900
5951
  tags_long: {},
5901
5952
  tags_double: {},
5902
5953
  tags_bool: {},
@@ -5907,7 +5958,18 @@ async function verifyOpenClawTraceLink(cloud) {
5907
5958
  res = await httpsPost(
5908
5959
  tracesUrl,
5909
5960
  ingestBody,
5910
- { Authorization: authHeader },
5961
+ {
5962
+ Authorization: authHeader,
5963
+ 'User-Agent': `coze_lab/${PACKAGE_VERSION} node/${process.versions.node}`,
5964
+ 'X-Coze-Client-User-Agent': JSON.stringify({
5965
+ version: PACKAGE_VERSION,
5966
+ lang: 'nodejs',
5967
+ lang_version: process.versions.node,
5968
+ os_name: process.platform,
5969
+ scene: 'cozeloop',
5970
+ source: 'openapi',
5971
+ }),
5972
+ },
5911
5973
  );
5912
5974
  } catch (e) {
5913
5975
  warn(`openclaw 插件 token 上报探测失败: ${e.message}`);
@@ -6424,7 +6486,7 @@ async function main() {
6424
6486
  written = writeCodexHook(token, WORKSPACE_ID, pythonCmd, codexHome, args.cloud);
6425
6487
  } else {
6426
6488
  // openclaw:云端用 traceAgentIds allowlist 做 per-agent 放行。
6427
- written = writeOpenClawHook(token, WORKSPACE_ID, args.agentId, args.cloud) || {};
6489
+ written = writeOpenClawHook(token, WORKSPACE_ID, args.agentId, args.cloud, args.force) || {};
6428
6490
  }
6429
6491
  // 走到这里说明 detectAgent / 环境检查 / 写 hook 配置全部成功 → 注入成功。
6430
6492
  cloudResult.inject = 'ok';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -212,16 +212,41 @@ def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
212
212
  logid = _extract_logid(detail)
213
213
  if logid:
214
214
  print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
215
+ hook_log(f"upload FAILED logid={logid} detail={detail[:300]}")
215
216
  else:
216
217
  print(f"[CozeLoop] 上报失败: {detail[:300]}", file=sys.stderr)
218
+ hook_log(f"upload FAILED detail={detail[:300]}")
217
219
  except Exception:
218
220
  pass
219
221
  return _processor
220
222
 
221
223
 
222
224
 
225
+ def _log_file_path() -> str:
226
+ return os.environ.get("COZELOOP_HOOK_LOG", "").strip()
227
+
228
+
229
+ def hook_log(message: str):
230
+ """Append one diagnostic line to the hook log, if configured.
231
+
232
+ onboard writes COZELOOP_HOOK_LOG into settings.local.json (env), so this
233
+ captures runtime upload diagnostics even when stderr is not visible.
234
+ """
235
+ log_path = _log_file_path()
236
+ if not log_path:
237
+ return
238
+ try:
239
+ p = Path(log_path).expanduser()
240
+ p.parent.mkdir(parents=True, exist_ok=True)
241
+ with p.open("a", encoding="utf-8") as f:
242
+ f.write(f"{datetime.now().isoformat()} {message}\n")
243
+ except Exception:
244
+ pass
245
+
246
+
223
247
  def debug_log(message: str):
224
- """Print debug message if debug mode is enabled."""
248
+ """Print debug message if debug mode is enabled; always append to hook log."""
249
+ hook_log(f"DEBUG {message}")
225
250
  if DEBUG:
226
251
  print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
227
252
 
@@ -281,16 +306,22 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
281
306
 
282
307
  def _normalize_api_base_url(url: str) -> str:
283
308
  base = (url or "").strip().rstrip("/")
284
- if base.endswith(_OTEL_SUFFIX + "/v1/traces"):
285
- return base[:-len(_OTEL_SUFFIX + "/v1/traces")].rstrip("/")
286
- if base.endswith("/api/v1/loop/opentelemetry/v1/traces"):
287
- return base[:-len("/v1/loop/opentelemetry/v1/traces")].rstrip("/")
288
- if base.endswith(_OTEL_SUFFIX):
289
- return base[:-len(_OTEL_SUFFIX)].rstrip("/")
290
- if base.endswith("/api/v1/loop/opentelemetry"):
291
- return base[:-len("/v1/loop/opentelemetry")].rstrip("/")
292
- if base.endswith("/api/v1"):
293
- return base[:-len("/v1")].rstrip("/")
309
+ if not base:
310
+ return base
311
+ # 剥除已知 loop 路径后缀,还原纯 API base
312
+ # 关键:含 /api 的变体必须连 /api 整体剥掉,否则残留 /api 会拼出 /api/v1/loop/... → 后端 400。
313
+ # 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免误匹配短后缀。
314
+ suffixes = (
315
+ "/api/v1/loop/opentelemetry/v1/traces",
316
+ _OTEL_SUFFIX + "/v1/traces",
317
+ "/api/v1/loop/opentelemetry",
318
+ _OTEL_SUFFIX,
319
+ "/api/v1",
320
+ "/v1",
321
+ )
322
+ for suffix in suffixes:
323
+ if base.endswith(suffix):
324
+ return base[:-len(suffix)].rstrip("/")
294
325
  return base
295
326
 
296
327
  def get_api_base_url() -> str:
@@ -970,6 +1001,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
970
1001
  api_base_url = get_api_base_url()
971
1002
  if api_base_url:
972
1003
  client_kwargs["api_base_url"] = api_base_url
1004
+ hook_log(
1005
+ f"init client session={session_id} workspace={workspace_id} "
1006
+ f"token={(token[:12] + '...') if token else 'none'} "
1007
+ f"api_base_url={api_base_url or '(default)'}"
1008
+ )
973
1009
  client = cozeloop.new_client(**client_kwargs)
974
1010
 
975
1011
  try:
@@ -218,16 +218,22 @@ def _refresh_token(refresh_tok: str):
218
218
 
219
219
  def _normalize_api_base_url(url: str) -> str:
220
220
  base = (url or "").strip().rstrip("/")
221
- if base.endswith(_OTEL_SUFFIX + "/v1/traces"):
222
- return base[:-len(_OTEL_SUFFIX + "/v1/traces")].rstrip("/")
223
- if base.endswith("/api/v1/loop/opentelemetry/v1/traces"):
224
- return base[:-len("/v1/loop/opentelemetry/v1/traces")].rstrip("/")
225
- if base.endswith(_OTEL_SUFFIX):
226
- return base[:-len(_OTEL_SUFFIX)].rstrip("/")
227
- if base.endswith("/api/v1/loop/opentelemetry"):
228
- return base[:-len("/v1/loop/opentelemetry")].rstrip("/")
229
- if base.endswith("/api/v1"):
230
- return base[:-len("/v1")].rstrip("/")
221
+ if not base:
222
+ return base
223
+ # 剥除已知 loop 路径后缀,还原纯 API base
224
+ # 关键:含 /api 的变体必须连 /api 整体剥掉,否则残留 /api 会拼出 /api/v1/loop/... → 后端 400。
225
+ # 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免误匹配短后缀。
226
+ suffixes = (
227
+ "/api/v1/loop/opentelemetry/v1/traces",
228
+ _OTEL_SUFFIX + "/v1/traces",
229
+ "/api/v1/loop/opentelemetry",
230
+ _OTEL_SUFFIX,
231
+ "/api/v1",
232
+ "/v1",
233
+ )
234
+ for suffix in suffixes:
235
+ if base.endswith(suffix):
236
+ return base[:-len(suffix)].rstrip("/")
231
237
  return base
232
238
 
233
239
 
@@ -5,13 +5,21 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/sema
5
5
  import { hostname } from "os";
6
6
  import { basename, join } from "path";
7
7
  import { createRequire } from "node:module";
8
- import { readFileSync, writeFileSync, mkdirSync } from "fs";
8
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
9
9
  import { homedir } from "os";
10
10
  import http from "http";
11
11
  import https from "https";
12
12
 
13
13
  const require = createRequire(import.meta.url);
14
14
  const { version: PLUGIN_VERSION } = require("../package.json");
15
+ const CLIENT_USER_AGENT = {
16
+ version: PLUGIN_VERSION,
17
+ lang: "nodejs",
18
+ lang_version: process.versions.node,
19
+ os_name: process.platform,
20
+ scene: "cozeloop",
21
+ source: "openapi",
22
+ };
15
23
 
16
24
  // ── Token refresh helpers ─────────────────────────────────────────────────
17
25
  const _CLIENT_ID = "46371084383473718052118955183420.app.coze";
@@ -82,24 +90,41 @@ const EXPORT_SUCCESS = 0;
82
90
  const EXPORT_FAILED = 1;
83
91
  const INGEST_TRACE_PATH = "/v1/loop/traces/ingest";
84
92
 
93
+ // 把一行诊断写入 onboard 配置的 logFile(pluginConfig.logFile)。云端 gateway 日志常拿不到
94
+ // (无 DBUS / 无 stdout 落盘),这个文件是排查上报失败的唯一依据。无 logFile 或写失败都静默。
95
+ function fileLog(logFile, message) {
96
+ if (!logFile)
97
+ return;
98
+ try {
99
+ appendFileSync(logFile, `${new Date().toISOString()} ${message}\n`);
100
+ }
101
+ catch {
102
+ /* best-effort, never throw from logging */
103
+ }
104
+ }
105
+
85
106
  function normalizeApiBaseUrl(endpoint) {
86
107
  const base = String(endpoint || "").replace(/\/+$/, "");
87
108
  if (!base)
88
109
  return _COZE_API;
110
+ // 剥除已知 loop 路径后缀,还原纯 API base。
111
+ // 关键:cozeloop ingest 正确端点 <host>/v1/loop/traces/ingest,【不带 /api 前缀】。
112
+ // 含 /api 的变体必须连 /api 整体剥掉 —— 否则残留 /api → /api/v1/loop/traces/ingest → 后端 400。
113
+ // 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免 endsWith 误匹配短后缀。
89
114
  const suffixes = [
90
- "/v1/loop/traces/ingest",
91
115
  "/api/v1/loop/traces/ingest",
92
- "/v1/loop/opentelemetry/v1/traces",
116
+ "/v1/loop/traces/ingest",
93
117
  "/api/v1/loop/opentelemetry/v1/traces",
94
- "/v1/loop/opentelemetry",
118
+ "/v1/loop/opentelemetry/v1/traces",
95
119
  "/api/v1/loop/opentelemetry",
120
+ "/v1/loop/opentelemetry",
96
121
  "/v1/traces",
97
122
  "/api/v1",
123
+ "/v1",
98
124
  ];
99
125
  for (const suffix of suffixes) {
100
126
  if (base.endsWith(suffix)) {
101
- const trimSuffix = suffix.startsWith("/api/") ? suffix.slice("/api".length) : suffix;
102
- return base.slice(0, -trimSuffix.length).replace(/\/+$/, "");
127
+ return base.slice(0, -suffix.length).replace(/\/+$/, "");
103
128
  }
104
129
  }
105
130
  return base;
@@ -195,7 +220,7 @@ function spanToUploadSpan(span, config) {
195
220
  const attrs = span.attributes || {};
196
221
  const { tags, systemTags } = splitAttributes(attrs);
197
222
  const resourceAttrs = span.resource?.attributes || {};
198
- const serviceName = resourceAttrs["service.name"] || config.serviceName || "openclaw-agent";
223
+ const serviceName = resourceAttrs["service.name"] || config.serviceName || "";
199
224
  const spanType = mapSpanType(attrs["cozeloop.span_type"]);
200
225
  const input = attrs["cozeloop.input"] !== undefined ? safeStringify(attrs["cozeloop.input"]) : "";
201
226
  const output = attrs["cozeloop.output"] !== undefined ? safeStringify(attrs["cozeloop.output"]) : "";
@@ -204,7 +229,6 @@ function spanToUploadSpan(span, config) {
204
229
  return {
205
230
  started_at_micros: hrTimeToUnixMicros(span.startTime),
206
231
  log_id: "",
207
- traceId: spanContext.traceId,
208
232
  trace_id: spanContext.traceId,
209
233
  span_id: spanContext.spanId,
210
234
  parent_id: span.parentSpanId || "0",
@@ -260,9 +284,11 @@ class CozeloopIngestExporter {
260
284
  this.url = normalizeIngestUrl(config.endpoint || config.url);
261
285
  this.headers = config.headers || {};
262
286
  this.logger = config.logger;
287
+ this.logFile = config.logFile;
263
288
  this.workspaceId = config.workspaceId;
264
289
  this.serviceName = config.serviceName;
265
290
  this.shutdownRequested = false;
291
+ fileLog(this.logFile, `[ingest] exporter ready url=${this.url} workspaceId=${this.workspaceId}`);
266
292
  }
267
293
  export(spans, resultCallback) {
268
294
  if (!spans || spans.length === 0 || this.shutdownRequested) {
@@ -273,6 +299,7 @@ class CozeloopIngestExporter {
273
299
  .then(() => resultCallback({ code: EXPORT_SUCCESS }))
274
300
  .catch((err) => {
275
301
  this.logger?.error?.(`[CozeloopTrace] CozeLoop ingest export failed: ${err?.message || err}`);
302
+ fileLog(this.logFile, `[ingest] export FAILED url=${this.url} spans=${spans.length} err=${err?.message || err}`);
276
303
  resultCallback({ code: EXPORT_FAILED, error: err });
277
304
  });
278
305
  }
@@ -283,15 +310,18 @@ class CozeloopIngestExporter {
283
310
  serviceName: this.serviceName,
284
311
  })),
285
312
  };
313
+ fileLog(this.logFile, `[ingest] POST url=${this.url} spans=${body.spans.length}`);
286
314
  const res = await postJson(this.url, body, this.headers);
287
315
  if (res.status < 200 || res.status >= 300) {
288
316
  const snippet = String(res.body || "").slice(0, 300);
317
+ fileLog(this.logFile, `[ingest] HTTP ${res.status} url=${this.url}${snippet ? ` body=${snippet}` : ""}`);
289
318
  throw new Error(`HTTP ${res.status}${snippet ? `: ${snippet}` : ""}`);
290
319
  }
291
320
  if (res.body) {
292
321
  try {
293
322
  const parsed = JSON.parse(res.body);
294
323
  if (parsed && parsed.code !== undefined && parsed.code !== 0) {
324
+ fileLog(this.logFile, `[ingest] biz-error code=${parsed.code} msg=${parsed.msg || ""} url=${this.url}`);
295
325
  throw new Error(`code ${parsed.code}: ${parsed.msg || res.body.slice(0, 300)}`);
296
326
  }
297
327
  }
@@ -301,6 +331,7 @@ class CozeloopIngestExporter {
301
331
  }
302
332
  }
303
333
  }
334
+ fileLog(this.logFile, `[ingest] OK HTTP ${res.status} spans=${body.spans.length}`);
304
335
  }
305
336
  async forceFlush() {
306
337
  return;
@@ -405,13 +436,17 @@ export class CozeloopExporter {
405
436
  const authorization = this.config.authorization;
406
437
  const workspaceId = this.config.workspaceId;
407
438
  this.api.logger.info(`[CozeloopTrace] Using authorization, workspaceId=${workspaceId}, tokenLength=${authorization?.length}`);
439
+ fileLog(this.config.logFile, `[init] ingestUrl=${normalizeIngestUrl(this.config.endpoint)} workspaceId=${workspaceId} tokenLength=${authorization?.length || 0} plugin=${PLUGIN_VERSION}`);
408
440
  const exporter = new CozeloopIngestExporter({
409
441
  endpoint: this.config.endpoint,
410
442
  logger: this.api.logger,
443
+ logFile: this.config.logFile,
411
444
  workspaceId,
412
445
  serviceName: this.config.serviceName,
413
446
  headers: {
414
447
  "Authorization": authorization,
448
+ "User-Agent": `openclaw-cozeloop-trace/${PLUGIN_VERSION} node/${process.versions.node}`,
449
+ "X-Coze-Client-User-Agent": JSON.stringify(CLIENT_USER_AGENT),
415
450
  "x-tt-env": "ppe_cozelab",
416
451
  "x-use-ppe": "1",
417
452
  },
@@ -473,10 +508,11 @@ export class CozeloopExporter {
473
508
  const runtimeTag = {
474
509
  language: "nodejs",
475
510
  library: "openclaw",
511
+ scene: process.env.COZELOOP_SCENE || "custom",
512
+ library_version: null,
513
+ loop_sdk_version: `v${PLUGIN_VERSION}`,
514
+ extra: null,
476
515
  };
477
- if (process.env.COZELOOP_SCENE) {
478
- runtimeTag.scene = process.env.COZELOOP_SCENE;
479
- }
480
516
  const systemTagRuntime = JSON.stringify(runtimeTag);
481
517
  const span = this.tracer.startSpan(spanData.name, {
482
518
  kind: spanKind,
@@ -579,10 +615,11 @@ export class CozeloopExporter {
579
615
  const runtimeTag = {
580
616
  language: "nodejs",
581
617
  library: "openclaw",
618
+ scene: process.env.COZELOOP_SCENE || "custom",
619
+ library_version: null,
620
+ loop_sdk_version: `v${PLUGIN_VERSION}`,
621
+ extra: null,
582
622
  };
583
- if (process.env.COZELOOP_SCENE) {
584
- runtimeTag.scene = process.env.COZELOOP_SCENE;
585
- }
586
623
  const systemTagRuntime = JSON.stringify(runtimeTag);
587
624
  const span = this.tracer.startSpan(spanData.name, {
588
625
  kind: spanKind,
@@ -534,6 +534,7 @@ const cozeloopTracePlugin = {
534
534
  batchInterval: pluginConfig.batchInterval || 5000,
535
535
  enabledHooks: pluginConfig.enabledHooks,
536
536
  disableLocalCredentials: pluginConfig.disableLocalCredentials === true,
537
+ logFile: pluginConfig.logFile,
537
538
  };
538
539
  const exporter = new CozeloopExporter(api, config);
539
540
  // 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.16",
4
+ "version": "0.1.18",
5
5
  "description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
6
6
  "type": "plugin",
7
7
  "entry": "./dist/index.js",
@@ -54,6 +54,11 @@
54
54
  "type": "boolean",
55
55
  "default": false,
56
56
  "description": "Disable ~/.cozeloop credentials refresh and use configured authorization only"
57
+ },
58
+ "logFile": {
59
+ "type": "string",
60
+ "default": "",
61
+ "description": "Append trace ingest diagnostics (URL, span count, HTTP status, failure body) to this file"
57
62
  }
58
63
  }
59
64
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",