coze_lab 0.1.30 → 0.1.32

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
@@ -5393,6 +5393,12 @@ function getCloudCozeloopApiBaseUrl() {
5393
5393
  function normalizeCozeloopApiBaseUrl(raw) {
5394
5394
  const base = raw.trim().replace(/\/+$/, '');
5395
5395
  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
+ }
5396
5402
  if (base.endsWith('/v1/loop/opentelemetry/v1/traces')) {
5397
5403
  return base.slice(0, -'/v1/loop/opentelemetry/v1/traces'.length).replace(/\/+$/, '');
5398
5404
  }
@@ -5431,6 +5437,14 @@ function getOtelTracesUrl(cloud) {
5431
5437
  return `${getOtelEndpointBase(cloud).replace(/\/+$/, '')}/v1/traces`;
5432
5438
  }
5433
5439
 
5440
+ function getCozeloopIngestUrlFromBase(apiBase) {
5441
+ return `${String(apiBase || COZE_API).replace(/\/+$/, '')}/v1/loop/traces/ingest`;
5442
+ }
5443
+
5444
+ function getCozeloopIngestUrl(cloud) {
5445
+ return getCozeloopIngestUrlFromBase(getCozeloopApiBaseUrl(cloud));
5446
+ }
5447
+
5434
5448
  function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud) {
5435
5449
  if (!existing.plugins) existing.plugins = {};
5436
5450
  if (!existing.plugins.allow) existing.plugins.allow = [];
@@ -5450,7 +5464,9 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
5450
5464
  if (!existing.plugins.entries[PLUGIN].config) existing.plugins.entries[PLUGIN].config = {};
5451
5465
  const pcfg = existing.plugins.entries[PLUGIN].config;
5452
5466
  pcfg.authorization = `Bearer ${token}`;
5453
- pcfg.endpoint = getOtelEndpointBase(cloud);
5467
+ // OpenClaw runtime uses CozeLoop ingest protocol, not OTLP. Keep accepting
5468
+ // OTEL_ENDPOINT in cloud, but normalize it to the API base URL before writing.
5469
+ pcfg.endpoint = getCozeloopApiBaseUrl(cloud);
5454
5470
  pcfg.workspaceId = workspaceId;
5455
5471
  pcfg.debug = true;
5456
5472
  pcfg.disableLocalCredentials = !!cloud;
@@ -5848,48 +5864,50 @@ async function verifyOpenClawTraceLink(cloud) {
5848
5864
  return { success: false, status: 0, body: 'plugin authorization missing' };
5849
5865
  }
5850
5866
 
5851
- // 1) 用插件【实际配置的】token 打一条最小 OTLP trace ——这才是运行时真实用的那个 token。
5852
- // 不能发空 resourceSpans,CozeLoop 会返回 "unknown event type",导致自检假失败。
5867
+ // 1) 用插件【实际配置的】token 打一条最小 CozeLoop ingest trace ——这才是运行时真实用的那个 token。
5853
5868
  const authHeader = pcfg.authorization; // 形如 "Bearer czu_xxx"
5854
- const tracesUrl = (pcfg.endpoint
5855
- ? `${String(pcfg.endpoint).replace(/\/+$/, '')}/v1/traces`
5856
- : getOtelTracesUrl(cloud));
5869
+ const tracesUrl = getCozeloopIngestUrlFromBase(normalizeCozeloopApiBaseUrl(pcfg.endpoint || getCozeloopApiBaseUrl(cloud)));
5857
5870
  const workspaceId = pcfg.workspaceId || WORKSPACE_ID;
5858
5871
  const traceId = crypto.randomBytes(16).toString('hex');
5859
5872
  const spanId = crypto.randomBytes(8).toString('hex');
5860
- const nowNs = String(Date.now() * 1_000_000);
5873
+ const nowMicros = Date.now() * 1000;
5861
5874
  const pair = crypto.randomBytes(6).toString('hex');
5862
- const otlpBody = {
5863
- resourceSpans: [{
5864
- resource: {
5865
- attributes: [
5866
- { key: 'service.name', value: { stringValue: 'cozelab-onboard-openclaw' } },
5867
- ],
5875
+ const ingestBody = {
5876
+ spans: [{
5877
+ started_at_micros: nowMicros,
5878
+ log_id: '',
5879
+ span_id: spanId,
5880
+ parent_id: '0',
5881
+ trace_id: traceId,
5882
+ duration_micros: 1,
5883
+ service_name: 'cozelab-onboard-openclaw',
5884
+ workspace_id: String(workspaceId),
5885
+ span_name: 'cozelab-onboard-openclaw-selfcheck',
5886
+ span_type: 'main',
5887
+ status_code: 0,
5888
+ input: 'cozelab-onboard-openclaw selfcheck',
5889
+ output: 'ok',
5890
+ object_storage: '',
5891
+ system_tags_string: {
5892
+ runtime: JSON.stringify({ language: 'nodejs', library: 'openclaw', scene: process.env.COZELOOP_SCENE || 'custom' }),
5868
5893
  },
5869
- scopeSpans: [{
5870
- scope: { name: 'cozelab-onboard-openclaw-selfcheck' },
5871
- spans: [{
5872
- traceId,
5873
- spanId,
5874
- name: 'cozelab-onboard-openclaw-selfcheck',
5875
- kind: 1,
5876
- startTimeUnixNano: nowNs,
5877
- endTimeUnixNano: nowNs,
5878
- status: { code: 1 },
5879
- attributes: [
5880
- { key: 'pair_code', value: { stringValue: pair } },
5881
- { key: 'source', value: { stringValue: 'cozelab-onboard-openclaw' } },
5882
- ],
5883
- }],
5884
- }],
5894
+ system_tags_long: {},
5895
+ system_tags_double: {},
5896
+ tags_string: {
5897
+ pair_code: pair,
5898
+ source: 'cozelab-onboard-openclaw',
5899
+ },
5900
+ tags_long: {},
5901
+ tags_double: {},
5902
+ tags_bool: {},
5885
5903
  }],
5886
5904
  };
5887
5905
  let res;
5888
5906
  try {
5889
5907
  res = await httpsPost(
5890
5908
  tracesUrl,
5891
- otlpBody,
5892
- { Authorization: authHeader, 'cozeloop-workspace-id': String(workspaceId) },
5909
+ ingestBody,
5910
+ { Authorization: authHeader },
5893
5911
  );
5894
5912
  } catch (e) {
5895
5913
  warn(`openclaw 插件 token 上报探测失败: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -1,6 +1,5 @@
1
1
  import { trace, context, SpanKind, SpanStatusCode } from "@opentelemetry/api";
2
2
  import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
3
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
4
3
  import { Resource } from "@opentelemetry/resources";
5
4
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/semantic-conventions";
6
5
  import { hostname } from "os";
@@ -8,6 +7,7 @@ import { basename, join } from "path";
8
7
  import { createRequire } from "node:module";
9
8
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
10
9
  import { homedir } from "os";
10
+ import http from "http";
11
11
  import https from "https";
12
12
 
13
13
  const require = createRequire(import.meta.url);
@@ -78,6 +78,238 @@ async function getRefreshedToken(currentAuthorization, opts = {}) {
78
78
  }
79
79
  // ─────────────────────────────────────────────────────────────────────────
80
80
 
81
+ const EXPORT_SUCCESS = 0;
82
+ const EXPORT_FAILED = 1;
83
+ const INGEST_TRACE_PATH = "/v1/loop/traces/ingest";
84
+
85
+ function normalizeApiBaseUrl(endpoint) {
86
+ const base = String(endpoint || "").replace(/\/+$/, "");
87
+ if (!base)
88
+ return _COZE_API;
89
+ const suffixes = [
90
+ "/v1/loop/traces/ingest",
91
+ "/api/v1/loop/traces/ingest",
92
+ "/v1/loop/opentelemetry/v1/traces",
93
+ "/api/v1/loop/opentelemetry/v1/traces",
94
+ "/v1/loop/opentelemetry",
95
+ "/api/v1/loop/opentelemetry",
96
+ "/v1/traces",
97
+ "/api/v1",
98
+ ];
99
+ for (const suffix of suffixes) {
100
+ if (base.endsWith(suffix)) {
101
+ const trimSuffix = suffix.startsWith("/api/") ? suffix.slice("/api".length) : suffix;
102
+ return base.slice(0, -trimSuffix.length).replace(/\/+$/, "");
103
+ }
104
+ }
105
+ return base;
106
+ }
107
+
108
+ function normalizeIngestUrl(endpoint) {
109
+ const base = String(endpoint || "").replace(/\/+$/, "");
110
+ if (base.endsWith(INGEST_TRACE_PATH))
111
+ return base;
112
+ return `${normalizeApiBaseUrl(base)}${INGEST_TRACE_PATH}`;
113
+ }
114
+
115
+ function hrTimeToUnixMicros(time) {
116
+ if (Array.isArray(time)) {
117
+ return Number(BigInt(time[0]) * 1000000n + BigInt(Math.trunc(time[1] / 1000)));
118
+ }
119
+ const millis = time instanceof Date ? time.getTime() : Number(time || Date.now());
120
+ return Math.trunc(millis * 1000);
121
+ }
122
+
123
+ function hrDurationToMicros(duration) {
124
+ if (Array.isArray(duration)) {
125
+ return Number(BigInt(duration[0]) * 1000000n + BigInt(Math.trunc(duration[1] / 1000)));
126
+ }
127
+ return 0;
128
+ }
129
+
130
+ function safeStringify(value) {
131
+ if (value === undefined || value === null)
132
+ return "";
133
+ if (typeof value === "string")
134
+ return value;
135
+ try {
136
+ return JSON.stringify(value);
137
+ }
138
+ catch {
139
+ return String(value);
140
+ }
141
+ }
142
+
143
+ function mapSpanType(type) {
144
+ switch (type) {
145
+ case "entry":
146
+ case "gateway":
147
+ case "message":
148
+ case "session":
149
+ return "main";
150
+ case "agent":
151
+ case "model":
152
+ case "tool":
153
+ return type;
154
+ default:
155
+ return type || "main";
156
+ }
157
+ }
158
+
159
+ function putTypedTag(maps, key, value) {
160
+ if (value === undefined || value === null || key === "")
161
+ return;
162
+ if (key === "cozeloop.span_type" || key === "cozeloop.input" || key === "cozeloop.output" ||
163
+ key === "cozeloop.system_tag_runtime") {
164
+ return;
165
+ }
166
+ if (typeof value === "boolean") {
167
+ maps.bool[key] = value;
168
+ }
169
+ else if (typeof value === "number" && Number.isInteger(value)) {
170
+ maps.long[key] = value;
171
+ }
172
+ else if (typeof value === "number") {
173
+ maps.double[key] = value;
174
+ }
175
+ else {
176
+ maps.string[key] = typeof value === "string" ? value : safeStringify(value);
177
+ }
178
+ }
179
+
180
+ function splitAttributes(attributes) {
181
+ const tags = { string: {}, long: {}, double: {}, bool: {} };
182
+ const systemTags = { string: {}, long: {}, double: {} };
183
+ for (const [key, value] of Object.entries(attributes || {})) {
184
+ if (key === "cozeloop.system_tag_runtime") {
185
+ systemTags.string.runtime = safeStringify(value);
186
+ continue;
187
+ }
188
+ putTypedTag(tags, key, value);
189
+ }
190
+ return { tags, systemTags };
191
+ }
192
+
193
+ function spanToUploadSpan(span, config) {
194
+ const spanContext = span.spanContext();
195
+ const attrs = span.attributes || {};
196
+ const { tags, systemTags } = splitAttributes(attrs);
197
+ const resourceAttrs = span.resource?.attributes || {};
198
+ const serviceName = resourceAttrs["service.name"] || config.serviceName || "openclaw-agent";
199
+ const spanType = mapSpanType(attrs["cozeloop.span_type"]);
200
+ const input = attrs["cozeloop.input"] !== undefined ? safeStringify(attrs["cozeloop.input"]) : "";
201
+ const output = attrs["cozeloop.output"] !== undefined ? safeStringify(attrs["cozeloop.output"]) : "";
202
+ const hasError = span.status?.code === SpanStatusCode.ERROR || attrs.error === true || attrs["tool.error"] === true;
203
+
204
+ return {
205
+ started_at_micros: hrTimeToUnixMicros(span.startTime),
206
+ log_id: "",
207
+ traceId: spanContext.traceId,
208
+ trace_id: spanContext.traceId,
209
+ span_id: spanContext.spanId,
210
+ parent_id: span.parentSpanId || "0",
211
+ duration_micros: hrDurationToMicros(span.duration),
212
+ service_name: String(serviceName),
213
+ workspace_id: String(config.workspaceId || ""),
214
+ span_name: String(span.name || "unknown"),
215
+ span_type: spanType,
216
+ status_code: hasError ? -1 : 0,
217
+ input,
218
+ output,
219
+ object_storage: "",
220
+ system_tags_string: systemTags.string,
221
+ system_tags_long: systemTags.long,
222
+ system_tags_double: systemTags.double,
223
+ tags_string: tags.string,
224
+ tags_long: tags.long,
225
+ tags_double: tags.double,
226
+ tags_bool: tags.bool,
227
+ };
228
+ }
229
+
230
+ function postJson(url, body, headers) {
231
+ return new Promise((resolve, reject) => {
232
+ const data = JSON.stringify(body);
233
+ const u = new URL(url);
234
+ const client = u.protocol === "http:" ? http : https;
235
+ const req = client.request({
236
+ protocol: u.protocol,
237
+ hostname: u.hostname,
238
+ port: u.port || undefined,
239
+ path: u.pathname + u.search,
240
+ method: "POST",
241
+ headers: {
242
+ "Content-Type": "application/json",
243
+ "Content-Length": Buffer.byteLength(data),
244
+ ...(headers || {}),
245
+ },
246
+ }, (res) => {
247
+ let buf = "";
248
+ res.on("data", c => buf += c);
249
+ res.on("end", () => resolve({ status: res.statusCode || 0, body: buf }));
250
+ });
251
+ req.on("error", reject);
252
+ req.setTimeout(15000, () => req.destroy(new Error("CozeLoop ingest export timed out")));
253
+ req.write(data);
254
+ req.end();
255
+ });
256
+ }
257
+
258
+ class CozeloopIngestExporter {
259
+ constructor(config) {
260
+ this.url = normalizeIngestUrl(config.endpoint || config.url);
261
+ this.headers = config.headers || {};
262
+ this.logger = config.logger;
263
+ this.workspaceId = config.workspaceId;
264
+ this.serviceName = config.serviceName;
265
+ this.shutdownRequested = false;
266
+ }
267
+ export(spans, resultCallback) {
268
+ if (!spans || spans.length === 0 || this.shutdownRequested) {
269
+ resultCallback({ code: EXPORT_SUCCESS });
270
+ return;
271
+ }
272
+ this.postSpans(spans)
273
+ .then(() => resultCallback({ code: EXPORT_SUCCESS }))
274
+ .catch((err) => {
275
+ this.logger?.error?.(`[CozeloopTrace] CozeLoop ingest export failed: ${err?.message || err}`);
276
+ resultCallback({ code: EXPORT_FAILED, error: err });
277
+ });
278
+ }
279
+ async postSpans(spans) {
280
+ const body = {
281
+ spans: spans.map((span) => spanToUploadSpan(span, {
282
+ workspaceId: this.workspaceId,
283
+ serviceName: this.serviceName,
284
+ })),
285
+ };
286
+ const res = await postJson(this.url, body, this.headers);
287
+ if (res.status < 200 || res.status >= 300) {
288
+ const snippet = String(res.body || "").slice(0, 300);
289
+ throw new Error(`HTTP ${res.status}${snippet ? `: ${snippet}` : ""}`);
290
+ }
291
+ if (res.body) {
292
+ try {
293
+ const parsed = JSON.parse(res.body);
294
+ if (parsed && parsed.code !== undefined && parsed.code !== 0) {
295
+ throw new Error(`code ${parsed.code}: ${parsed.msg || res.body.slice(0, 300)}`);
296
+ }
297
+ }
298
+ catch (err) {
299
+ if (/^code\s/.test(String(err?.message || ""))) {
300
+ throw err;
301
+ }
302
+ }
303
+ }
304
+ }
305
+ async forceFlush() {
306
+ return;
307
+ }
308
+ async shutdown() {
309
+ this.shutdownRequested = true;
310
+ }
311
+ }
312
+
81
313
  export class CozeloopExporter {
82
314
  config;
83
315
  api;
@@ -173,11 +405,13 @@ export class CozeloopExporter {
173
405
  const authorization = this.config.authorization;
174
406
  const workspaceId = this.config.workspaceId;
175
407
  this.api.logger.info(`[CozeloopTrace] Using authorization, workspaceId=${workspaceId}, tokenLength=${authorization?.length}`);
176
- const exporter = new OTLPTraceExporter({
177
- url: `${this.config.endpoint}/v1/traces`,
408
+ const exporter = new CozeloopIngestExporter({
409
+ endpoint: this.config.endpoint,
410
+ logger: this.api.logger,
411
+ workspaceId,
412
+ serviceName: this.config.serviceName,
178
413
  headers: {
179
414
  "Authorization": authorization,
180
- "cozeloop-workspace-id": workspaceId,
181
415
  "x-tt-env": "ppe_cozelab",
182
416
  "x-use-ppe": "1",
183
417
  },
@@ -441,12 +675,22 @@ export class CozeloopExporter {
441
675
  }
442
676
  async flush() {
443
677
  if (this.provider) {
444
- await this.provider.forceFlush();
678
+ try {
679
+ await this.provider.forceFlush();
680
+ }
681
+ catch (err) {
682
+ this.api.logger.error(`[CozeloopTrace] Flush failed: ${err?.message || err}`);
683
+ }
445
684
  }
446
685
  }
447
686
  async dispose() {
448
687
  if (this.provider) {
449
- await this.provider.shutdown();
688
+ try {
689
+ await this.provider.shutdown();
690
+ }
691
+ catch (err) {
692
+ this.api.logger.error(`[CozeloopTrace] Shutdown failed: ${err?.message || err}`);
693
+ }
450
694
  }
451
695
  }
452
696
  }
@@ -476,20 +476,39 @@ function resolveCozeContext(input, sessionId) {
476
476
  return cozeContextBySession.get(sessionId);
477
477
  return lastCozeContext || {};
478
478
  }
479
+ function resetRuntimeState() {
480
+ lastUserChannelId = undefined;
481
+ lastUserTraceContext = undefined;
482
+ lastOpenclawSessionId = undefined;
483
+ cozeContextBySession.clear();
484
+ lastCozeContext = undefined;
485
+ activeAgentCtx = undefined;
486
+ activeAgentChannelId = undefined;
487
+ activeAgentContextByKey.clear();
488
+ lastUserInput = undefined;
489
+ lastUserContextByKey.clear();
490
+ pendingToolCallsByKey.clear();
491
+ }
479
492
  // Active agent context: set in before_agent_start, cleared in agent_end.
480
493
  // All hooks between these two (llm_input, llm_output, tool calls, messages)
481
494
  // use this to ensure every span lands in the same Trace.
482
495
  let activeAgentCtx;
483
496
  let activeAgentChannelId;
497
+ const activeAgentContextByKey = new Map();
484
498
  // Latest user input captured from message_received, independent of any ctx.
485
499
  // Used by ensureRootSpan as a reliable fallback for the root span's input.
486
500
  let lastUserInput;
487
- let pendingToolCall;
501
+ const lastUserContextByKey = new Map();
502
+ const pendingToolCallsByKey = new Map();
488
503
  function resolveOpenclawSessionId(hookCtx, eventSessionId) {
489
504
  const raw = hookCtx.sessionId?.trim() || eventSessionId?.trim();
490
- if (!raw)
491
- return undefined;
492
- return raw;
505
+ if (raw)
506
+ return raw;
507
+ const sessionKey = hookCtx.sessionKey?.trim();
508
+ const match = sessionKey?.match(/^agent:[^:]+:(.+)$/);
509
+ if (match?.[1])
510
+ return match[1];
511
+ return undefined;
493
512
  }
494
513
  const cozeloopTracePlugin = {
495
514
  id: "openclaw-cozeloop-trace",
@@ -497,6 +516,7 @@ const cozeloopTracePlugin = {
497
516
  version: PLUGIN_VERSION,
498
517
  description: "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
499
518
  activate(api) {
519
+ resetRuntimeState();
500
520
  const pluginConfig = api.pluginConfig || {};
501
521
  const authorization = pluginConfig.authorization;
502
522
  const workspaceId = pluginConfig.workspaceId;
@@ -505,7 +525,7 @@ const cozeloopTracePlugin = {
505
525
  return;
506
526
  }
507
527
  const config = {
508
- endpoint: pluginConfig.endpoint || "https://api.coze.cn/v1/loop/opentelemetry",
528
+ endpoint: pluginConfig.endpoint || "https://api.coze.cn",
509
529
  authorization,
510
530
  workspaceId,
511
531
  serviceName: pluginConfig.serviceName || "openclaw-agent",
@@ -558,6 +578,30 @@ const cozeloopTracePlugin = {
558
578
  const ctx = contextByRunId.get(runId);
559
579
  return ctx?.originalChannelId || ctx?.channelId;
560
580
  };
581
+ const sessionIdFromChannelId = (channelId) => {
582
+ const match = String(channelId || "").match(/^agent\/[^:]+:(.+)$/);
583
+ return match?.[1];
584
+ };
585
+ const canFallbackToLastUserContext = (rawChannelId) => {
586
+ const rawSessionId = sessionIdFromChannelId(rawChannelId);
587
+ const lastSessionId = lastUserTraceContext?.openclawSessionId || sessionIdFromChannelId(lastUserChannelId);
588
+ return !rawSessionId || !lastSessionId || rawSessionId === lastSessionId;
589
+ };
590
+ const canFallbackToActiveAgentContext = (rawChannelId) => {
591
+ const rawSessionId = sessionIdFromChannelId(rawChannelId);
592
+ const activeSessionId = activeAgentCtx?.openclawSessionId || sessionIdFromChannelId(activeAgentChannelId);
593
+ return !rawSessionId || !activeSessionId || rawSessionId === activeSessionId;
594
+ };
595
+ const canUseLastUserFallbackFor = (ctx, rawChannelId) => {
596
+ if (!lastUserTraceContext || lastUserTraceContext === ctx)
597
+ return true;
598
+ const rawSessionId = ctx?.openclawSessionId || sessionIdFromChannelId(rawChannelId);
599
+ const lastSessionId = lastUserTraceContext.openclawSessionId || sessionIdFromChannelId(lastUserChannelId);
600
+ return !rawSessionId || !lastSessionId || rawSessionId === lastSessionId;
601
+ };
602
+ const lastUserFallbackFor = (ctx, rawChannelId) => {
603
+ return canUseLastUserFallbackFor(ctx, rawChannelId) ? lastUserTraceContext : undefined;
604
+ };
561
605
  const startTurn = (runId, channelId, originalChannelId, openclawSessionId) => {
562
606
  const traceId = generateId(32);
563
607
  const ctx = {
@@ -583,7 +627,7 @@ const cozeloopTracePlugin = {
583
627
  const getOrCreateContext = (rawChannelId, runId, hookName) => {
584
628
  let channelId = rawChannelId;
585
629
  let activeCtx = getContextByChannel(rawChannelId);
586
- const effectiveRunId = runId || activeCtx?.runId || `run-${Date.now()}`;
630
+ const effectiveRunId = runId || activeCtx?.runId || `run-${Date.now()}-${generateId(8)}`;
587
631
  if (rawChannelId.startsWith("agent/") && effectiveRunId) {
588
632
  const originalChannelId = getOriginalChannelId(effectiveRunId);
589
633
  if (originalChannelId) {
@@ -594,7 +638,7 @@ const cozeloopTracePlugin = {
594
638
  if (!activeCtx) {
595
639
  activeCtx = getContextByRun(effectiveRunId);
596
640
  }
597
- if (!activeCtx && rawChannelId.startsWith("agent/") && lastUserTraceContext) {
641
+ if (!activeCtx && rawChannelId.startsWith("agent/") && lastUserTraceContext && canFallbackToLastUserContext(rawChannelId)) {
598
642
  activeCtx = lastUserTraceContext;
599
643
  channelId = lastUserChannelId || channelId;
600
644
  contextByChannelId.set(rawChannelId, activeCtx);
@@ -616,19 +660,140 @@ const cozeloopTracePlugin = {
616
660
  }
617
661
  return { ctx: activeCtx, channelId, isNew };
618
662
  };
663
+ const contextKeys = (hookCtx, channelId, eventSessionId) => {
664
+ const keys = [];
665
+ const add = (value) => {
666
+ const s = String(value || "").trim();
667
+ if (s && !keys.includes(s))
668
+ keys.push(s);
669
+ };
670
+ add(resolveOpenclawSessionId(hookCtx || {}, eventSessionId));
671
+ add(hookCtx?.sessionKey);
672
+ add(hookCtx?.conversationId);
673
+ add(channelId);
674
+ return keys;
675
+ };
676
+ const rememberLastUserContext = (hookCtx, channelId, ctx) => {
677
+ for (const key of contextKeys(hookCtx, channelId)) {
678
+ lastUserContextByKey.set(key, { ctx, channelId });
679
+ }
680
+ };
681
+ const resolveLastUserContext = (hookCtx, channelId) => {
682
+ for (const key of contextKeys(hookCtx, channelId)) {
683
+ const found = lastUserContextByKey.get(key);
684
+ if (found)
685
+ return found;
686
+ }
687
+ if (lastUserTraceContext) {
688
+ if (canFallbackToLastUserContext(channelId)) {
689
+ return { ctx: lastUserTraceContext, channelId: lastUserChannelId || channelId };
690
+ }
691
+ }
692
+ return undefined;
693
+ };
694
+ const rememberActiveAgentContext = (hookCtx, channelId, ctx) => {
695
+ for (const key of contextKeys(hookCtx, channelId, ctx.openclawSessionId)) {
696
+ activeAgentContextByKey.set(key, { ctx, channelId });
697
+ }
698
+ activeAgentCtx = ctx;
699
+ activeAgentChannelId = channelId;
700
+ };
701
+ const resolveActiveAgentContext = (hookCtx, channelId) => {
702
+ for (const key of contextKeys(hookCtx, channelId)) {
703
+ const found = activeAgentContextByKey.get(key);
704
+ if (found)
705
+ return found;
706
+ }
707
+ if (activeAgentCtx) {
708
+ if (canFallbackToActiveAgentContext(channelId)) {
709
+ return { ctx: activeAgentCtx, channelId: activeAgentChannelId || channelId };
710
+ }
711
+ }
712
+ return undefined;
713
+ };
714
+ const clearActiveAgentContext = (ctx) => {
715
+ for (const [key, found] of activeAgentContextByKey.entries()) {
716
+ if (found.ctx === ctx)
717
+ activeAgentContextByKey.delete(key);
718
+ }
719
+ if (activeAgentCtx === ctx) {
720
+ activeAgentCtx = undefined;
721
+ activeAgentChannelId = undefined;
722
+ }
723
+ };
724
+ const clearLastUserContext = (ctx) => {
725
+ for (const [key, found] of lastUserContextByKey.entries()) {
726
+ if (found.ctx === ctx)
727
+ lastUserContextByKey.delete(key);
728
+ }
729
+ if (lastUserTraceContext === ctx) {
730
+ lastUserTraceContext = undefined;
731
+ lastUserChannelId = undefined;
732
+ }
733
+ };
619
734
  // Resolve context for hooks that fire between before_agent_start and
620
735
  // agent_end. When an agent is active, always return that agent's context
621
736
  // so every span ends up in the same Trace regardless of channelId drift.
622
- const resolveActiveContext = (rawChannelId, runId, hookName) => {
623
- if (activeAgentCtx) {
737
+ const resolveActiveContext = (rawChannelId, runId, hookName, hookCtx) => {
738
+ const active = resolveActiveAgentContext(hookCtx || {}, rawChannelId);
739
+ if (active) {
624
740
  if (config.debug) {
625
- api.logger.info(`[CozeloopTrace] Using activeAgentCtx for ${hookName}: traceId=${activeAgentCtx.traceId}, rootSpanId=${activeAgentCtx.rootSpanId}`);
741
+ api.logger.info(`[CozeloopTrace] Using activeAgentCtx for ${hookName}: traceId=${active.ctx.traceId}, rootSpanId=${active.ctx.rootSpanId}`);
626
742
  }
627
- return { ctx: activeAgentCtx, channelId: activeAgentChannelId || rawChannelId };
743
+ return active;
628
744
  }
629
745
  const { ctx, channelId } = getOrCreateContext(rawChannelId, runId, hookName);
630
746
  return { ctx, channelId };
631
747
  };
748
+ const getToolCallId = (event) => {
749
+ const candidates = [
750
+ event?.toolCallId,
751
+ event?.tool_call_id,
752
+ event?.callId,
753
+ event?.id,
754
+ event?.requestId,
755
+ event?.invocationId,
756
+ event?.params?.toolCallId,
757
+ event?.params?.tool_call_id,
758
+ ];
759
+ const found = candidates.find((value) => value !== undefined && value !== null && String(value).trim());
760
+ return found === undefined ? undefined : String(found);
761
+ };
762
+ const pendingToolKey = (ctx, channelId, toolName, toolCallId) => {
763
+ const traceKey = ctx.rootSpanId || ctx.traceId || channelId;
764
+ if (toolCallId)
765
+ return `${traceKey}:id:${toolCallId}`;
766
+ return `${traceKey}:name:${toolName || "unknown_tool"}`;
767
+ };
768
+ const pushPendingToolCall = (ctx, channelId, pending) => {
769
+ const key = pendingToolKey(ctx, channelId, pending.toolName, pending.toolCallId);
770
+ const queue = pendingToolCallsByKey.get(key) || [];
771
+ queue.push(pending);
772
+ pendingToolCallsByKey.set(key, queue);
773
+ return queue.length;
774
+ };
775
+ const shiftPendingToolCall = (ctx, channelId, toolName, toolCallId) => {
776
+ const key = pendingToolKey(ctx, channelId, toolName, toolCallId);
777
+ const queue = pendingToolCallsByKey.get(key);
778
+ if (!queue || queue.length === 0)
779
+ return undefined;
780
+ const pending = queue.shift();
781
+ if (queue.length === 0) {
782
+ pendingToolCallsByKey.delete(key);
783
+ }
784
+ return pending;
785
+ };
786
+ const clearPendingToolCallsForContext = (ctx) => {
787
+ for (const [key, queue] of pendingToolCallsByKey.entries()) {
788
+ const kept = queue.filter((pending) => pending.traceContext !== ctx);
789
+ if (kept.length === 0) {
790
+ pendingToolCallsByKey.delete(key);
791
+ }
792
+ else if (kept.length !== queue.length) {
793
+ pendingToolCallsByKey.set(key, kept);
794
+ }
795
+ }
796
+ };
632
797
  const createSpan = (ctx, channelId, name, type, startTime, endTime, attributes = {}, input, output, parentSpanId) => {
633
798
  return {
634
799
  name,
@@ -878,6 +1043,7 @@ const cozeloopTracePlugin = {
878
1043
  if (role === "user" || !role) {
879
1044
  lastUserChannelId = channelId;
880
1045
  lastUserTraceContext = ctx;
1046
+ rememberLastUserContext(hookCtx, channelId, ctx);
881
1047
  ctx.userInput = event.content;
882
1048
  lastUserInput = event.content;
883
1049
  rememberCozeContext(event.content, ctx.openclawSessionId || lastOpenclawSessionId);
@@ -891,20 +1057,22 @@ const cozeloopTracePlugin = {
891
1057
  if (!lastUserTraceContext) {
892
1058
  lastUserTraceContext = ctx;
893
1059
  lastUserChannelId = channelId;
1060
+ rememberLastUserContext(hookCtx, channelId, ctx);
894
1061
  }
895
1062
  });
896
1063
  }
897
1064
  if (shouldHookEnabled("message_sending")) {
898
1065
  on("message_sending", async (event, hookCtx) => {
899
- if (lastUserTraceContext) {
900
- lastUserTraceContext.lastOutput = event.content;
1066
+ const rawChannelId = resolveChannelId(hookCtx, event.to);
1067
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1068
+ if (lastUser) {
1069
+ lastUser.ctx.lastOutput = event.content;
901
1070
  if (config.debug) {
902
- api.logger.info(`[CozeloopTrace] Captured output for root span: traceId=${lastUserTraceContext.traceId}, content=${typeof event.content === 'string' ? event.content.substring(0, 100) : 'non-string'}`);
1071
+ api.logger.info(`[CozeloopTrace] Captured output for root span: traceId=${lastUser.ctx.traceId}, content=${typeof event.content === 'string' ? event.content.substring(0, 100) : 'non-string'}`);
903
1072
  }
904
1073
  }
905
1074
  else {
906
- const rawChannelId = resolveChannelId(hookCtx, event.to);
907
- const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending");
1075
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending", hookCtx);
908
1076
  ctx.lastOutput = event.content;
909
1077
  if (config.debug) {
910
1078
  api.logger.info(`[CozeloopTrace] Captured output (fallback) for root span: traceId=${ctx.traceId}`);
@@ -915,15 +1083,16 @@ const cozeloopTracePlugin = {
915
1083
  if (shouldHookEnabled("message_sent")) {
916
1084
  on("message_sent", async (event, hookCtx) => {
917
1085
  if (event.content && event.success) {
918
- if (lastUserTraceContext) {
919
- lastUserTraceContext.lastOutput = event.content;
1086
+ const rawChannelId = resolveChannelId(hookCtx, event.to);
1087
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1088
+ if (lastUser) {
1089
+ lastUser.ctx.lastOutput = event.content;
920
1090
  if (config.debug) {
921
- api.logger.info(`[CozeloopTrace] Captured output from message_sent: traceId=${lastUserTraceContext.traceId}`);
1091
+ api.logger.info(`[CozeloopTrace] Captured output from message_sent: traceId=${lastUser.ctx.traceId}`);
922
1092
  }
923
1093
  }
924
1094
  else {
925
- const rawChannelId = resolveChannelId(hookCtx, event.to);
926
- const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent");
1095
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent", hookCtx);
927
1096
  ctx.lastOutput = event.content;
928
1097
  if (config.debug) {
929
1098
  api.logger.info(`[CozeloopTrace] Captured output from message_sent (fallback): traceId=${ctx.traceId}`);
@@ -941,7 +1110,7 @@ const cozeloopTracePlugin = {
941
1110
  if (config.debug) {
942
1111
  api.logger.info(`[CozeloopTrace] llm_input hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
943
1112
  }
944
- const { ctx } = resolveActiveContext(rawChannelId, event.runId, "llm_input");
1113
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_input", hookCtx);
945
1114
  const ocSessionId = resolveOpenclawSessionId(hookCtx);
946
1115
  if (ocSessionId) {
947
1116
  ctx.openclawSessionId = ocSessionId;
@@ -966,9 +1135,10 @@ const cozeloopTracePlugin = {
966
1135
  rememberCozeContext(event.prompt, ctx.openclawSessionId || lastOpenclawSessionId);
967
1136
  // Fallback: ensure root + agent spans exist in case before_agent_start
968
1137
  // was not fired (older OpenClaw versions or resumed sessions).
969
- const channelIdForSpans = activeAgentChannelId || rawChannelId;
1138
+ const active = resolveActiveAgentContext(hookCtx, channelId);
1139
+ const channelIdForSpans = active?.channelId || channelId || rawChannelId;
970
1140
  await ensureRootSpan(ctx, channelIdForSpans);
971
- await ensureAgentSpan(ctx, channelIdForSpans);
1141
+ await ensureAgentSpan(ctx, channelIdForSpans, undefined, hookCtx);
972
1142
  const messages = [];
973
1143
  if (event.systemPrompt) {
974
1144
  messages.push({ role: "system", content: safeClone(event.systemPrompt) });
@@ -1035,14 +1205,15 @@ const cozeloopTracePlugin = {
1035
1205
  api.logger.info(`[CozeloopTrace][DEBUG] llm_output event keys=${JSON.stringify(Object.keys(event))}`);
1036
1206
  api.logger.info(`[CozeloopTrace] llm_output hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
1037
1207
  }
1038
- const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output");
1208
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output", hookCtx);
1039
1209
  const now = Date.now();
1040
1210
  const startTime = ctx.llmStartTime || lastLlmStartTime || now;
1041
1211
  if (event.assistantTexts && event.assistantTexts.length > 0) {
1042
1212
  const outputText = event.assistantTexts.join("\n");
1043
1213
  ctx.lastOutput = outputText;
1044
- if (lastUserTraceContext) {
1045
- lastUserTraceContext.lastOutput = outputText;
1214
+ const lastUser = resolveLastUserContext(hookCtx, channelId);
1215
+ if (lastUser) {
1216
+ lastUser.ctx.lastOutput = outputText;
1046
1217
  }
1047
1218
  if (config.debug) {
1048
1219
  api.logger.info(`[CozeloopTrace] Captured output from llm_output (will use last): traceId=${ctx.traceId}, length=${outputText.length}`);
@@ -1128,34 +1299,39 @@ const cozeloopTracePlugin = {
1128
1299
  if (config.debug) {
1129
1300
  api.logger.info(`[CozeloopTrace] before_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1130
1301
  }
1131
- const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call");
1132
- pendingToolCall = {
1302
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call", hookCtx);
1303
+ const pendingToolCall = {
1133
1304
  toolName: event.toolName,
1305
+ toolCallId: getToolCallId(event),
1134
1306
  toolSpanId: generateId(16),
1135
1307
  toolStartTime: Date.now(),
1136
1308
  toolInput: event.params,
1137
1309
  traceContext: ctx,
1138
1310
  channelId: channelId,
1139
1311
  };
1312
+ const pendingCount = pushPendingToolCall(ctx, channelId, pendingToolCall);
1140
1313
  ctx.reactCount = (ctx.reactCount || 0) + 1;
1141
1314
  if (config.debug) {
1142
- api.logger.info(`[CozeloopTrace] Tool call started: ${event.toolName}, spanId=${pendingToolCall.toolSpanId}, traceId=${ctx.traceId}`);
1315
+ api.logger.info(`[CozeloopTrace] Tool call started: ${event.toolName}, toolCallId=${pendingToolCall.toolCallId || "none"}, spanId=${pendingToolCall.toolSpanId}, traceId=${ctx.traceId}, pendingCount=${pendingCount}`);
1143
1316
  }
1144
1317
  });
1145
1318
  }
1146
1319
  if (shouldHookEnabled("after_tool_call")) {
1147
1320
  on("after_tool_call", async (event, hookCtx) => {
1321
+ const rawChannelId = resolveChannelId(hookCtx);
1322
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "after_tool_call", hookCtx);
1148
1323
  if (config.debug) {
1149
1324
  api.logger.info(`[CozeloopTrace] after_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1150
1325
  }
1151
- if (!pendingToolCall || pendingToolCall.toolName !== event.toolName) {
1326
+ const toolCallId = getToolCallId(event);
1327
+ const pendingToolCall = shiftPendingToolCall(ctx, channelId, event.toolName, toolCallId);
1328
+ if (!pendingToolCall) {
1152
1329
  if (config.debug) {
1153
- api.logger.info(`[CozeloopTrace] Skipping after_tool_call: no pending tool or name mismatch, toolName=${event.toolName}, pending=${pendingToolCall?.toolName}`);
1330
+ api.logger.info(`[CozeloopTrace] Skipping after_tool_call: no pending tool, toolName=${event.toolName}, toolCallId=${toolCallId || "none"}, pendingKeys=${pendingToolCallsByKey.size}`);
1154
1331
  }
1155
1332
  return;
1156
1333
  }
1157
1334
  const { toolName, toolSpanId, toolStartTime, toolInput, traceContext } = pendingToolCall;
1158
- pendingToolCall = undefined;
1159
1335
  const now = Date.now();
1160
1336
  if (!traceContext.pendingToolSpans) {
1161
1337
  traceContext.pendingToolSpans = [];
@@ -1177,11 +1353,12 @@ const cozeloopTracePlugin = {
1177
1353
  // Helper: finalize a trace — end agent span (if open), end root span, flush,
1178
1354
  // and clean up all state. Called from agent_end (normal path) and
1179
1355
  // session_end (fallback for old OpenClaw versions that don't emit agent_end).
1180
- let traceFinalized = false;
1356
+ const finalizingTraces = new Set();
1181
1357
  const finalizeTrace = (ctx, channelId, agentEndAttrs, agentOutput) => {
1182
- if (traceFinalized)
1358
+ const finalizeKey = ctx.rootSpanId || ctx.traceId;
1359
+ if (finalizingTraces.has(finalizeKey))
1183
1360
  return;
1184
- traceFinalized = true;
1361
+ finalizingTraces.add(finalizeKey);
1185
1362
  const now = Date.now();
1186
1363
  // End agent span if still open.
1187
1364
  if (ctx.agentSpanId) {
@@ -1194,14 +1371,15 @@ const cozeloopTracePlugin = {
1194
1371
  }
1195
1372
  const rootSpanId = ctx.rootSpanId;
1196
1373
  const rootSpanStartTime = ctx.rootSpanStartTime;
1197
- const userInput = ctx.userInput || (lastUserTraceContext ? lastUserTraceContext.userInput : undefined) || lastUserInput;
1374
+ const lastUserFallback = lastUserFallbackFor(ctx, channelId);
1375
+ const userInput = ctx.userInput || lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
1198
1376
  const traceId = ctx.traceId;
1199
1377
  const hasRootSpan = !!rootSpanStartTime;
1200
1378
  const savedLastUserChannelId = lastUserChannelId;
1201
1379
  const originalChannelId = ctx.originalChannelId || channelId;
1202
1380
  setTimeout(async () => {
1203
1381
  if (hasRootSpan) {
1204
- const finalOutput = ctx.lastOutput || (lastUserTraceContext ? lastUserTraceContext.lastOutput : undefined);
1382
+ const finalOutput = ctx.lastOutput || lastUserFallback?.lastOutput;
1205
1383
  if (config.debug) {
1206
1384
  api.logger.info(`[CozeloopTrace] Ending root span with input=${userInput ? 'present' : 'missing'}, output=${finalOutput ? 'present' : 'missing'}`);
1207
1385
  }
@@ -1215,20 +1393,17 @@ const cozeloopTracePlugin = {
1215
1393
  }
1216
1394
  await exporter.flush();
1217
1395
  exporter.endTrace(rootSpanId);
1218
- if (activeAgentCtx === ctx) {
1219
- activeAgentCtx = undefined;
1220
- activeAgentChannelId = undefined;
1221
- }
1396
+ clearPendingToolCallsForContext(ctx);
1397
+ clearActiveAgentContext(ctx);
1222
1398
  if (savedLastUserChannelId) {
1223
1399
  endTurn(savedLastUserChannelId);
1224
1400
  }
1225
1401
  if (originalChannelId && originalChannelId !== savedLastUserChannelId) {
1226
1402
  endTurn(originalChannelId);
1227
1403
  }
1228
- lastUserChannelId = undefined;
1229
- lastUserTraceContext = undefined;
1404
+ clearLastUserContext(ctx);
1230
1405
  lastUserInput = undefined;
1231
- traceFinalized = false;
1406
+ finalizingTraces.delete(finalizeKey);
1232
1407
  }, 200);
1233
1408
  };
1234
1409
  // OpenClaw runtime 周期性发送的心跳轮询消息,不是真实对话,整条 trace 丢弃。
@@ -1249,8 +1424,9 @@ const cozeloopTracePlugin = {
1249
1424
  // currentRootContext is set and the agent span becomes a proper child.
1250
1425
  const ensureRootSpan = async (ctx, channelId) => {
1251
1426
  // 心跳轮询消息:不是真实对话,整条 trace 不上报(沿用下方“无 coze-context 即 return”的同款范式)。
1427
+ const lastUserFallback = lastUserFallbackFor(ctx, channelId);
1252
1428
  const heartbeatInput = ctx.userInput
1253
- || lastUserTraceContext?.userInput || lastUserInput;
1429
+ || lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
1254
1430
  if (isHeartbeatInput(heartbeatInput)) {
1255
1431
  if (config.debug) {
1256
1432
  api.logger.info(`[CozeloopTrace] skip heartbeat poll trace, traceId=${ctx.traceId}`);
@@ -1267,7 +1443,7 @@ const cozeloopTracePlugin = {
1267
1443
  let cozeCtx = resolveCozeContext(ctx.userInput, ocSessionId);
1268
1444
  if (Object.keys(cozeCtx).length === 0) {
1269
1445
  // Try the fallback user inputs too before giving up.
1270
- const fallbackInput = lastUserTraceContext?.userInput || lastUserInput;
1446
+ const fallbackInput = lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
1271
1447
  cozeCtx = resolveCozeContext(fallbackInput, ocSessionId);
1272
1448
  }
1273
1449
  if (Object.keys(cozeCtx).length === 0) {
@@ -1298,7 +1474,7 @@ const cozeloopTracePlugin = {
1298
1474
  // Resolve user input: prefer ctx.userInput set by this turn's
1299
1475
  // message_received, fall back to lastUserTraceContext, then lastUserInput.
1300
1476
  if (!ctx.userInput) {
1301
- ctx.userInput = lastUserTraceContext?.userInput || lastUserInput;
1477
+ ctx.userInput = lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
1302
1478
  }
1303
1479
  const rootSpanData = {
1304
1480
  name: "openclaw_request",
@@ -1322,9 +1498,11 @@ const cozeloopTracePlugin = {
1322
1498
  };
1323
1499
  // Helper: ensure the agent span exists for a given context.
1324
1500
  // Safe to call multiple times — only creates the span once.
1325
- const ensureAgentSpan = async (ctx, channelId, agentId) => {
1326
- if (ctx.agentSpanId)
1501
+ const ensureAgentSpan = async (ctx, channelId, agentId, hookCtx) => {
1502
+ if (ctx.agentSpanId) {
1503
+ rememberActiveAgentContext(hookCtx || {}, channelId, ctx);
1327
1504
  return;
1505
+ }
1328
1506
  const effectiveAgentId = agentId || "main";
1329
1507
  const now = Date.now();
1330
1508
  ctx.agentStartTime = now;
@@ -1347,8 +1525,7 @@ const cozeloopTracePlugin = {
1347
1525
  };
1348
1526
  await exporter.startSpan(spanData, ctx.agentSpanId);
1349
1527
  // Set active agent context so all subsequent hooks use the same Trace.
1350
- activeAgentCtx = ctx;
1351
- activeAgentChannelId = channelId;
1528
+ rememberActiveAgentContext(hookCtx || {}, channelId, ctx);
1352
1529
  if (config.debug) {
1353
1530
  api.logger.info(`[CozeloopTrace] ensureAgentSpan: created agent span, agentId=${effectiveAgentId}, spanId=${ctx.agentSpanId}, traceId=${ctx.traceId}`);
1354
1531
  }
@@ -1368,7 +1545,7 @@ const cozeloopTracePlugin = {
1368
1545
  }
1369
1546
  ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
1370
1547
  await ensureRootSpan(ctx, channelId);
1371
- await ensureAgentSpan(ctx, channelId, agentId);
1548
+ await ensureAgentSpan(ctx, channelId, agentId, hookCtx);
1372
1549
  });
1373
1550
  }
1374
1551
  if (shouldHookEnabled("agent_end")) {
@@ -1378,8 +1555,9 @@ const cozeloopTracePlugin = {
1378
1555
  api.logger.info(`[CozeloopTrace] agent_end hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}`);
1379
1556
  }
1380
1557
  // Use activeAgentCtx if available, otherwise fall back to resolution.
1381
- const ctx = activeAgentCtx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
1382
- const channelId = activeAgentChannelId || rawChannelId;
1558
+ const active = resolveActiveAgentContext(hookCtx, rawChannelId);
1559
+ const ctx = active?.ctx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
1560
+ const channelId = active?.channelId || rawChannelId;
1383
1561
  finalizeTrace(ctx, channelId, {
1384
1562
  "agent.duration_ms": event.durationMs || 0,
1385
1563
  "agent.message_count": event.messageCount || 0,
@@ -1397,9 +1575,11 @@ const cozeloopTracePlugin = {
1397
1575
  if (config.debug) {
1398
1576
  api.logger.info(`[CozeloopTrace] session_end: ${rawChannelId}`);
1399
1577
  }
1400
- const ctx = activeAgentCtx || lastUserTraceContext;
1578
+ const active = resolveActiveAgentContext(hookCtx, rawChannelId);
1579
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1580
+ const ctx = active?.ctx || lastUser?.ctx;
1401
1581
  if (ctx && ctx.rootSpanStartTime) {
1402
- const channelId = activeAgentChannelId || lastUserChannelId || rawChannelId;
1582
+ const channelId = active?.channelId || lastUser?.channelId || rawChannelId;
1403
1583
  if (config.debug) {
1404
1584
  api.logger.info(`[CozeloopTrace] session_end: finalizing trace as fallback, traceId=${ctx.traceId}`);
1405
1585
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cozeloop-trace",
3
3
  "name": "OpenClaw CozeLoop Trace",
4
- "version": "0.1.14",
4
+ "version": "0.1.16",
5
5
  "description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
6
6
  "type": "plugin",
7
7
  "entry": "./dist/index.js",
@@ -10,8 +10,8 @@
10
10
  "properties": {
11
11
  "endpoint": {
12
12
  "type": "string",
13
- "default": "https://api.coze.cn/v1/loop/opentelemetry",
14
- "description": "CozeLoop OTLP endpoint URL"
13
+ "default": "https://api.coze.cn",
14
+ "description": "CozeLoop API base URL"
15
15
  },
16
16
  "authorization": {
17
17
  "type": "string",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,7 +37,6 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@opentelemetry/api": "^1.7.0",
40
- "@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
41
40
  "@opentelemetry/resources": "^1.22.0",
42
41
  "@opentelemetry/sdk-trace-base": "^1.22.0",
43
42
  "@opentelemetry/semantic-conventions": "^1.22.0"