coze_lab 0.1.28 → 0.1.31

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
@@ -5848,17 +5848,47 @@ async function verifyOpenClawTraceLink(cloud) {
5848
5848
  return { success: false, status: 0, body: 'plugin authorization missing' };
5849
5849
  }
5850
5850
 
5851
- // 1) 用插件【实际配置的】token ingest(空 spans)——这才是运行时真实用的那个 token。
5851
+ // 1) 用插件【实际配置的】token 打一条最小 OTLP trace ——这才是运行时真实用的那个 token。
5852
+ // 不能发空 resourceSpans,CozeLoop 会返回 "unknown event type",导致自检假失败。
5852
5853
  const authHeader = pcfg.authorization; // 形如 "Bearer czu_xxx"
5853
5854
  const tracesUrl = (pcfg.endpoint
5854
5855
  ? `${String(pcfg.endpoint).replace(/\/+$/, '')}/v1/traces`
5855
5856
  : getOtelTracesUrl(cloud));
5856
5857
  const workspaceId = pcfg.workspaceId || WORKSPACE_ID;
5858
+ const traceId = crypto.randomBytes(16).toString('hex');
5859
+ const spanId = crypto.randomBytes(8).toString('hex');
5860
+ const nowNs = String(Date.now() * 1_000_000);
5861
+ 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
+ ],
5868
+ },
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
+ }],
5885
+ }],
5886
+ };
5857
5887
  let res;
