coze_lab 0.1.30 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.30",
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,
@@ -878,6 +1010,7 @@ const cozeloopTracePlugin = {
878
1010
  if (role === "user" || !role) {
879
1011
  lastUserChannelId = channelId;
880
1012
  lastUserTraceContext = ctx;
1013
+ rememberLastUserContext(hookCtx, channelId, ctx);
881
1014
  ctx.userInput = event.content;
882
1015
  lastUserInput = event.content;
883
1016
  rememberCozeContext(event.content, ctx.openclawSessionId || lastOpenclawSessionId);
@@ -891,20 +1024,22 @@ const cozeloopTracePlugin = {
891
1024
  if (!lastUserTraceContext) {
892
1025
  lastUserTraceContext = ctx;
893
1026
  lastUserChannelId = channelId;
1027
+ rememberLastUserContext(hookCtx, channelId, ctx);
894
1028
  }
895
1029
  });
896
1030
  }
897
1031
  if (shouldHookEnabled("message_sending")) {
898
1032
  on("message_sending", async (event, hookCtx) => {
899
- if (lastUserTraceContext) {
900
- 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;
901
1037
  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'}`);
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'}`);
903
1039
  }
904
1040
  }
905
1041
  else {
906
- const rawChannelId = resolveChannelId(hookCtx, event.to);
907
- const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending");
1042
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending", hookCtx);
908
1043
  ctx.lastOutput = event.content;
909
1044
  if (config.debug) {
910
1045
  api.logger.info(`[CozeloopTrace] Captured output (fallback) for root span: traceId=${ctx.traceId}`);
@@ -915,15 +1050,16 @@ const cozeloopTracePlugin = {
915
1050
  if (shouldHookEnabled("message_sent")) {
916
1051
  on("message_sent", async (event, hookCtx) => {
917
1052
  if (event.content && event.success) {
918
- if (lastUserTraceContext) {
919
- 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;
920
1057
  if (config.debug) {
921
- 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}`);
922
1059
  }
923
1060
  }
924
1061
  else {
925
- const rawChannelId = resolveChannelId(hookCtx, event.to);
926
- const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent");
1062
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent", hookCtx);
927
1063
  ctx.lastOutput = event.content;
928
1064
  if (config.debug) {
929
1065
  api.logger.info(`[CozeloopTrace] Captured output from message_sent (fallback): traceId=${ctx.traceId}`);
@@ -941,7 +1077,7 @@ const cozeloopTracePlugin = {
941
1077
  if (config.debug) {
942
1078
  api.logger.info(`[CozeloopTrace] llm_input hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
943
1079
  }
944
- const { ctx } = resolveActiveContext(rawChannelId, event.runId, "llm_input");
1080
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_input", hookCtx);
945
1081
  const ocSessionId = resolveOpenclawSessionId(hookCtx);
946
1082
  if (ocSessionId) {
947
1083
  ctx.openclawSessionId = ocSessionId;
@@ -966,9 +1102,10 @@ const cozeloopTracePlugin = {
966
1102
  rememberCozeContext(event.prompt, ctx.openclawSessionId || lastOpenclawSessionId);
967
1103
  // Fallback: ensure root + agent spans exist in case before_agent_start
968
1104
  // was not fired (older OpenClaw versions or resumed sessions).
969
- const channelIdForSpans = activeAgentChannelId || rawChannelId;
1105
+ const active = resolveActiveAgentContext(hookCtx, channelId);
1106
+ const channelIdForSpans = active?.channelId || channelId || rawChannelId;
970
1107
  await ensureRootSpan(ctx, channelIdForSpans);
971
- await ensureAgentSpan(ctx, channelIdForSpans);
1108
+ await ensureAgentSpan(ctx, channelIdForSpans, undefined, hookCtx);
972
1109
  const messages = [];
973
1110
  if (event.systemPrompt) {
974
1111
  messages.push({ role: "system", content: safeClone(event.systemPrompt) });
@@ -1035,14 +1172,15 @@ const cozeloopTracePlugin = {
1035
1172
  api.logger.info(`[CozeloopTrace][DEBUG] llm_output event keys=${JSON.stringify(Object.keys(event))}`);
1036
1173
  api.logger.info(`[CozeloopTrace] llm_output hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
1037
1174
  }
1038
- const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output");
1175
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output", hookCtx);
1039
1176
  const now = Date.now();
1040
1177
  const startTime = ctx.llmStartTime || lastLlmStartTime || now;
1041
1178
  if (event.assistantTexts && event.assistantTexts.length > 0) {
1042
1179
  const outputText = event.assistantTexts.join("\n");
1043
1180
  ctx.lastOutput = outputText;
1044
- if (lastUserTraceContext) {
1045
- lastUserTraceContext.lastOutput = outputText;
1181
+ const lastUser = resolveLastUserContext(hookCtx, channelId);
1182
+ if (lastUser) {
1183
+ lastUser.ctx.lastOutput = outputText;
1046
1184
  }
1047
1185
  if (config.debug) {
1048
1186
  api.logger.info(`[CozeloopTrace] Captured output from llm_output (will use last): traceId=${ctx.traceId}, length=${outputText.length}`);
@@ -1128,34 +1266,39 @@ const cozeloopTracePlugin = {
1128
1266
  if (config.debug) {
1129
1267
  api.logger.info(`[CozeloopTrace] before_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1130
1268
  }
1131
- const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call");
1132
- pendingToolCall = {
1269
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call", hookCtx);
1270
+ const pendingToolCall = {
1133
1271
  toolName: event.toolName,
1272
+ toolCallId: getToolCallId(event),
1134
1273
  toolSpanId: generateId(16),
1135
1274
  toolStartTime: Date.now(),
1136
1275
  toolInput: event.params,
1137
1276
  traceContext: ctx,
1138
1277
  channelId: channelId,
1139
1278
  };
1279
+ const pendingCount = pushPendingToolCall(ctx, channelId, pendingToolCall);
1140
1280
  ctx.reactCount = (ctx.reactCount || 0) + 1;
1141
1281
  if (config.debug) {
1142
- 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}`);
1143
1283
  }
1144
1284
  });
1145
1285
  }
1146
1286
  if (shouldHookEnabled("after_tool_call")) {
1147
1287
  on("after_tool_call", async (event, hookCtx) => {
1288
+ const rawChannelId = resolveChannelId(hookCtx);
1289
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "after_tool_call", hookCtx);
1148
1290
  if (config.debug) {
1149
1291
  api.logger.info(`[CozeloopTrace] after_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1150
1292
  }
1151
- if (!pendingToolCall || pendingToolCall.toolName !== event.toolName) {
1293
+ const toolCallId = getToolCallId(event);
1294
+ const pendingToolCall = shiftPendingToolCall(ctx, channelId, event.toolName, toolCallId);
1295
+ if (!pendingToolCall) {
1152
1296
  if (config.debug) {
1153
- 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}`);
1154
1298
  }
1155
1299
  return;
1156
1300
  }
1157
1301
  const { toolName, toolSpanId, toolStartTime, toolInput, traceContext } = pendingToolCall;
1158
- pendingToolCall = undefined;
1159
1302
  const now = Date.now();
1160
1303
  if (!traceContext.pendingToolSpans) {
1161
1304
  traceContext.pendingToolSpans = [];
@@ -1177,11 +1320,12 @@ const cozeloopTracePlugin = {
1177
1320
  // Helper: finalize a trace — end agent span (if open), end root span, flush,
1178
1321
  // and clean up all state. Called from agent_end (normal path) and
1179
1322
  // session_end (fallback for old OpenClaw versions that don't emit agent_end).
1180
- let traceFinalized = false;
1323
+ const finalizingTraces = new Set();
1181
1324
  const finalizeTrace = (ctx, channelId, agentEndAttrs, agentOutput) => {
1182
- if (traceFinalized)
1325
+ const finalizeKey = ctx.rootSpanId || ctx.traceId;
1326
+ if (finalizingTraces.has(finalizeKey))
1183
1327
  return;
1184
- traceFinalized = true;
1328
+ finalizingTraces.add(finalizeKey);
1185
1329
  const now = Date.now();
1186
1330
  // End agent span if still open.
1187
1331
  if (ctx.agentSpanId) {
@@ -1215,20 +1359,17 @@ const cozeloopTracePlugin = {
1215
1359
  }
1216
1360
  await exporter.flush();
1217
1361
  exporter.endTrace(rootSpanId);
1218
- if (activeAgentCtx === ctx) {
1219
- activeAgentCtx = undefined;
1220
- activeAgentChannelId = undefined;
1221
- }
1362
+ clearPendingToolCallsForContext(ctx);
1363
+ clearActiveAgentContext(ctx);
1222
1364
  if (savedLastUserChannelId) {
1223
1365
  endTurn(savedLastUserChannelId);
1224
1366
  }
1225
1367
  if (originalChannelId && originalChannelId !== savedLastUserChannelId) {
1226
1368
  endTurn(originalChannelId);
1227
1369
  }
1228
- lastUserChannelId = undefined;
1229
- lastUserTraceContext = undefined;
1370
+ clearLastUserContext(ctx);
1230
1371
  lastUserInput = undefined;
1231
- traceFinalized = false;
1372
+ finalizingTraces.delete(finalizeKey);
1232
1373
  }, 200);
1233
1374
  };
1234
1375
  // OpenClaw runtime 周期性发送的心跳轮询消息,不是真实对话,整条 trace 丢弃。
@@ -1322,9 +1463,11 @@ const cozeloopTracePlugin = {
1322
1463
  };
1323
1464
  // Helper: ensure the agent span exists for a given context.
1324
1465
  // Safe to call multiple times — only creates the span once.
1325
- const ensureAgentSpan = async (ctx, channelId, agentId) => {
1326
- if (ctx.agentSpanId)
1466
+ const ensureAgentSpan = async (ctx, channelId, agentId, hookCtx) => {
1467
+ if (ctx.agentSpanId) {
1468
+ rememberActiveAgentContext(hookCtx || {}, channelId, ctx);
1327
1469
  return;
1470
+ }
1328
1471
  const effectiveAgentId = agentId || "main";
1329
1472
  const now = Date.now();
1330
1473
  ctx.agentStartTime = now;
@@ -1347,8 +1490,7 @@ const cozeloopTracePlugin = {
1347
1490
  };
1348
1491
  await exporter.startSpan(spanData, ctx.agentSpanId);
1349
1492
  // Set active agent context so all subsequent hooks use the same Trace.
1350
- activeAgentCtx = ctx;
1351
- activeAgentChannelId = channelId;
1493
+ rememberActiveAgentContext(hookCtx || {}, channelId, ctx);
1352
1494
  if (config.debug) {
1353
1495
  api.logger.info(`[CozeloopTrace] ensureAgentSpan: created agent span, agentId=${effectiveAgentId}, spanId=${ctx.agentSpanId}, traceId=${ctx.traceId}`);
1354
1496
  }
@@ -1368,7 +1510,7 @@ const cozeloopTracePlugin = {
1368
1510
  }
1369
1511
  ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
1370
1512
  await ensureRootSpan(ctx, channelId);
1371
- await ensureAgentSpan(ctx, channelId, agentId);
1513
+ await ensureAgentSpan(ctx, channelId, agentId, hookCtx);
1372
1514
  });
1373
1515
  }
1374
1516
  if (shouldHookEnabled("agent_end")) {
@@ -1378,8 +1520,9 @@ const cozeloopTracePlugin = {
1378
1520
  api.logger.info(`[CozeloopTrace] agent_end hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}`);
1379
1521
  }
1380
1522
  // Use activeAgentCtx if available, otherwise fall back to resolution.
1381
- const ctx = activeAgentCtx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
1382
- 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;
1383
1526
  finalizeTrace(ctx, channelId, {
1384
1527
  "agent.duration_ms": event.durationMs || 0,
1385
1528
  "agent.message_count": event.messageCount || 0,
@@ -1397,9 +1540,11 @@ const cozeloopTracePlugin = {
1397
1540
  if (config.debug) {
1398
1541
  api.logger.info(`[CozeloopTrace] session_end: ${rawChannelId}`);
1399
1542
  }
1400
- const ctx = activeAgentCtx || lastUserTraceContext;
1543
+ const active = resolveActiveAgentContext(hookCtx, rawChannelId);
1544
+ const lastUser = resolveLastUserContext(hookCtx, rawChannelId);
1545
+ const ctx = active?.ctx || lastUser?.ctx;
1401
1546
  if (ctx && ctx.rootSpanStartTime) {
1402
- const channelId = activeAgentChannelId || lastUserChannelId || rawChannelId;
1547
+ const channelId = active?.channelId || lastUser?.channelId || rawChannelId;
1403
1548
  if (config.debug) {
1404
1549
  api.logger.info(`[CozeloopTrace] session_end: finalizing trace as fallback, traceId=${ctx.traceId}`);
1405
1550
  }
@@ -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.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.14",
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"