5858
5888
  try {
5859
5889
  res = await httpsPost(
5860
5890
  tracesUrl,
5861
- { resourceSpans: [] },
5891
+ otlpBody,
5862
5892
  { Authorization: authHeader, 'cozeloop-workspace-id': String(workspaceId) },
5863
5893
  );
5864
5894
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.28",
3
+ "version": "0.1.31",
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,193 @@ async function getRefreshedToken(currentAuthorization, opts = {}) {
78
78
  }
79
79
  // ─────────────────────────────────────────────────────────────────────────
80
80
 
81
+ const EXPORT_SUCCESS = 0;
82
+ const EXPORT_FAILED = 1;
83
+
84
+ function normalizeTraceUrl(endpoint) {
85
+ const base = String(endpoint || "").replace(/\/+$/, "");
86
+ return base.endsWith("/v1/traces") ? base : `${base}/v1/traces`;
87
+ }
88
+
89
+ function hrTimeToUnixNano(time) {
90
+ if (Array.isArray(time)) {
91
+ return (BigInt(time[0]) * 1000000000n + BigInt(time[1])).toString();
92
+ }
93
+ const millis = time instanceof Date ? time.getTime() : Number(time || Date.now());
94
+ return (BigInt(Math.trunc(millis)) * 1000000n).toString();
95
+ }
96
+
97
+ function spanKindToOtlp(kind) {
98
+ switch (kind) {
99
+ case SpanKind.INTERNAL: return 1;
100
+ case SpanKind.SERVER: return 2;
101
+ case SpanKind.CLIENT: return 3;
102
+ case SpanKind.PRODUCER: return 4;
103
+ case SpanKind.CONSUMER: return 5;
104
+ default: return 0;
105
+ }
106
+ }
107
+
108
+ function scalarToAnyValue(value) {
109
+ if (value === undefined || value === null)
110
+ return undefined;
111
+ if (typeof value === "string")
112
+ return { stringValue: value };
113
+ if (typeof value === "boolean")
114
+ return { boolValue: value };
115
+ if (typeof value === "number") {
116
+ if (Number.isInteger(value))
117
+ return { intValue: String(value) };
118
+ return { doubleValue: value };
119
+ }
120
+ try {
121
+ return { stringValue: JSON.stringify(value) };
122
+ }
123
+ catch {
124
+ return { stringValue: String(value) };
125
+ }
126
+ }
127
+
128
+ function valueToAnyValue(value) {
129
+ if (Array.isArray(value)) {
130
+ const values = value.map(scalarToAnyValue).filter(Boolean);
131
+ return { arrayValue: { values } };
132
+ }
133
+ return scalarToAnyValue(value);
134
+ }
135
+
136
+ function attributesToOtlp(attributes) {
137
+ const out = [];
138
+ for (const [key, value] of Object.entries(attributes || {})) {
139
+ const otlpValue = valueToAnyValue(value);
140
+ if (otlpValue) {
141
+ out.push({ key, value: otlpValue });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function spanToOtlp(span) {
148
+ const spanContext = span.spanContext();
149
+ const otlpSpan = {
150
+ traceId: spanContext.traceId,
151
+ spanId: spanContext.spanId,
152
+ name: span.name,
153
+ kind: spanKindToOtlp(span.kind),
154
+ startTimeUnixNano: hrTimeToUnixNano(span.startTime),
155
+ endTimeUnixNano: hrTimeToUnixNano(span.endTime),
156
+ attributes: attributesToOtlp(span.attributes),
157
+ status: { code: span.status?.code ?? 0 },
158
+ };
159
+ if (span.parentSpanId) {
160
+ otlpSpan.parentSpanId = span.parentSpanId;
161
+ }
162
+ if (span.status?.message) {
163
+ otlpSpan.status.message = span.status.message;
164
+ }
165
+ return otlpSpan;
166
+ }
167
+
168
+ function buildOtlpTraceRequest(spans) {
169
+ const resourceGroups = new Map();
170
+ for (const span of spans) {
171
+ const resourceAttrs = attributesToOtlp(span.resource?.attributes || {});
172
+ const scope = span.instrumentationLibrary || {};
173
+ const scopeData = {
174
+ name: scope.name || "openclaw-cozeloop-trace",
175
+ version: scope.version || PLUGIN_VERSION,
176
+ };
177
+ if (scope.schemaUrl) {
178
+ scopeData.schemaUrl = scope.schemaUrl;
179
+ }
180
+ const resourceKey = JSON.stringify(resourceAttrs);
181
+ let resourceGroup = resourceGroups.get(resourceKey);
182
+ if (!resourceGroup) {
183
+ resourceGroup = {
184
+ resource: { attributes: resourceAttrs },
185
+ scopeGroups: new Map(),
186
+ };
187
+ resourceGroups.set(resourceKey, resourceGroup);
188
+ }
189
+ const scopeKey = JSON.stringify(scopeData);
190
+ let scopeGroup = resourceGroup.scopeGroups.get(scopeKey);
191
+ if (!scopeGroup) {
192
+ scopeGroup = { scope: scopeData, spans: [] };
193
+ resourceGroup.scopeGroups.set(scopeKey, scopeGroup);
194
+ }
195
+ scopeGroup.spans.push(spanToOtlp(span));
196
+ }
197
+ return {
198
+ resourceSpans: Array.from(resourceGroups.values()).map((resourceGroup) => ({
199
+ resource: resourceGroup.resource,
200
+ scopeSpans: Array.from(resourceGroup.scopeGroups.values()),
201
+ })),
202
+ };
203
+ }
204
+
205
+ function postJson(url, body, headers) {
206
+ return new Promise((resolve, reject) => {
207
+ const data = JSON.stringify(body);
208
+ const u = new URL(url);
209
+ const client = u.protocol === "http:" ? http : https;
210
+ const req = client.request({
211
+ protocol: u.protocol,
212
+ hostname: u.hostname,
213
+ port: u.port || undefined,
214
+ path: u.pathname + u.search,
215
+ method: "POST",
216
+ headers: {
217
+ "Content-Type": "application/json",
218
+ "Content-Length": Buffer.byteLength(data),
219
+ ...(headers || {}),
220
+ },
221
+ }, (res) => {
222
+ let buf = "";
223
+ res.on("data", c => buf += c);
224
+ res.on("end", () => resolve({ status: res.statusCode || 0, body: buf }));
225
+ });
226
+ req.on("error", reject);
227
+ req.setTimeout(15000, () => req.destroy(new Error("OTLP JSON export timed out")));
228
+ req.write(data);
229
+ req.end();
230
+ });
231
+ }
232
+
233
+ class JsonOtlpTraceExporter {
234
+ constructor(config) {
235
+ this.url = normalizeTraceUrl(config.url);
236
+ this.headers = config.headers || {};
237
+ this.logger = config.logger;
238
+ this.shutdownRequested = false;
239
+ }
240
+ export(spans, resultCallback) {
241
+ if (!spans || spans.length === 0 || this.shutdownRequested) {
242
+ resultCallback({ code: EXPORT_SUCCESS });
243
+ return;
244
+ }
245
+ this.postSpans(spans)
246
+ .then(() => resultCallback({ code: EXPORT_SUCCESS }))
247
+ .catch((err) => {
248
+ this.logger?.error?.(`[CozeloopTrace] OTLP JSON export failed: ${err?.message || err}`);
249
+ resultCallback({ code: EXPORT_FAILED, error: err });
250
+ });
251
+ }
252
+ async postSpans(spans) {
253
+ const body = buildOtlpTraceRequest(spans);
254
+ const res = await postJson(this.url, body, this.headers);
255
+ if (res.status < 200 || res.status >= 300) {
256
+ const snippet = String(res.body || "").slice(0, 300);
257
+ throw new Error(`HTTP ${res.status}${snippet ? `: ${snippet}` : ""}`);
258
+ }
259
+ }
260
+ async forceFlush() {
261
+ return;
262
+ }
263
+ async shutdown() {
264
+ this.shutdownRequested = true;
265
+ }
266
+ }
267
+
81
268
  export class CozeloopExporter {
82
269
  config;
83
270
  api;
@@ -173,8 +360,9 @@ export class CozeloopExporter {
173
360
  const authorization = this.config.authorization;
174
361
  const workspaceId = this.config.workspaceId;
175
362
  this.api.logger.info(`[CozeloopTrace] Using authorization, workspaceId=${workspaceId}, tokenLength=${authorization?.length}`);
176
- const exporter = new OTLPTraceExporter({
363
+ const exporter = new JsonOtlpTraceExporter({
177
364
  url: `${this.config.endpoint}/v1/traces`,
365
+ logger: this.api.logger,
178
366
  headers: {
179
367
  "Authorization": authorization,
180
368
  "cozeloop-workspace-id": workspaceId,
@@ -441,12 +629,22 @@ export class CozeloopExporter {
441
629
  }
442
630
  async flush() {
443
631
  if (this.provider) {
444
- await this.provider.forceFlush();
632
+ try {
633
+ await this.provider.forceFlush();
634
+ }
635
+ catch (err) {
636
+ this.api.logger.error(`[CozeloopTrace] Flush failed: ${err?.message || err}`);
637
+ }
445
638
  }
446
639
  }
447
640
  async dispose() {
448
641
  if (this.provider) {
449
- await this.provider.shutdown();
642
+ try {
643
+ await this.provider.shutdown();
644
+ }
645
+ catch (err) {
646
+ this.api.logger.error(`[CozeloopTrace] Shutdown failed: ${err?.message || err}`);
647
+ }
450
648
  }
451
649
  }
452
650
  }
@@ -481,15 +481,21 @@ function resolveCozeContext(input, sessionId) {
481
481
  // use this to ensure every span lands in the same Trace.
482
482
  let activeAgentCtx;
483
483
  let activeAgentChannelId;
484
+ const activeAgentContextByKey = new Map();
484
485
  // Latest user input captured from message_received, independent of any ctx.
485
486
  // Used by ensureRootSpan as a reliable fallback for the root span's input.
486
487
  let lastUserInput;
487
- let pendingToolCall;
488
+ const lastUserContextByKey = new Map();
489
+ const pendingToolCallsByKey = new Map();
488
490
  function resolveOpenclawSessionId(hookCtx, eventSessionId) {
489
491
  const raw = hookCtx.sessionId?.trim() || eventSessionId?.trim();
490
- if (!raw)
491
- return undefined;
492
- return raw;
492
+ if (raw)
493
+ return raw;
494
+ const sessionKey = hookCtx.sessionKey?.trim();
495
+ const match = sessionKey?.match(/^agent:[^:]+:(.+)$/);
496
+ if (match?.[1])
497
+ return match[1];
498
+ return undefined;
493
499
  }
494
500
  const cozeloopTracePlugin = {
495
501
  id: "openclaw-cozeloop-trace",
@@ -558,6 +564,15 @@ const cozeloopTracePlugin = {
558
564
  const ctx = contextByRunId.get(runId);
559
565
  return ctx?.originalChannelId || ctx?.channelId;
560
566
  };
567
+ const sessionIdFromChannelId = (channelId) => {
568
+ const match = String(channelId || "").match(/^agent\/[^:]+:(.+)$/);
569
+ return match?.[1];
570
+ };
571
+ const canFallbackToLastUserContext = (rawChannelId) => {
572
+ const rawSessionId = sessionIdFromChannelId(rawChannelId);
573
+ const lastSessionId = lastUserTraceContext?.openclawSessionId || sessionIdFromChannelId(lastUserChannelId);
574
+ return !rawSessionId || !lastSessionId || rawSessionId === lastSessionId;
575
+ };
561
576
  const startTurn = (runId, channelId, originalChannelId, openclawSessionId) => {
562
577
  const traceId = generateId(32);
563
578
  const ctx = {
@@ -594,7 +609,7 @@ const cozeloopTracePlugin = {
594
609
  if (!activeCtx) {
595
610
  activeCtx = getContextByRun(effectiveRunId);
596
611
  }
597
- if (!activeCtx && rawChannelId.startsWith("agent/") && lastUserTraceContext) {
612
+ if (!activeCtx && rawChannelId.startsWith("agent/") && lastUserTraceContext && canFallbackToLastUserContext(rawChannelId)) {
598
613
  activeCtx = lastUserTraceContext;
599
614
  channelId = lastUserChannelId || channelId;
600
615
  contextByChannelId.set(rawChannelId, activeCtx);
@@ -616,19 +631,136 @@ const cozeloopTracePlugin = {
616
631
  }
617
632
  return { ctx: activeCtx, channelId, isNew };
618
633
  };
634
+ const contextKeys = (hookCtx, channelId, eventSessionId) => {
635
+ const keys = [];
636
+ const add = (value) => {
637
+ const s = String(value || "").trim();
638
+ if (s && !keys.includes(s))
639
+ keys.push(s);
640
+ };
641
+ add(resolveOpenclawSessionId(hookCtx || {}, eventSessionId));
642
+ add(hookCtx?.sessionKey);
643
+ add(hookCtx?.conversationId);
644
+ add(channelId);
645
+ return keys;
646
+ };
647
+ const rememberLastUserContext = (hookCtx, channelId, ctx) => {
648
+ for (const key of contextKeys(hookCtx, channelId)) {
649
+ lastUserContextByKey.set(key, { ctx, channelId });
650
+ }
651
+ };
652
+ const resolveLastUserContext = (hookCtx, channelId) => {
653
+ for (const key of contextKeys(hookCtx, channelId)) {
654
+ const found = lastUserContextByKey.get(key);
655
+ if (found)
656
+ return found;
657
+ }
658
+ if (lastUserTraceContext) {
659
+ return { ctx: lastUserTraceContext, channelId: lastUserChannelId || channelId };
660
+ }
661
+ return undefined;
662
+ };
663
+ const rememberActiveAgentContext = (hookCtx, channelId, ctx) => {
664
+ for (const key of contextKeys(hookCtx, channelId, ctx.openclawSessionId)) {
665
+ activeAgentContextByKey.set(key, { ctx, channelId });
666
+ }
667
+ activeAgentCtx = ctx;
668
+ activeAgentChannelId = channelId;
669
+ };
670
+ const resolveActiveAgentContext = (hookCtx, channelId) => {
671
+ for (const key of contextKeys(hookCtx, channelId)) {
672
+ const found = activeAgentContextByKey.get(key);
673
+ if (found)
674
+ return found;
675
+ }
676
+ if (activeAgentCtx) {
677
+ return { ctx: activeAgentCtx, channelId: activeAgentChannelId || channelId };
678
+ }
679
+ return undefined;
680
+ };
681
+ const clearActiveAgentContext = (ctx) => {
682
+ for (const [key, found] of activeAgentContextByKey.entries()) {
683
+ if (found.ctx === ctx)
684
+ activeAgentContextByKey.delete(key);
685
+ }
686
+ if (activeAgentCtx === ctx) {
687
+ activeAgentCtx = undefined;
688
+ activeAgentChannelId = undefined;
689
+ }
690
+ };
691
+ const clearLastUserContext = (ctx) => {
692
+ for (const [key, found] of lastUserContextByKey.entries()) {
693
+ if (found.ctx === ctx)
694
+ lastUserContextByKey.delete(key);
695
+ }
696
+ if (lastUserTraceContext === ctx) {
697
+ lastUserTraceContext = undefined;
698
+ lastUserChannelId = undefined;
699
+ }
700
+ };
619
701
  // Resolve context for hooks that fire between before_agent_start and
620
702
  // agent_end. When an agent is active, always return that agent's context
621
703
  // so every span ends up in the same Trace regardless of channelId drift.
622
- const resolveActiveContext = (rawChannelId, runId, hookName) => {
623
- if (activeAgentCtx) {
704
+ const resolveActiveContext = (rawChannelId, runId, hookName, hookCtx) => {
705
+ const active = resolveActiveAgentContext(hookCtx || {}, rawChannelId);
706
+ if (active) {
624
707
  if (config.debug) {
625
- api.logger.info(`[CozeloopTrace] Using activeAgentCtx for ${hookName}: traceId=${activeAgentCtx.traceId}, rootSpanId=${activeAgentCtx.rootSpanId}`);
708
+ api.logger.info(`[CozeloopTrace] Using activeAgentCtx for ${hookName}: traceId=${active.ctx.traceId}, rootSpanId=${active.ctx.rootSpanId}`);
626
709
  }
627
- return { ctx: activeAgentCtx, channelId: activeAgentChannelId || rawChannelId };
710
+ return active;
628
711
  }
629
712
  const { ctx, channelId } = getOrCreateContext(rawChannelId, runId, hookName);
630
713
  return { ctx, channelId };
631
714
  };
715
+ const getToolCallId = (event) => {
716
+ const candidates = [
717
+ event?.toolCallId,
718
+ event?.tool_call_id,
719
+ event?.callId,
720
+ event?.id,
721
+ event?.requestId,
722
+ event?.invocationId,
723
+ event?.params?.toolCallId,
724
+ event?.params?.tool_call_id,
725
+ ];
726
+ const found = candidates.find((value) => value !== undefined && value !== null && String(value).trim());
727
+ return found === undefined ? undefined : String(found);
728
+ };
729
+ const pendingToolKey = (ctx, channelId, toolName, toolCallId) => {
730
+ const traceKey = ctx.rootSpanId || ctx.traceId || channelId;
731
+ if (toolCallId)
732
+ return `${traceKey}:id:${toolCallId}`;
733
+ return `${traceKey}:name:${toolName || "unknown_tool"}`;
734
+ };
735
+ const pushPendingToolCall = (ctx, channelId, pending) => {
736
+ const key = pendingToolKey(ctx, channelId, pending.toolName, pending.toolCallId);
737
+ const queue = pendingToolCallsByKey.get(key) || [];
738
+ queue.push(pending);
739
+ pendingToolCallsByKey.set(key, queue);
740
+ return queue.length;
741
+ };
742
+ const shiftPendingToolCall = (ctx, channelId, toolName, toolCallId) => {
743
+ const key = pendingToolKey(ctx, channelId, toolName, toolCallId);
744
+ const queue = pendingToolCallsByKey.get(key);
745
+ if (!queue || queue.length === 0)
746
+ return undefined;
747
+ const pending = queue.shift();
748
+ if (queue.length === 0) {
749
+ pendingToolCallsByKey.delete(key);
750
+ }
751
+ return pending;
752
+ };
753
+ const clearPendingToolCallsForContext = (ctx) => {
754
+ for (const [key, queue] of pendingToolCallsByKey.entries()) {
755
+ const kept = queue.filter((pending) => pending.traceContext !== ctx);
756
+ if (kept.length === 0) {
757
+ pendingToolCallsByKey.delete(key);
758
+ }
759
+ else if (kept.length !== queue.length) {
760
+ pendingToolCallsByKey.set(key, kept);
761
+ }
762
+ }
763
+ };
632
764
  const createSpan = (ctx, channelId, name, type, startTime, endTime, attributes = {}, input, output, parentSpanId) => {
633
765
  return {
634
766
  name,
@@ -871,41 +1003,43 @@ const cozeloopTracePlugin = {
871
1003
  if (!role && event.from) {
872
1004
  role = "user";
873
1005
  }
874
- const isNonAgentChannel = !rawChannelId.startsWith("agent/");
875
- if (isNonAgentChannel) {
876
- if (role === "user" || !role) {
877
- lastUserChannelId = channelId;
878
- lastUserTraceContext = ctx;
879
- ctx.userInput = event.content;
880
- lastUserInput = event.content;
881
- // Cache any coze-context as soon as we see the user input,
882
- // so later /loop turns (which carry none) can inherit it.
883
- rememberCozeContext(event.content, ctx.openclawSessionId || lastOpenclawSessionId);
884
- if (config.debug) {
885
- api.logger.info(`[CozeloopTrace] Saved user context: channelId=${channelId}, traceId=${ctx.traceId}`);
886
- }
887
- }
888
- if (!ctx.userInput) {
889
- ctx.userInput = event.content;
890
- }
891
- if (!lastUserTraceContext) {
892
- lastUserTraceContext = ctx;
893
- lastUserChannelId = channelId;
1006
+ // coze-context 缓存对【所有 channel】生效,含 agent/ channel(Coze 群聊真实
1007
+ // 链路 channelId=agent/main:<session>)。此前只在 isNonAgentChannel 分支缓存,
1008
+ // 导致群聊消息的 <coze-context>(message_id/group_id 等)被整体跳过、root span
1009
+ // 拿不到 coze tag。parseCozeContext 解析不到时返回空、无副作用,故无条件 remember 安全。
1010
+ if (role === "user" || !role) {
1011
+ lastUserChannelId = channelId;
1012
+ lastUserTraceContext = ctx;
1013
+ rememberLastUserContext(hookCtx, channelId, ctx);
1014
+ ctx.userInput = event.content;
1015
+ lastUserInput = event.content;
1016
+ rememberCozeContext(event.content, ctx.openclawSessionId || lastOpenclawSessionId);
1017
+ if (config.debug) {
1018
+ api.logger.info(`[CozeloopTrace] Saved user context: channelId=${channelId}, traceId=${ctx.traceId}`);
894
1019
  }
895
1020
  }
1021
+ if (!ctx.userInput) {
1022
+ ctx.userInput = event.content;
1023
+ }
1024
+ if (!lastUserTraceContext) {
1025
+ lastUserTraceContext = ctx;
1026
+ lastUserChannelId = channelId;
1027
+ rememberLastUserContext(hookCtx, channelId, ctx);
1028
+ }
896
1029
  });
897
1030
  }
898
1031
  if (shouldHookEnabled("message_sending")) {
899
1032
  on("message_sending", async (event, hookCtx) => {
900
- if (lastUserTraceContext) {
901
- lastUserTraceContext.lastOutput = event.content;
1033
+ const rawChannelId = resolveChannelId(hookCtx, event.to);
1034
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1035
+ if (lastUser) {
1036
+ lastUser.ctx.lastOutput = event.content;
902
1037
  if (config.debug) {
903
- api.logger.info(`[CozeloopTrace] Captured output for root span: traceId=${lastUserTraceContext.traceId}, content=${typeof event.content === 'string' ? event.content.substring(0, 100) : 'non-string'}`);
1038
+ 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'}`);
904
1039
  }
905
1040
  }
906
1041
  else {
907
- const rawChannelId = resolveChannelId(hookCtx, event.to);
908
- const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending");
1042
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending", hookCtx);
909
1043
  ctx.lastOutput = event.content;
910
1044
  if (config.debug) {
911
1045
  api.logger.info(`[CozeloopTrace] Captured output (fallback) for root span: traceId=${ctx.traceId}`);
@@ -916,15 +1050,16 @@ const cozeloopTracePlugin = {
916
1050
  if (shouldHookEnabled("message_sent")) {
917
1051
  on("message_sent", async (event, hookCtx) => {
918
1052
  if (event.content && event.success) {
919
- if (lastUserTraceContext) {
920
- lastUserTraceContext.lastOutput = event.content;
1053
+ const rawChannelId = resolveChannelId(hookCtx, event.to);
1054
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1055
+ if (lastUser) {
1056
+ lastUser.ctx.lastOutput = event.content;
921
1057
  if (config.debug) {
922
- api.logger.info(`[CozeloopTrace] Captured output from message_sent: traceId=${lastUserTraceContext.traceId}`);
1058
+ api.logger.info(`[CozeloopTrace] Captured output from message_sent: traceId=${lastUser.ctx.traceId}`);
923
1059
  }
924
1060
  }
925
1061
  else {
926
- const rawChannelId = resolveChannelId(hookCtx, event.to);
927
- const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent");
1062
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent", hookCtx);
928
1063
  ctx.lastOutput = event.content;
929
1064
  if (config.debug) {
930
1065
  api.logger.info(`[CozeloopTrace] Captured output from message_sent (fallback): traceId=${ctx.traceId}`);
@@ -942,7 +1077,7 @@ const cozeloopTracePlugin = {
942
1077
  if (config.debug) {
943
1078
  api.logger.info(`[CozeloopTrace] llm_input hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
944
1079
  }
945
- const { ctx } = resolveActiveContext(rawChannelId, event.runId, "llm_input");
1080
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_input", hookCtx);
946
1081
  const ocSessionId = resolveOpenclawSessionId(hookCtx);
947
1082
  if (ocSessionId) {
948
1083
  ctx.openclawSessionId = ocSessionId;
@@ -967,9 +1102,10 @@ const cozeloopTracePlugin = {
967
1102
  rememberCozeContext(event.prompt, ctx.openclawSessionId || lastOpenclawSessionId);
968
1103
  // Fallback: ensure root + agent spans exist in case before_agent_start
969
1104
  // was not fired (older OpenClaw versions or resumed sessions).
970
- const channelIdForSpans = activeAgentChannelId || rawChannelId;
1105
+ const active = resolveActiveAgentContext(hookCtx, channelId);
1106
+ const channelIdForSpans = active?.channelId || channelId || rawChannelId;
971
1107
  await ensureRootSpan(ctx, channelIdForSpans);
972
- await ensureAgentSpan(ctx, channelIdForSpans);
1108
+ await ensureAgentSpan(ctx, channelIdForSpans, undefined, hookCtx);
973
1109
  const messages = [];
974
1110
  if (event.systemPrompt) {
975
1111
  messages.push({ role: "system", content: safeClone(event.systemPrompt) });
@@ -1036,14 +1172,15 @@ const cozeloopTracePlugin = {
1036
1172
  api.logger.info(`[CozeloopTrace][DEBUG] llm_output event keys=${JSON.stringify(Object.keys(event))}`);
1037
1173
  api.logger.info(`[CozeloopTrace] llm_output hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
1038
1174
  }
1039
- const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output");
1175
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output", hookCtx);
1040
1176
  const now = Date.now();
1041
1177
  const startTime = ctx.llmStartTime || lastLlmStartTime || now;
1042
1178
  if (event.assistantTexts && event.assistantTexts.length > 0) {
1043
1179
  const outputText = event.assistantTexts.join("\n");
1044
1180
  ctx.lastOutput = outputText;
1045
- if (lastUserTraceContext) {
1046
- lastUserTraceContext.lastOutput = outputText;
1181
+ const lastUser = resolveLastUserContext(hookCtx, channelId);
1182
+ if (lastUser) {
1183
+ lastUser.ctx.lastOutput = outputText;
1047
1184
  }
1048
1185
  if (config.debug) {
1049
1186
  api.logger.info(`[CozeloopTrace] Captured output from llm_output (will use last): traceId=${ctx.traceId}, length=${outputText.length}`);
@@ -1129,34 +1266,39 @@ const cozeloopTracePlugin = {
1129
1266
  if (config.debug) {
1130
1267
  api.logger.info(`[CozeloopTrace] before_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1131
1268
  }
1132
- const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call");
1133
- pendingToolCall = {
1269
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call", hookCtx);
1270
+ const pendingToolCall = {
1134
1271
  toolName: event.toolName,
1272
+ toolCallId: getToolCallId(event),
1135
1273
  toolSpanId: generateId(16),
1136
1274
  toolStartTime: Date.now(),
1137
1275
  toolInput: event.params,
1138
1276
  traceContext: ctx,
1139
1277
  channelId: channelId,
1140
1278
  };
1279
+ const pendingCount = pushPendingToolCall(ctx, channelId, pendingToolCall);
1141
1280
  ctx.reactCount = (ctx.reactCount || 0) + 1;
1142
1281
  if (config.debug) {
1143
- api.logger.info(`[CozeloopTrace] Tool call started: ${event.toolName}, spanId=${pendingToolCall.toolSpanId}, traceId=${ctx.traceId}`);
1282
+ api.logger.info(`[CozeloopTrace] Tool call started: ${event.toolName}, toolCallId=${pendingToolCall.toolCallId || "none"}, spanId=${pendingToolCall.toolSpanId}, traceId=${ctx.traceId}, pendingCount=${pendingCount}`);
1144
1283
  }
1145
1284
  });
1146
1285
  }
1147
1286
  if (shouldHookEnabled("after_tool_call")) {
1148
1287
  on("after_tool_call", async (event, hookCtx) => {
1288
+ const rawChannelId = resolveChannelId(hookCtx);
1289
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "after_tool_call", hookCtx);
1149
1290
  if (config.debug) {
1150
1291
  api.logger.info(`[CozeloopTrace] after_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1151
1292
  }
1152
- if (!pendingToolCall || pendingToolCall.toolName !== event.toolName) {
1293
+ const toolCallId = getToolCallId(event);
1294
+ const pendingToolCall = shiftPendingToolCall(ctx, channelId, event.toolName, toolCallId);
1295
+ if (!pendingToolCall) {
1153
1296
  if (config.debug) {
1154
- api.logger.info(`[CozeloopTrace] Skipping after_tool_call: no pending tool or name mismatch, toolName=${event.toolName}, pending=${pendingToolCall?.toolName}`);
1297
+ api.logger.info(`[CozeloopTrace] Skipping after_tool_call: no pending tool, toolName=${event.toolName}, toolCallId=${toolCallId || "none"}, pendingKeys=${pendingToolCallsByKey.size}`);
1155
1298
  }
1156
1299
  return;
1157
1300
  }
1158
1301
  const { toolName, toolSpanId, toolStartTime, toolInput, traceContext } = pendingToolCall;
1159
- pendingToolCall = undefined;
1160
1302
  const now = Date.now();
1161
1303
  if (!traceContext.pendingToolSpans) {
1162
1304
  traceContext.pendingToolSpans = [];
@@ -1178,11 +1320,12 @@ const cozeloopTracePlugin = {
1178
1320
  // Helper: finalize a trace — end agent span (if open), end root span, flush,
1179
1321
  // and clean up all state. Called from agent_end (normal path) and
1180
1322
  // session_end (fallback for old OpenClaw versions that don't emit agent_end).
1181
- let traceFinalized = false;
1323
+ const finalizingTraces = new Set();
1182
1324
  const finalizeTrace = (ctx, channelId, agentEndAttrs, agentOutput) => {
1183
- if (traceFinalized)
1325
+ const finalizeKey = ctx.rootSpanId || ctx.traceId;
1326
+ if (finalizingTraces.has(finalizeKey))
1184
1327
  return;
1185
- traceFinalized = true;
1328
+ finalizingTraces.add(finalizeKey);
1186
1329
  const now = Date.now();
1187
1330
  // End agent span if still open.
1188
1331
  if (ctx.agentSpanId) {
@@ -1216,20 +1359,17 @@ const cozeloopTracePlugin = {
1216
1359
  }
1217
1360
  await exporter.flush();
1218
1361
  exporter.endTrace(rootSpanId);
1219
- if (activeAgentCtx === ctx) {
1220
- activeAgentCtx = undefined;
1221
- activeAgentChannelId = undefined;
1222
- }
1362
+ clearPendingToolCallsForContext(ctx);
1363
+ clearActiveAgentContext(ctx);
1223
1364
  if (savedLastUserChannelId) {
1224
1365
  endTurn(savedLastUserChannelId);
1225
1366
  }
1226
1367
  if (originalChannelId && originalChannelId !== savedLastUserChannelId) {
1227
1368
  endTurn(originalChannelId);
1228
1369
  }
1229
- lastUserChannelId = undefined;
1230
- lastUserTraceContext = undefined;
1370
+ clearLastUserContext(ctx);
1231
1371
  lastUserInput = undefined;
1232
- traceFinalized = false;
1372
+ finalizingTraces.delete(finalizeKey);
1233
1373
  }, 200);
1234
1374
  };
1235
1375
  // OpenClaw runtime 周期性发送的心跳轮询消息,不是真实对话,整条 trace 丢弃。
@@ -1323,9 +1463,11 @@ const cozeloopTracePlugin = {
1323
1463
  };
1324
1464
  // Helper: ensure the agent span exists for a given context.
1325
1465
  // Safe to call multiple times — only creates the span once.
1326
- const ensureAgentSpan = async (ctx, channelId, agentId) => {
1327
- if (ctx.agentSpanId)
1466
+ const ensureAgentSpan = async (ctx, channelId, agentId, hookCtx) => {
1467
+ if (ctx.agentSpanId) {
1468
+ rememberActiveAgentContext(hookCtx || {}, channelId, ctx);
1328
1469
  return;
1470
+ }
1329
1471
  const effectiveAgentId = agentId || "main";
1330
1472
  const now = Date.now();
1331
1473
  ctx.agentStartTime = now;
@@ -1348,8 +1490,7 @@ const cozeloopTracePlugin = {
1348
1490
  };
1349
1491
  await exporter.startSpan(spanData, ctx.agentSpanId);
1350
1492
  // Set active agent context so all subsequent hooks use the same Trace.
1351
- activeAgentCtx = ctx;
1352
- activeAgentChannelId = channelId;
1493
+ rememberActiveAgentContext(hookCtx || {}, channelId, ctx);
1353
1494
  if (config.debug) {
1354
1495
  api.logger.info(`[CozeloopTrace] ensureAgentSpan: created agent span, agentId=${effectiveAgentId}, spanId=${ctx.agentSpanId}, traceId=${ctx.traceId}`);
1355
1496
  }
@@ -1369,7 +1510,7 @@ const cozeloopTracePlugin = {
1369
1510
  }
1370
1511
  ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
1371
1512
  await ensureRootSpan(ctx, channelId);
1372
- await ensureAgentSpan(ctx, channelId, agentId);
1513
+ await ensureAgentSpan(ctx, channelId, agentId, hookCtx);
1373
1514
  });
1374
1515
  }
1375
1516
  if (shouldHookEnabled("agent_end")) {
@@ -1379,8 +1520,9 @@ const cozeloopTracePlugin = {
1379
1520
  api.logger.info(`[CozeloopTrace] agent_end hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}`);
1380
1521
  }
1381
1522
  // Use activeAgentCtx if available, otherwise fall back to resolution.
1382
- const ctx = activeAgentCtx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
1383
- const channelId = activeAgentChannelId || rawChannelId;
1523
+ const active = resolveActiveAgentContext(hookCtx, rawChannelId);
1524
+ const ctx = active?.ctx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
1525
+ const channelId = active?.channelId || rawChannelId;
1384
1526
  finalizeTrace(ctx, channelId, {
1385
1527
  "agent.duration_ms": event.durationMs || 0,
1386
1528
  "agent.message_count": event.messageCount || 0,
@@ -1398,9 +1540,11 @@ const cozeloopTracePlugin = {
1398
1540
  if (config.debug) {
1399
1541
  api.logger.info(`[CozeloopTrace] session_end: ${rawChannelId}`);
1400
1542
  }
1401
- const ctx = activeAgentCtx || lastUserTraceContext;
1543
+ const active = resolveActiveAgentContext(hookCtx, rawChannelId);
1544
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1545
+ const ctx = active?.ctx || lastUser?.ctx;
1402
1546
  if (ctx && ctx.rootSpanStartTime) {
1403
- const channelId = activeAgentChannelId || lastUserChannelId || rawChannelId;
1547
+ const channelId = active?.channelId || lastUser?.channelId || rawChannelId;
1404
1548
  if (config.debug) {
1405
1549
  api.logger.info(`[CozeloopTrace] session_end: finalizing trace as fallback, traceId=${ctx.traceId}`);
1406
1550
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cozeloop-trace",
3
3
  "name": "OpenClaw CozeLoop Trace",
4
- "version": "0.1.13",
4
+ "version": "0.1.15",
5
5
  "description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
6
6
  "type": "plugin",
7
7
  "entry": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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"