coze_lab 0.1.0

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.
@@ -0,0 +1,1315 @@
1
+ import { CozeloopExporter } from "./cozeloop-exporter.js";
2
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { createRequire } from "node:module";
5
+ const require = createRequire(import.meta.url);
6
+ const { version: PLUGIN_VERSION } = require("../package.json");
7
+ import { homedir } from "node:os";
8
+ function generateId(length = 16) {
9
+ const chars = "0123456789abcdef";
10
+ let result = "";
11
+ for (let i = 0; i < length; i++) {
12
+ result += chars[Math.floor(Math.random() * chars.length)];
13
+ }
14
+ return result;
15
+ }
16
+ function safeClone(value) {
17
+ if (typeof globalThis.structuredClone === "function") {
18
+ return globalThis.structuredClone(value);
19
+ }
20
+ return JSON.parse(JSON.stringify(value));
21
+ }
22
+ // --- coze-context parsing ---------------------------------------------------
23
+ // User input may embed a <coze-context>...</coze-context> block with key:value
24
+ // lines (agent_id, session_id, message_id, account_id, ...). Parse the LAST
25
+ // block and inject the pairs (prefixed "coze.") into the root span attributes.
26
+ const COZE_CTX_OPEN = "<coze-context>";
27
+ const COZE_CTX_CLOSE = "</coze-context>";
28
+ function cozeInputToText(input) {
29
+ if (input == null)
30
+ return "";
31
+ if (typeof input === "string")
32
+ return input;
33
+ if (Array.isArray(input)) {
34
+ return input
35
+ .map((item) => {
36
+ if (typeof item === "string")
37
+ return item;
38
+ if (item && typeof item === "object" && typeof item.text === "string")
39
+ return item.text;
40
+ return "";
41
+ })
42
+ .join("\n");
43
+ }
44
+ if (typeof input === "object" && typeof input.text === "string")
45
+ return input.text;
46
+ return "";
47
+ }
48
+ function parseCozeContext(input) {
49
+ const text = cozeInputToText(input);
50
+ if (!text || !text.includes(COZE_CTX_OPEN))
51
+ return {};
52
+ const openIdx = text.lastIndexOf(COZE_CTX_OPEN);
53
+ const closeIdx = text.indexOf(COZE_CTX_CLOSE, openIdx);
54
+ if (closeIdx === -1)
55
+ return {};
56
+ let body = text.slice(openIdx + COZE_CTX_OPEN.length, closeIdx);
57
+ // The block may arrive with real newlines, OR with literal backslash-n
58
+ // (e.g. when the whole message is an embedded JSON string never un-escaped).
59
+ // Normalize both forms before splitting into lines.
60
+ body = body.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n");
61
+ const out = {};
62
+ for (const rawLine of body.split("\n")) {
63
+ const line = rawLine.trim();
64
+ const sep = line.indexOf(":");
65
+ if (sep <= 0)
66
+ continue;
67
+ const key = line.slice(0, sep).trim();
68
+ const value = line.slice(sep + 1).trim();
69
+ if (key)
70
+ out["coze." + key] = value;
71
+ }
72
+ return out;
73
+ }
74
+ function resolveOpenclawStateDir() {
75
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
76
+ if (override)
77
+ return resolve(override.startsWith("~") ? override.replace(/^~(?=$|[\\/])/, homedir()) : override);
78
+ const home = homedir();
79
+ const newDir = join(home, ".openclaw");
80
+ try {
81
+ if (existsSync(newDir))
82
+ return newDir;
83
+ }
84
+ catch { /* ignore */ }
85
+ for (const legacy of [".clawdbot", ".moldbot", ".moltbot"]) {
86
+ const legacyDir = join(home, legacy);
87
+ try {
88
+ if (existsSync(legacyDir))
89
+ return legacyDir;
90
+ }
91
+ catch { /* ignore */ }
92
+ }
93
+ return newDir;
94
+ }
95
+ function resolveAgentIdFromHookCtx(hookCtx) {
96
+ const explicit = hookCtx.agentId?.trim()?.toLowerCase();
97
+ if (explicit)
98
+ return explicit;
99
+ const sessionKey = hookCtx.sessionKey?.trim()?.toLowerCase();
100
+ if (sessionKey) {
101
+ const match = sessionKey.match(/^agent:([^:]+):/);
102
+ if (match?.[1])
103
+ return match[1];
104
+ }
105
+ return "main";
106
+ }
107
+ function resolveSessionFile(hookCtx) {
108
+ try {
109
+ const stateDir = resolveOpenclawStateDir();
110
+ const agentId = resolveAgentIdFromHookCtx(hookCtx);
111
+ const sessionsDir = join(stateDir, "agents", agentId, "sessions");
112
+ const sessionId = (hookCtx.sessionId || "");
113
+ let targetFile;
114
+ const files = readdirSync(sessionsDir);
115
+ if (sessionId) {
116
+ for (const f of files) {
117
+ if (!f.endsWith(".jsonl"))
118
+ continue;
119
+ if (f.includes(".deleted.") || f.includes(".reset."))
120
+ continue;
121
+ if (f.startsWith(sessionId)) {
122
+ targetFile = join(sessionsDir, f);
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ if (!targetFile) {
128
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.includes(".deleted.") && !f.includes(".reset."));
129
+ if (jsonlFiles.length > 0) {
130
+ targetFile = join(sessionsDir, jsonlFiles[jsonlFiles.length - 1]);
131
+ }
132
+ }
133
+ return targetFile;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ function formatAssistantOutput(content, stopReason) {
140
+ const contentItems = Array.isArray(content) ? content : [];
141
+ const toolCalls = [];
142
+ const messageContent = [];
143
+ for (const item of contentItems) {
144
+ if (!item || typeof item !== "object") {
145
+ messageContent.push(item);
146
+ continue;
147
+ }
148
+ const itemType = item.type;
149
+ if (itemType === "toolCall") {
150
+ toolCalls.push({
151
+ function: {
152
+ arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? item.input ?? {}),
153
+ name: item.name ?? "",
154
+ },
155
+ id: item.id ?? "",
156
+ type: "function",
157
+ });
158
+ }
159
+ else if (itemType === "text") {
160
+ messageContent.push({ type: "text", text: item.text ?? "" });
161
+ }
162
+ else if (itemType === "thinking") {
163
+ messageContent.push({
164
+ type: "thinking",
165
+ thinking: item.thinking ?? "",
166
+ signature: item.signature,
167
+ });
168
+ }
169
+ else {
170
+ messageContent.push(item);
171
+ }
172
+ }
173
+ const message = {
174
+ content: messageContent.length === 1 && messageContent[0]?.type === "text"
175
+ ? (messageContent[0].text ?? "")
176
+ : messageContent,
177
+ role: "assistant",
178
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
179
+ function_call: null,
180
+ provider_specific_fields: null,
181
+ };
182
+ return {
183
+ choices: [
184
+ {
185
+ finish_reason: stopReason === "toolUse" ? "tool_calls" : "stop",
186
+ message,
187
+ },
188
+ ],
189
+ };
190
+ }
191
+ function convertAssistantContentForMessages(content) {
192
+ if (!Array.isArray(content))
193
+ return [{ type: "text", text: String(content ?? "") }];
194
+ const result = [];
195
+ for (const item of content) {
196
+ if (!item || typeof item !== "object") {
197
+ result.push(item);
198
+ continue;
199
+ }
200
+ if (item.type === "toolCall") {
201
+ result.push({
202
+ type: "tool_use",
203
+ id: item.id ?? "",
204
+ name: item.name ?? "",
205
+ input: item.arguments ?? item.input ?? {},
206
+ });
207
+ }
208
+ else if (item.type === "thinking") {
209
+ result.push({
210
+ type: "thinking",
211
+ thinking: item.thinking ?? "",
212
+ signature: item.signature,
213
+ });
214
+ }
215
+ else {
216
+ result.push(item);
217
+ }
218
+ }
219
+ return result;
220
+ }
221
+ function convertToolResultsForMessages(toolResultEntries) {
222
+ const messages = [];
223
+ for (const tr of toolResultEntries) {
224
+ let textContent = "";
225
+ if (Array.isArray(tr.content)) {
226
+ const parts = tr.content;
227
+ textContent = parts
228
+ .filter((p) => p?.type === "text")
229
+ .map((p) => String(p.text ?? ""))
230
+ .join("\n");
231
+ }
232
+ else if (typeof tr.content === "string") {
233
+ textContent = tr.content;
234
+ }
235
+ else {
236
+ textContent = JSON.stringify(tr.content);
237
+ }
238
+ messages.push({
239
+ role: "tool",
240
+ content: textContent,
241
+ tool_call_id: tr.toolCallId ?? "",
242
+ });
243
+ }
244
+ return messages;
245
+ }
246
+ function parseEntryWrittenAt(entry) {
247
+ const ts = entry.timestamp;
248
+ if (typeof ts === "string") {
249
+ const ms = new Date(ts).getTime();
250
+ if (!Number.isNaN(ms))
251
+ return ms;
252
+ }
253
+ if (typeof ts === "number")
254
+ return ts;
255
+ return undefined;
256
+ }
257
+ function readCurrentTurnReactSequence(hookCtx) {
258
+ try {
259
+ const targetFile = resolveSessionFile(hookCtx);
260
+ if (!targetFile)
261
+ return { entries: [] };
262
+ const raw = readFileSync(targetFile, "utf-8");
263
+ const lines = raw.trim().split("\n");
264
+ let lastUserIdx = -1;
265
+ let userWrittenAt;
266
+ let userContent;
267
+ for (let i = lines.length - 1; i >= 0; i--) {
268
+ const line = lines[i].trim();
269
+ if (!line)
270
+ continue;
271
+ try {
272
+ const entry = JSON.parse(line);
273
+ if (entry.type !== "message")
274
+ continue;
275
+ const msg = entry.message;
276
+ if (msg?.role === "user") {
277
+ lastUserIdx = i;
278
+ userWrittenAt = parseEntryWrittenAt(entry);
279
+ userContent = msg.content;
280
+ break;
281
+ }
282
+ }
283
+ catch {
284
+ continue;
285
+ }
286
+ }
287
+ if (lastUserIdx < 0)
288
+ return { entries: [] };
289
+ const entries = [];
290
+ for (let i = lastUserIdx + 1; i < lines.length; i++) {
291
+ const line = lines[i].trim();
292
+ if (!line)
293
+ continue;
294
+ try {
295
+ const entry = JSON.parse(line);
296
+ if (entry.type !== "message")
297
+ continue;
298
+ const msg = entry.message;
299
+ if (!msg)
300
+ continue;
301
+ const writtenAt = parseEntryWrittenAt(entry);
302
+ if (msg.role === "assistant") {
303
+ entries.push({
304
+ type: "assistant",
305
+ content: msg.content,
306
+ provider: msg.provider,
307
+ model: msg.model,
308
+ usage: msg.usage,
309
+ stopReason: msg.stopReason,
310
+ timestamp: msg.timestamp,
311
+ writtenAt,
312
+ });
313
+ }
314
+ else if (msg.role === "toolResult") {
315
+ entries.push({
316
+ type: "toolResult",
317
+ content: msg.content,
318
+ toolCallId: msg.toolCallId,
319
+ toolName: msg.toolName,
320
+ isError: msg.isError,
321
+ timestamp: msg.timestamp,
322
+ writtenAt,
323
+ });
324
+ }
325
+ }
326
+ catch {
327
+ continue;
328
+ }
329
+ }
330
+ return { entries, userWrittenAt, userContent };
331
+ }
332
+ catch {
333
+ return { entries: [] };
334
+ }
335
+ }
336
+ /**
337
+ * Check if a content value contains multimodal parts (non-text types like image).
338
+ */
339
+ function hasMultimodalParts(content) {
340
+ if (!Array.isArray(content))
341
+ return false;
342
+ return content.some((item) => item && typeof item === "object" && item.type && item.type !== "text");
343
+ }
344
+ /**
345
+ * Convert session-file content parts to the expected span input format.
346
+ *
347
+ * Session file stores images as:
348
+ * { type: "image", data: "<base64>", mimeType: "image/jpeg" }
349
+ *
350
+ * Span input expects:
351
+ * { type: "image_url", image_url: { url: "data:image/jpeg;base64,<base64>", name: "", detail: "" } }
352
+ *
353
+ * If the total size of all image data exceeds 3 MB, images are replaced with
354
+ * a placeholder text part to avoid oversized span input.
355
+ *
356
+ * Text parts are kept as-is.
357
+ */
358
+ const IMAGE_SIZE_LIMIT = 1 * 1024 * 1024; // 1 MB
359
+ function convertContentPartsForSpan(content) {
360
+ if (!Array.isArray(content))
361
+ return content;
362
+ const parts = content;
363
+ // Calculate total byte size of all image data (base64 length × 3/4 ≈ raw bytes)
364
+ let totalImageBytes = 0;
365
+ for (const part of parts) {
366
+ if (part?.type === "image" && typeof part.data === "string") {
367
+ totalImageBytes += Math.ceil(part.data.length * 3 / 4);
368
+ }
369
+ }
370
+ const exceedsLimit = totalImageBytes > IMAGE_SIZE_LIMIT;
371
+ return parts.map((part) => {
372
+ if (!part || typeof part !== "object")
373
+ return part;
374
+ if (part.type === "image" && typeof part.data === "string") {
375
+ if (exceedsLimit) {
376
+ return {
377
+ type: "text",
378
+ text: "[image data removed due to large size - already processed by model]",
379
+ };
380
+ }
381
+ const mimeType = part.mimeType || "image/png";
382
+ return {
383
+ type: "image_url",
384
+ image_url: {
385
+ url: `data:${mimeType};base64,${part.data}`,
386
+ name: "",
387
+ detail: "",
388
+ },
389
+ };
390
+ }
391
+ return part;
392
+ });
393
+ }
394
+ function normalizeChannelId(input, defaultPlatform = "system") {
395
+ if (!input || input === "unknown") {
396
+ return `${defaultPlatform}/unknown`;
397
+ }
398
+ if (input.includes("/")) {
399
+ // 已标准化的两段格式(无冒号),直接返回
400
+ if (!input.includes(":")) {
401
+ return input;
402
+ }
403
+ // 复合格式:尝试提取飞书用户标识
404
+ const feishuMatch = input.match(/(ou|oc|og)_[a-zA-Z0-9]+/);
405
+ if (feishuMatch) {
406
+ return `feishu/${feishuMatch[0]}`;
407
+ }
408
+ // agent/xxx:yyy → agent/xxx
409
+ const slashIdx = input.indexOf("/");
410
+ const afterSlash = input.substring(slashIdx + 1);
411
+ const colonIdx = afterSlash.indexOf(":");
412
+ if (colonIdx > 0) {
413
+ return `${input.substring(0, slashIdx)}/${afterSlash.substring(0, colonIdx)}`;
414
+ }
415
+ return input;
416
+ }
417
+ const prefix = input.split(/[_:]/)[0];
418
+ switch (prefix) {
419
+ case "ou":
420
+ case "oc":
421
+ case "og":
422
+ return `feishu/${input}`;
423
+ case "user":
424
+ case "chat":
425
+ return `feishu/${input.slice(prefix.length + 1)}`;
426
+ case "agent":
427
+ return `agent/${input.slice(6)}`;
428
+ default:
429
+ return `${defaultPlatform}/${input}`;
430
+ }
431
+ }
432
+ function resolveChannelId(ctx, eventFrom, defaultValue = "system/unknown") {
433
+ if (ctx.conversationId && /^(user|chat):/.test(ctx.conversationId)) {
434
+ return normalizeChannelId(ctx.conversationId);
435
+ }
436
+ if (eventFrom && /^feishu:/.test(eventFrom)) {
437
+ const platformId = eventFrom.slice(7);
438
+ return `feishu/${platformId}`;
439
+ }
440
+ if (ctx.channelId && /^feishu\/(ou|oc|og)_/.test(ctx.channelId)) {
441
+ return ctx.channelId;
442
+ }
443
+ const raw = ctx.sessionKey || ctx.channelId || eventFrom || defaultValue;
444
+ return normalizeChannelId(raw);
445
+ }
446
+ let lastUserChannelId;
447
+ let lastUserTraceContext;
448
+ let lastOpenclawSessionId;
449
+ // Active agent context: set in before_agent_start, cleared in agent_end.
450
+ // All hooks between these two (llm_input, llm_output, tool calls, messages)
451
+ // use this to ensure every span lands in the same Trace.
452
+ let activeAgentCtx;
453
+ let activeAgentChannelId;
454
+ // Latest user input captured from message_received, independent of any ctx.
455
+ // Used by ensureRootSpan as a reliable fallback for the root span's input.
456
+ let lastUserInput;
457
+ let pendingToolCall;
458
+ function resolveOpenclawSessionId(hookCtx, eventSessionId) {
459
+ const raw = hookCtx.sessionId?.trim() || eventSessionId?.trim();
460
+ if (!raw)
461
+ return undefined;
462
+ return raw;
463
+ }
464
+ const cozeloopTracePlugin = {
465
+ id: "openclaw-cozeloop-trace",
466
+ name: "OpenClaw CozeLoop Trace",
467
+ version: PLUGIN_VERSION,
468
+ description: "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
469
+ activate(api) {
470
+ const pluginConfig = api.pluginConfig || {};
471
+ const authorization = pluginConfig.authorization;
472
+ const workspaceId = pluginConfig.workspaceId;
473
+ if (!authorization || !workspaceId) {
474
+ api.logger.error("[CozeloopTrace] Missing required configuration: 'authorization' and 'workspaceId' must be provided");
475
+ return;
476
+ }
477
+ const config = {
478
+ endpoint: pluginConfig.endpoint || "https://api.coze.cn/v1/loop/opentelemetry",
479
+ authorization,
480
+ workspaceId,
481
+ serviceName: pluginConfig.serviceName || "openclaw-agent",
482
+ debug: pluginConfig.debug || false,
483
+ batchSize: pluginConfig.batchSize || 10,
484
+ batchInterval: pluginConfig.batchInterval || 5000,
485
+ enabledHooks: pluginConfig.enabledHooks,
486
+ };
487
+ const exporter = new CozeloopExporter(api, config);
488
+ const contextByChannelId = new Map();
489
+ const contextByRunId = new Map();
490
+ const shouldHookEnabled = (hookName) => {
491
+ if (!config.enabledHooks)
492
+ return true;
493
+ return config.enabledHooks.includes(hookName);
494
+ };
495
+ const getContextByChannel = (channelId) => {
496
+ return contextByChannelId.get(channelId);
497
+ };
498
+ const getContextByRun = (runId) => {
499
+ return contextByRunId.get(runId);
500
+ };
501
+ const getOriginalChannelId = (runId) => {
502
+ const ctx = contextByRunId.get(runId);
503
+ return ctx?.originalChannelId || ctx?.channelId;
504
+ };
505
+ const startTurn = (runId, channelId, originalChannelId, openclawSessionId) => {
506
+ const traceId = generateId(32);
507
+ const ctx = {
508
+ traceId,
509
+ rootSpanId: generateId(16),
510
+ runId,
511
+ turnId: runId,
512
+ channelId,
513
+ originalChannelId: originalChannelId || channelId,
514
+ openclawSessionId,
515
+ };
516
+ contextByChannelId.set(channelId, ctx);
517
+ contextByRunId.set(runId, ctx);
518
+ return ctx;
519
+ };
520
+ const endTurn = (channelId) => {
521
+ const ctx = contextByChannelId.get(channelId);
522
+ if (ctx) {
523
+ contextByChannelId.delete(channelId);
524
+ contextByRunId.delete(ctx.runId);
525
+ }
526
+ };
527
+ const getOrCreateContext = (rawChannelId, runId, hookName) => {
528
+ let channelId = rawChannelId;
529
+ let activeCtx = getContextByChannel(rawChannelId);
530
+ const effectiveRunId = runId || activeCtx?.runId || `run-${Date.now()}`;
531
+ if (rawChannelId.startsWith("agent/") && effectiveRunId) {
532
+ const originalChannelId = getOriginalChannelId(effectiveRunId);
533
+ if (originalChannelId) {
534
+ channelId = originalChannelId;
535
+ activeCtx = getContextByChannel(originalChannelId) || activeCtx;
536
+ }
537
+ }
538
+ if (!activeCtx) {
539
+ activeCtx = getContextByRun(effectiveRunId);
540
+ }
541
+ if (!activeCtx && rawChannelId.startsWith("agent/") && lastUserTraceContext) {
542
+ activeCtx = lastUserTraceContext;
543
+ channelId = lastUserChannelId || channelId;
544
+ contextByChannelId.set(rawChannelId, activeCtx);
545
+ contextByRunId.set(effectiveRunId, activeCtx);
546
+ if (config.debug) {
547
+ api.logger.info(`[CozeloopTrace] LINKING agent to user context: hook=${hookName}, agentChannel=${rawChannelId}, userChannel=${channelId}, traceId=${activeCtx.traceId}`);
548
+ }
549
+ }
550
+ let isNew = false;
551
+ if (!activeCtx) {
552
+ activeCtx = startTurn(effectiveRunId, channelId, rawChannelId !== channelId ? rawChannelId : undefined);
553
+ isNew = true;
554
+ if (config.debug) {
555
+ api.logger.info(`[CozeloopTrace] NEW TraceContext created: hook=${hookName}, channelId=${channelId}, runId=${effectiveRunId}, traceId=${activeCtx.traceId}`);
556
+ }
557
+ }
558
+ else if (config.debug) {
559
+ api.logger.info(`[CozeloopTrace] REUSING TraceContext: hook=${hookName}, channelId=${channelId}, runId=${effectiveRunId}, traceId=${activeCtx.traceId}`);
560
+ }
561
+ return { ctx: activeCtx, channelId, isNew };
562
+ };
563
+ // Resolve context for hooks that fire between before_agent_start and
564
+ // agent_end. When an agent is active, always return that agent's context
565
+ // so every span ends up in the same Trace regardless of channelId drift.
566
+ const resolveActiveContext = (rawChannelId, runId, hookName) => {
567
+ if (activeAgentCtx) {
568
+ if (config.debug) {
569
+ api.logger.info(`[CozeloopTrace] Using activeAgentCtx for ${hookName}: traceId=${activeAgentCtx.traceId}, rootSpanId=${activeAgentCtx.rootSpanId}`);
570
+ }
571
+ return { ctx: activeAgentCtx, channelId: activeAgentChannelId || rawChannelId };
572
+ }
573
+ const { ctx, channelId } = getOrCreateContext(rawChannelId, runId, hookName);
574
+ return { ctx, channelId };
575
+ };
576
+ const createSpan = (ctx, channelId, name, type, startTime, endTime, attributes = {}, input, output, parentSpanId) => {
577
+ return {
578
+ name,
579
+ type: type,
580
+ startTime,
581
+ endTime,
582
+ attributes: {
583
+ ...attributes,
584
+ "session.id": ctx.openclawSessionId || channelId,
585
+ "run.id": ctx.runId,
586
+ "turn.id": ctx.turnId,
587
+ "openclaw.channel_id": channelId,
588
+ },
589
+ input,
590
+ output,
591
+ traceId: ctx.traceId,
592
+ spanId: generateId(16),
593
+ parentSpanId: parentSpanId || ctx.rootSpanId,
594
+ };
595
+ };
596
+ const buildReactSpans = async (ctx, channelId, entries, initialInput, agentStartTime, userWrittenAt, skipCount, sessionUserContent) => {
597
+ const entriesToSkip = skipCount || 0;
598
+ const reactMessages = [];
599
+ if (initialInput && typeof initialInput === "object") {
600
+ const inputObj = initialInput;
601
+ if ("messages" in inputObj && Array.isArray(inputObj.messages)) {
602
+ for (const msg of inputObj.messages) {
603
+ const m = { role: String(msg.role || ""), content: safeClone(msg.content) };
604
+ if (msg.tool_call_id) {
605
+ m.tool_call_id = String(msg.tool_call_id);
606
+ }
607
+ reactMessages.push(m);
608
+ }
609
+ }
610
+ }
611
+ // Enrich the last user message with multimodal content from the session
612
+ // file. At llm_output time the session file is guaranteed to contain the
613
+ // full user message including image parts.
614
+ if (sessionUserContent && hasMultimodalParts(sessionUserContent)) {
615
+ const converted = convertContentPartsForSpan(safeClone(sessionUserContent));
616
+ if (config.debug) {
617
+ // Log size check details
618
+ const rawParts = sessionUserContent;
619
+ let totalBytes = 0;
620
+ let imageCount = 0;
621
+ for (const p of rawParts) {
622
+ if (p?.type === "image" && typeof p.data === "string") {
623
+ imageCount++;
624
+ totalBytes += Math.ceil(p.data.length * 3 / 4);
625
+ }
626
+ }
627
+ const convertedParts = converted;
628
+ const convertedTypes = convertedParts.map(p => String(p?.type ?? 'unknown'));
629
+ api.logger.info(`[CozeloopTrace] Multimodal enrichment: imageCount=${imageCount}, totalImageBytes=${totalBytes}, limit=${IMAGE_SIZE_LIMIT}, exceedsLimit=${totalBytes > IMAGE_SIZE_LIMIT}, convertedTypes=[${convertedTypes.join(',')}]`);
630
+ }
631
+ for (let mi = reactMessages.length - 1; mi >= 0; mi--) {
632
+ if (reactMessages[mi].role === "user") {
633
+ reactMessages[mi].content = converted;
634
+ if (config.debug) {
635
+ const parts = converted;
636
+ api.logger.info(`[CozeloopTrace] Enriched last user message in reactMessages with multimodal content: ${parts.length} parts, types=[${parts.map(p => p.type).join(',')}]`);
637
+ }
638
+ break;
639
+ }
640
+ }
641
+ // Also update ctx.userInput so the root span carries multimodal content
642
+ if (!hasMultimodalParts(ctx.userInput)) {
643
+ ctx.userInput = converted;
644
+ if (!lastUserInput || !hasMultimodalParts(lastUserInput)) {
645
+ lastUserInput = converted;
646
+ }
647
+ }
648
+ }
649
+ else if (config.debug) {
650
+ const isArray = Array.isArray(sessionUserContent);
651
+ if (isArray) {
652
+ const items = sessionUserContent;
653
+ api.logger.info(`[CozeloopTrace] Multimodal enrichment skipped: sessionUserContent=array[${items.length}] types=[${items.map(i => String(i?.type ?? typeof i)).join(',')}], hasMultimodal=false`);
654
+ }
655
+ else {
656
+ api.logger.info(`[CozeloopTrace] Multimodal enrichment skipped: sessionUserContent=${sessionUserContent === undefined ? 'undefined' : typeof sessionUserContent}`);
657
+ }
658
+ }
659
+ let reactRound = 0;
660
+ let modelSpanCount = 0;
661
+ let prevWrittenAt = userWrittenAt || agentStartTime;
662
+ for (let i = 0; i < entries.length; i++) {
663
+ const entry = entries[i];
664
+ const entryWrittenAt = entry.writtenAt || prevWrittenAt;
665
+ // Whether this entry was already exported in a previous llm_output call.
666
+ const alreadyExported = i < entriesToSkip;
667
+ if (entry.type === "assistant") {
668
+ reactRound++;
669
+ if (!alreadyExported) {
670
+ modelSpanCount++;
671
+ const provider = entry.provider || ctx.lastModelProvider || "unknown";
672
+ const model = entry.model || ctx.lastModelId || "unknown";
673
+ const spanStartTime = prevWrittenAt;
674
+ const spanEndTime = entryWrittenAt;
675
+ const modelSpan = createSpan(ctx, channelId, `${provider}/${model}`, "model", spanStartTime, spanEndTime, {
676
+ "gen_ai.provider.name": provider,
677
+ "gen_ai.request.model": model,
678
+ "gen_ai.usage.input_tokens": entry.usage?.input ?? 0,
679
+ "gen_ai.usage.output_tokens": entry.usage?.output ?? 0,
680
+ "react_round": reactRound,
681
+ }, { messages: reactMessages.map((msg) => safeClone(msg)) }, formatAssistantOutput(entry.content, entry.stopReason));
682
+ await exporter.export(modelSpan);
683
+ }
684
+ reactMessages.push({
685
+ role: "assistant",
686
+ content: convertAssistantContentForMessages(entry.content),
687
+ });
688
+ prevWrittenAt = entryWrittenAt;
689
+ }
690
+ else if (entry.type === "toolResult") {
691
+ if (!alreadyExported) {
692
+ const toolSpanStartTime = prevWrittenAt;
693
+ const toolSpanEndTime = entryWrittenAt;
694
+ let toolInput = undefined;
695
+ for (let j = i - 1; j >= 0; j--) {
696
+ if (entries[j].type === "assistant") {
697
+ const assistantContent = entries[j].content;
698
+ if (Array.isArray(assistantContent)) {
699
+ for (const item of assistantContent) {
700
+ if (item?.type === "toolCall" && item.id === entry.toolCallId) {
701
+ toolInput = { name: item.name, arguments: item.arguments ?? item.input };
702
+ break;
703
+ }
704
+ }
705
+ }
706
+ break;
707
+ }
708
+ }
709
+ const toolAttrs = {};
710
+ if (entry.isError) {
711
+ toolAttrs["error.msg"] = "tool returned error";
712
+ }
713
+ const toolSpan = createSpan(ctx, channelId, entry.toolName || "unknown_tool", "tool", toolSpanStartTime, toolSpanEndTime, toolAttrs, toolInput, entry.content);
714
+ await exporter.export(toolSpan);
715
+ }
716
+ const consecutiveToolResults = [entry];
717
+ let lastToolWrittenAt = entryWrittenAt;
718
+ while (i + 1 < entries.length && entries[i + 1].type === "toolResult") {
719
+ i++;
720
+ const nextTr = entries[i];
721
+ const nextAlreadyExported = i < entriesToSkip;
722
+ consecutiveToolResults.push(nextTr);
723
+ const nextWrittenAt = nextTr.writtenAt || lastToolWrittenAt;
724
+ lastToolWrittenAt = nextWrittenAt;
725
+ if (!nextAlreadyExported) {
726
+ let nextToolInput = undefined;
727
+ for (let j = i - 1; j >= 0; j--) {
728
+ if (entries[j].type === "assistant") {
729
+ const ac = entries[j].content;
730
+ if (Array.isArray(ac)) {
731
+ for (const item of ac) {
732
+ if (item?.type === "toolCall" && item.id === nextTr.toolCallId) {
733
+ nextToolInput = { name: item.name, arguments: item.arguments ?? item.input };
734
+ break;
735
+ }
736
+ }
737
+ }
738
+ break;
739
+ }
740
+ }
741
+ const nextToolAttrs = {};
742
+ if (nextTr.isError) {
743
+ nextToolAttrs["error.msg"] = "tool returned error";
744
+ }
745
+ const nextToolSpan = createSpan(ctx, channelId, nextTr.toolName || "unknown_tool", "tool", prevWrittenAt, nextWrittenAt, nextToolAttrs, nextToolInput, nextTr.content);
746
+ await exporter.export(nextToolSpan);
747
+ prevWrittenAt = nextWrittenAt;
748
+ }
749
+ }
750
+ const toolResultMsgs = convertToolResultsForMessages(consecutiveToolResults);
751
+ reactMessages.push(...toolResultMsgs);
752
+ // Use the last tool's timestamp so the next model span starts after
753
+ // all tools, not just after the first one.
754
+ prevWrittenAt = lastToolWrittenAt;
755
+ }
756
+ }
757
+ return modelSpanCount;
758
+ };
759
+ api.on("gateway_stop", async () => {
760
+ await exporter.dispose();
761
+ });
762
+ if (shouldHookEnabled("gateway_start")) {
763
+ api.on("gateway_start", async (event) => {
764
+ const now = Date.now();
765
+ const { ctx, channelId } = getOrCreateContext("system/gateway", undefined, "gateway_start");
766
+ const span = createSpan(ctx, channelId, "gateway_start", "gateway", now, now, {
767
+ "gateway.version": event.version || "unknown",
768
+ "gateway.working_dir": event.workingDir || process.cwd(),
769
+ });
770
+ await exporter.export(span);
771
+ if (config.debug) {
772
+ api.logger.info(`[CozeloopTrace] Exported gateway_start span, traceId=${ctx.traceId}`);
773
+ }
774
+ });
775
+ }
776
+ if (shouldHookEnabled("session_start")) {
777
+ api.on("session_start", async (event, hookCtx) => {
778
+ // Refresh token if expiring soon (< 10 min)
779
+ await exporter.refreshAuthIfNeeded();
780
+ const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
781
+ if (config.debug) {
782
+ api.logger.info(`[CozeloopTrace] session_start: ${rawChannelId}`);
783
+ }
784
+ const { ctx } = getOrCreateContext(rawChannelId, undefined, "session_start");
785
+ const ocSessionId = resolveOpenclawSessionId(hookCtx, event.sessionId);
786
+ if (ocSessionId) {
787
+ ctx.openclawSessionId = ocSessionId;
788
+ lastOpenclawSessionId = ocSessionId;
789
+ }
790
+ ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
791
+ });
792
+ }
793
+ if (shouldHookEnabled("message_received")) {
794
+ api.on("message_received", async (event, hookCtx) => {
795
+ const rawChannelId = resolveChannelId(hookCtx, event.from || event.metadata?.senderId);
796
+ if (config.debug) {
797
+ api.logger.info(`[CozeloopTrace] message_received hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.from=${event.from}`);
798
+ }
799
+ const { ctx, channelId } = getOrCreateContext(rawChannelId, undefined, "message_received");
800
+ const ocSessionId = resolveOpenclawSessionId(hookCtx);
801
+ if (ocSessionId) {
802
+ ctx.openclawSessionId = ocSessionId;
803
+ lastOpenclawSessionId = ocSessionId;
804
+ }
805
+ ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
806
+ let role = event.role;
807
+ if (!role && event.from) {
808
+ role = "user";
809
+ }
810
+ const isNonAgentChannel = !rawChannelId.startsWith("agent/");
811
+ if (isNonAgentChannel) {
812
+ if (role === "user" || !role) {
813
+ lastUserChannelId = channelId;
814
+ lastUserTraceContext = ctx;
815
+ ctx.userInput = event.content;
816
+ lastUserInput = event.content;
817
+ if (config.debug) {
818
+ api.logger.info(`[CozeloopTrace] Saved user context: channelId=${channelId}, traceId=${ctx.traceId}`);
819
+ }
820
+ }
821
+ if (!ctx.userInput) {
822
+ ctx.userInput = event.content;
823
+ }
824
+ if (!lastUserTraceContext) {
825
+ lastUserTraceContext = ctx;
826
+ lastUserChannelId = channelId;
827
+ }
828
+ }
829
+ });
830
+ }
831
+ if (shouldHookEnabled("message_sending")) {
832
+ api.on("message_sending", async (event, hookCtx) => {
833
+ if (lastUserTraceContext) {
834
+ lastUserTraceContext.lastOutput = event.content;
835
+ if (config.debug) {
836
+ api.logger.info(`[CozeloopTrace] Captured output for root span: traceId=${lastUserTraceContext.traceId}, content=${typeof event.content === 'string' ? event.content.substring(0, 100) : 'non-string'}`);
837
+ }
838
+ }
839
+ else {
840
+ const rawChannelId = resolveChannelId(hookCtx, event.to);
841
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending");
842
+ ctx.lastOutput = event.content;
843
+ if (config.debug) {
844
+ api.logger.info(`[CozeloopTrace] Captured output (fallback) for root span: traceId=${ctx.traceId}`);
845
+ }
846
+ }
847
+ });
848
+ }
849
+ if (shouldHookEnabled("message_sent")) {
850
+ api.on("message_sent", async (event, hookCtx) => {
851
+ if (event.content && event.success) {
852
+ if (lastUserTraceContext) {
853
+ lastUserTraceContext.lastOutput = event.content;
854
+ if (config.debug) {
855
+ api.logger.info(`[CozeloopTrace] Captured output from message_sent: traceId=${lastUserTraceContext.traceId}`);
856
+ }
857
+ }
858
+ else {
859
+ const rawChannelId = resolveChannelId(hookCtx, event.to);
860
+ const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent");
861
+ ctx.lastOutput = event.content;
862
+ if (config.debug) {
863
+ api.logger.info(`[CozeloopTrace] Captured output from message_sent (fallback): traceId=${ctx.traceId}`);
864
+ }
865
+ }
866
+ }
867
+ });
868
+ }
869
+ let lastLlmInput = undefined;
870
+ let lastLlmStartTime = undefined;
871
+ let lastLlmSpanId = undefined;
872
+ if (shouldHookEnabled("llm_input")) {
873
+ api.on("llm_input", async (event, hookCtx) => {
874
+ const rawChannelId = resolveChannelId(hookCtx);
875
+ if (config.debug) {
876
+ api.logger.info(`[CozeloopTrace] llm_input hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
877
+ }
878
+ const { ctx } = resolveActiveContext(rawChannelId, event.runId, "llm_input");
879
+ const ocSessionId = resolveOpenclawSessionId(hookCtx);
880
+ if (ocSessionId) {
881
+ ctx.openclawSessionId = ocSessionId;
882
+ lastOpenclawSessionId = ocSessionId;
883
+ }
884
+ ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
885
+ ctx.llmStartTime = Date.now();
886
+ ctx.llmSpanId = generateId(16);
887
+ ctx.lastModelProvider = event.provider;
888
+ ctx.lastModelId = event.model;
889
+ ctx.reactCount = 0;
890
+ // If userInput was never set (no message_received hook fired), capture
891
+ // the first llm prompt as the user input for the root span.
892
+ if (!ctx.userInput && event.prompt) {
893
+ ctx.userInput = event.prompt;
894
+ if (!lastUserInput) {
895
+ lastUserInput = event.prompt;
896
+ }
897
+ }
898
+ // Fallback: ensure root + agent spans exist in case before_agent_start
899
+ // was not fired (older OpenClaw versions or resumed sessions).
900
+ const channelIdForSpans = activeAgentChannelId || rawChannelId;
901
+ await ensureRootSpan(ctx, channelIdForSpans);
902
+ await ensureAgentSpan(ctx, channelIdForSpans);
903
+ const messages = [];
904
+ if (event.systemPrompt) {
905
+ messages.push({ role: "system", content: safeClone(event.systemPrompt) });
906
+ }
907
+ if (event.historyMessages && event.historyMessages.length > 0) {
908
+ messages.push(...event.historyMessages.map((msg) => safeClone(msg)));
909
+ }
910
+ if (event.prompt) {
911
+ messages.push({ role: "user", content: safeClone(event.prompt) });
912
+ }
913
+ const convertToolCallInPlace = (target) => {
914
+ if (target.type !== "toolCall")
915
+ return;
916
+ target.type = "tool_use";
917
+ if ("arguments" in target) {
918
+ target.input = target.arguments;
919
+ delete target.arguments;
920
+ }
921
+ };
922
+ const convertToolCallDeepInPlace = (value) => {
923
+ if (!value)
924
+ return;
925
+ if (Array.isArray(value)) {
926
+ for (const item of value) {
927
+ convertToolCallDeepInPlace(item);
928
+ }
929
+ return;
930
+ }
931
+ if (typeof value !== "object")
932
+ return;
933
+ const obj = value;
934
+ convertToolCallInPlace(obj);
935
+ if ("content" in obj) {
936
+ convertToolCallDeepInPlace(obj.content);
937
+ }
938
+ };
939
+ for (const message of messages) {
940
+ convertToolCallDeepInPlace(message);
941
+ if ("toolCallId" in message) {
942
+ message.tool_call_id = message.toolCallId;
943
+ delete message.toolCallId;
944
+ }
945
+ if (message.role === "toolResult") {
946
+ message.role = "tool";
947
+ }
948
+ }
949
+ ctx.llmInput = {
950
+ "messages": messages,
951
+ };
952
+ lastLlmInput = ctx.llmInput;
953
+ lastLlmStartTime = ctx.llmStartTime;
954
+ lastLlmSpanId = ctx.llmSpanId;
955
+ if (config.debug) {
956
+ api.logger.info(`[CozeloopTrace] LLM input started: ${event.provider}/${event.model}, runId=${event.runId}, traceId=${ctx.traceId}`);
957
+ }
958
+ });
959
+ }
960
+ if (shouldHookEnabled("llm_output")) {
961
+ api.on("llm_output", async (event, hookCtx) => {
962
+ const rawChannelId = resolveChannelId(hookCtx);
963
+ if (config.debug) {
964
+ api.logger.info(`[CozeloopTrace][DEBUG] llm_output event.usage=${JSON.stringify(event.usage)}`);
965
+ api.logger.info(`[CozeloopTrace][DEBUG] llm_output event.lastAssistant=${JSON.stringify(event.lastAssistant)}`);
966
+ api.logger.info(`[CozeloopTrace][DEBUG] llm_output event keys=${JSON.stringify(Object.keys(event))}`);
967
+ api.logger.info(`[CozeloopTrace] llm_output hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
968
+ }
969
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output");
970
+ const now = Date.now();
971
+ const startTime = ctx.llmStartTime || lastLlmStartTime || now;
972
+ if (event.assistantTexts && event.assistantTexts.length > 0) {
973
+ const outputText = event.assistantTexts.join("\n");
974
+ ctx.lastOutput = outputText;
975
+ if (lastUserTraceContext) {
976
+ lastUserTraceContext.lastOutput = outputText;
977
+ }
978
+ if (config.debug) {
979
+ api.logger.info(`[CozeloopTrace] Captured output from llm_output (will use last): traceId=${ctx.traceId}, length=${outputText.length}`);
980
+ }
981
+ }
982
+ const llmInput = ctx.llmInput || lastLlmInput;
983
+ const llmSpanId = ctx.llmSpanId || lastLlmSpanId;
984
+ if (config.debug) {
985
+ api.logger.info(`[CozeloopTrace] llm_output ctx: traceId=${ctx.traceId}, rootSpanId=${ctx.rootSpanId}, llmSpanId=${llmSpanId || "none"}, hasInput=${!!llmInput}`);
986
+ }
987
+ let sessionBasedSuccess = false;
988
+ try {
989
+ const { entries, userWrittenAt, userContent } = readCurrentTurnReactSequence(hookCtx);
990
+ const hasAssistantEntry = entries.some((e) => e.type === "assistant");
991
+ if (entries.length > 0 && hasAssistantEntry) {
992
+ const agentStart = ctx.agentStartTime || ctx.llmStartTime || lastLlmStartTime || now;
993
+ const skipCount = ctx.sessionBasedExportedCount || 0;
994
+ const modelCount = await buildReactSpans(ctx, channelId, entries, llmInput, agentStart, userWrittenAt, skipCount, userContent);
995
+ if (modelCount > 0) {
996
+ sessionBasedSuccess = true;
997
+ ctx.sessionBasedSpansCreated = true;
998
+ // Remember how many entries we exported so the next llm_output
999
+ // call skips them and avoids duplicate spans.
1000
+ ctx.sessionBasedExportedCount = entries.length;
1001
+ if (config.debug) {
1002
+ api.logger.info(`[CozeloopTrace] Session-based react spans created: modelCount=${modelCount}, totalEntries=${entries.length}, skipped=${skipCount}, traceId=${ctx.traceId}`);
1003
+ }
1004
+ }
1005
+ }
1006
+ }
1007
+ catch {
1008
+ if (config.debug) {
1009
+ api.logger.info(`[CozeloopTrace] Session-based span creation failed, falling back to hook data, traceId=${ctx.traceId}`);
1010
+ }
1011
+ }
1012
+ if (!sessionBasedSuccess) {
1013
+ if (ctx.pendingToolSpans) {
1014
+ for (const pts of ctx.pendingToolSpans) {
1015
+ const toolSpan = createSpan(ctx, channelId, pts.toolName, "tool", pts.toolStartTime, pts.toolEndTime, pts.toolError ? { "error.msg": String(pts.toolError) } : {}, pts.toolInput, pts.toolError ? { error: pts.toolError } : pts.toolOutput);
1016
+ toolSpan.spanId = pts.toolSpanId;
1017
+ await exporter.export(toolSpan);
1018
+ if (config.debug) {
1019
+ api.logger.info(`[CozeloopTrace] Exported pending tool span (fallback): ${pts.toolName}, spanId=${pts.toolSpanId}, traceId=${ctx.traceId}`);
1020
+ }
1021
+ }
1022
+ }
1023
+ const lastAssistantUsage = event.lastAssistant?.usage;
1024
+ const inputTokens = event.usage?.input ?? lastAssistantUsage?.input ?? 0;
1025
+ const outputTokens = event.usage?.output ?? lastAssistantUsage?.output ?? 0;
1026
+ const spanAttributes = {
1027
+ "gen_ai.provider.name": event.provider,
1028
+ "gen_ai.request.model": event.model,
1029
+ "gen_ai.usage.input_tokens": inputTokens,
1030
+ "gen_ai.usage.output_tokens": outputTokens,
1031
+ };
1032
+ const finalOutput = formatAssistantOutput(event.assistantTexts?.map((t) => ({ type: "text", text: t })) ?? [], "stop");
1033
+ const span = createSpan(ctx, channelId, `${event.provider}/${event.model}`, "model", startTime, now, spanAttributes, llmInput, finalOutput);
1034
+ if (llmSpanId) {
1035
+ span.spanId = llmSpanId;
1036
+ }
1037
+ if (config.debug) {
1038
+ api.logger.info(`[CozeloopTrace] llm_output span created (fallback): spanId=${span.spanId}, parentSpanId=${span.parentSpanId}`);
1039
+ }
1040
+ await exporter.export(span);
1041
+ if (config.debug) {
1042
+ api.logger.info(`[CozeloopTrace] Exported LLM span (fallback): ${event.provider}/${event.model}, duration=${now - startTime}ms, traceId=${ctx.traceId}`);
1043
+ }
1044
+ }
1045
+ ctx.llmStartTime = undefined;
1046
+ ctx.llmSpanId = undefined;
1047
+ ctx.llmInput = undefined;
1048
+ ctx.reactCount = 0;
1049
+ ctx.pendingToolSpans = undefined;
1050
+ ctx.sessionBasedSpansCreated = undefined;
1051
+ lastLlmInput = undefined;
1052
+ lastLlmStartTime = undefined;
1053
+ lastLlmSpanId = undefined;
1054
+ });
1055
+ }
1056
+ if (shouldHookEnabled("before_tool_call")) {
1057
+ api.on("before_tool_call", async (event, hookCtx) => {
1058
+ const rawChannelId = resolveChannelId(hookCtx);
1059
+ if (config.debug) {
1060
+ api.logger.info(`[CozeloopTrace] before_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1061
+ }
1062
+ const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call");
1063
+ pendingToolCall = {
1064
+ toolName: event.toolName,
1065
+ toolSpanId: generateId(16),
1066
+ toolStartTime: Date.now(),
1067
+ toolInput: event.params,
1068
+ traceContext: ctx,
1069
+ channelId: channelId,
1070
+ };
1071
+ ctx.reactCount = (ctx.reactCount || 0) + 1;
1072
+ if (config.debug) {
1073
+ api.logger.info(`[CozeloopTrace] Tool call started: ${event.toolName}, spanId=${pendingToolCall.toolSpanId}, traceId=${ctx.traceId}`);
1074
+ }
1075
+ });
1076
+ }
1077
+ if (shouldHookEnabled("after_tool_call")) {
1078
+ api.on("after_tool_call", async (event, hookCtx) => {
1079
+ if (config.debug) {
1080
+ api.logger.info(`[CozeloopTrace] after_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1081
+ }
1082
+ if (!pendingToolCall || pendingToolCall.toolName !== event.toolName) {
1083
+ if (config.debug) {
1084
+ api.logger.info(`[CozeloopTrace] Skipping after_tool_call: no pending tool or name mismatch, toolName=${event.toolName}, pending=${pendingToolCall?.toolName}`);
1085
+ }
1086
+ return;
1087
+ }
1088
+ const { toolName, toolSpanId, toolStartTime, toolInput, traceContext } = pendingToolCall;
1089
+ pendingToolCall = undefined;
1090
+ const now = Date.now();
1091
+ if (!traceContext.pendingToolSpans) {
1092
+ traceContext.pendingToolSpans = [];
1093
+ }
1094
+ traceContext.pendingToolSpans.push({
1095
+ toolName,
1096
+ toolSpanId,
1097
+ toolStartTime,
1098
+ toolEndTime: now,
1099
+ toolInput,
1100
+ toolOutput: event.error ? { error: event.error } : event.result,
1101
+ toolError: event.error ? String(event.error) : undefined,
1102
+ });
1103
+ if (config.debug) {
1104
+ api.logger.info(`[CozeloopTrace] Collected pending tool span: ${toolName}, spanId=${toolSpanId}, duration=${now - toolStartTime}ms, traceId=${traceContext.traceId}`);
1105
+ }
1106
+ });
1107
+ }
1108
+ // Helper: finalize a trace — end agent span (if open), end root span, flush,
1109
+ // and clean up all state. Called from agent_end (normal path) and
1110
+ // session_end (fallback for old OpenClaw versions that don't emit agent_end).
1111
+ let traceFinalized = false;
1112
+ const finalizeTrace = (ctx, channelId, agentEndAttrs, agentOutput) => {
1113
+ if (traceFinalized)
1114
+ return;
1115
+ traceFinalized = true;
1116
+ const now = Date.now();
1117
+ // End agent span if still open.
1118
+ if (ctx.agentSpanId) {
1119
+ exporter.endSpanById(ctx.agentSpanId, now, agentEndAttrs || {}, agentOutput);
1120
+ if (config.debug) {
1121
+ api.logger.info(`[CozeloopTrace] Ended agent span: spanId=${ctx.agentSpanId}, traceId=${ctx.traceId}`);
1122
+ }
1123
+ ctx.agentSpanId = undefined;
1124
+ ctx.agentStartTime = undefined;
1125
+ }
1126
+ const rootSpanId = ctx.rootSpanId;
1127
+ const rootSpanStartTime = ctx.rootSpanStartTime;
1128
+ const userInput = ctx.userInput || (lastUserTraceContext ? lastUserTraceContext.userInput : undefined) || lastUserInput;
1129
+ const traceId = ctx.traceId;
1130
+ const hasRootSpan = !!rootSpanStartTime;
1131
+ const savedLastUserChannelId = lastUserChannelId;
1132
+ const originalChannelId = ctx.originalChannelId || channelId;
1133
+ setTimeout(async () => {
1134
+ if (hasRootSpan) {
1135
+ const finalOutput = ctx.lastOutput || (lastUserTraceContext ? lastUserTraceContext.lastOutput : undefined);
1136
+ if (config.debug) {
1137
+ api.logger.info(`[CozeloopTrace] Ending root span with input=${userInput ? 'present' : 'missing'}, output=${finalOutput ? 'present' : 'missing'}`);
1138
+ }
1139
+ const endTime = Date.now();
1140
+ exporter.endSpanById(rootSpanId, endTime, {
1141
+ "request.duration_ms": endTime - (rootSpanStartTime || 0),
1142
+ }, finalOutput, userInput);
1143
+ if (config.debug) {
1144
+ api.logger.info(`[CozeloopTrace] Ended root span: spanId=${rootSpanId}, duration=${endTime - (rootSpanStartTime || 0)}ms, traceId=${traceId}`);
1145
+ }
1146
+ }
1147
+ await exporter.flush();
1148
+ exporter.endTrace(rootSpanId);
1149
+ if (activeAgentCtx === ctx) {
1150
+ activeAgentCtx = undefined;
1151
+ activeAgentChannelId = undefined;
1152
+ }
1153
+ if (savedLastUserChannelId) {
1154
+ endTurn(savedLastUserChannelId);
1155
+ }
1156
+ if (originalChannelId && originalChannelId !== savedLastUserChannelId) {
1157
+ endTurn(originalChannelId);
1158
+ }
1159
+ lastUserChannelId = undefined;
1160
+ lastUserTraceContext = undefined;
1161
+ lastUserInput = undefined;
1162
+ traceFinalized = false;
1163
+ }, 200);
1164
+ };
1165
+ // Helper: ensure root openclaw_request span is started for a given context.
1166
+ // Must be called before creating the agent span so that the exporter's
1167
+ // currentRootContext is set and the agent span becomes a proper child.
1168
+ const ensureRootSpan = async (ctx, channelId) => {
1169
+ // Only trace sessions that carry coze-context — no context means this
1170
+ // is not a Coze-originated session and we skip it entirely.
1171
+ if (!Object.keys(parseCozeContext(ctx.userInput)).length) {
1172
+ return;
1173
+ }
1174
+ // Check both: rootSpanStartTime indicates we created a root span before,
1175
+ // but the exporter's traceContexts may have been cleaned up by a previous
1176
+ // turn's deferred endTrace(). If the exporter no longer has the entry we
1177
+ // must recreate the root span.
1178
+ if (ctx.rootSpanStartTime && exporter.hasTraceContext(ctx.rootSpanId)) {
1179
+ return;
1180
+ }
1181
+ const now = Date.now();
1182
+ ctx.rootSpanStartTime = now;
1183
+ // Generate a fresh rootSpanId when the old one was cleaned up, so we
1184
+ // don't collide with the previous turn's IDs.
1185
+ const isRebuild = !exporter.hasTraceContext(ctx.rootSpanId);
1186
+ if (isRebuild) {
1187
+ ctx.rootSpanId = generateId(16);
1188
+ // This is a new turn reusing a stale ctx — clear the previous turn's
1189
+ // userInput, exported count, and agent span so we don't carry over
1190
+ // stale state. The agent span must be recreated under the new root.
1191
+ ctx.userInput = undefined;
1192
+ ctx.sessionBasedExportedCount = undefined;
1193
+ ctx.agentSpanId = undefined;
1194
+ ctx.agentStartTime = undefined;
1195
+ }
1196
+ // Resolve user input: prefer ctx.userInput set by this turn's
1197
+ // message_received, fall back to lastUserTraceContext, then lastUserInput.
1198
+ if (!ctx.userInput) {
1199
+ ctx.userInput = lastUserTraceContext?.userInput || lastUserInput;
1200
+ }
1201
+ const rootSpanData = {
1202
+ name: "openclaw_request",
1203
+ type: "entry",
1204
+ startTime: now,
1205
+ attributes: {
1206
+ "session.id": ctx.openclawSessionId || channelId,
1207
+ "run.id": ctx.runId,
1208
+ "turn.id": ctx.turnId,
1209
+ "openclaw.channel_id": channelId,
1210
+ ...parseCozeContext(ctx.userInput),
1211
+ },
1212
+ input: ctx.userInput,
1213
+ traceId: ctx.traceId,
1214
+ spanId: ctx.rootSpanId,
1215
+ };
1216
+ await exporter.startSpan(rootSpanData, ctx.rootSpanId);
1217
+ if (config.debug) {
1218
+ api.logger.info(`[CozeloopTrace] ensureRootSpan: created root span, rootSpanId=${ctx.rootSpanId}, traceContextsHas=${exporter.hasTraceContext(ctx.rootSpanId)}`);
1219
+ }
1220
+ };
1221
+ // Helper: ensure the agent span exists for a given context.
1222
+ // Safe to call multiple times — only creates the span once.
1223
+ const ensureAgentSpan = async (ctx, channelId, agentId) => {
1224
+ if (ctx.agentSpanId)
1225
+ return;
1226
+ const effectiveAgentId = agentId || "main";
1227
+ const now = Date.now();
1228
+ ctx.agentStartTime = now;
1229
+ ctx.agentSpanId = generateId(16);
1230
+ const spanData = {
1231
+ name: effectiveAgentId,
1232
+ type: "agent",
1233
+ startTime: now,
1234
+ attributes: {
1235
+ "agent.id": effectiveAgentId,
1236
+ "session.id": ctx.openclawSessionId || channelId,
1237
+ "run.id": ctx.runId,
1238
+ "turn.id": ctx.turnId,
1239
+ "openclaw.channel_id": channelId,
1240
+ ...parseCozeContext(ctx.userInput),
1241
+ },
1242
+ traceId: ctx.traceId,
1243
+ spanId: ctx.agentSpanId,
1244
+ parentSpanId: ctx.rootSpanId,
1245
+ };
1246
+ await exporter.startSpan(spanData, ctx.agentSpanId);
1247
+ // Set active agent context so all subsequent hooks use the same Trace.
1248
+ activeAgentCtx = ctx;
1249
+ activeAgentChannelId = channelId;
1250
+ if (config.debug) {
1251
+ api.logger.info(`[CozeloopTrace] ensureAgentSpan: created agent span, agentId=${effectiveAgentId}, spanId=${ctx.agentSpanId}, traceId=${ctx.traceId}`);
1252
+ }
1253
+ };
1254
+ if (shouldHookEnabled("before_agent_start")) {
1255
+ api.on("before_agent_start", async (event, hookCtx) => {
1256
+ const rawChannelId = resolveChannelId(hookCtx);
1257
+ const agentId = hookCtx.agentId || event.agentId || "main";
1258
+ if (config.debug) {
1259
+ api.logger.info(`[CozeloopTrace] before_agent_start hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId, agentId: hookCtx.agentId })}, event.agentId=${event.agentId}`);
1260
+ }
1261
+ const { ctx, channelId } = getOrCreateContext(rawChannelId, undefined, "before_agent_start");
1262
+ const ocSessionId = resolveOpenclawSessionId(hookCtx);
1263
+ if (ocSessionId) {
1264
+ ctx.openclawSessionId = ocSessionId;
1265
+ lastOpenclawSessionId = ocSessionId;
1266
+ }
1267
+ ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
1268
+ await ensureRootSpan(ctx, channelId);
1269
+ await ensureAgentSpan(ctx, channelId, agentId);
1270
+ });
1271
+ }
1272
+ if (shouldHookEnabled("agent_end")) {
1273
+ api.on("agent_end", async (event, hookCtx) => {
1274
+ const rawChannelId = resolveChannelId(hookCtx);
1275
+ if (config.debug) {
1276
+ api.logger.info(`[CozeloopTrace] agent_end hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}`);
1277
+ }
1278
+ // Use activeAgentCtx if available, otherwise fall back to resolution.
1279
+ const ctx = activeAgentCtx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
1280
+ const channelId = activeAgentChannelId || rawChannelId;
1281
+ finalizeTrace(ctx, channelId, {
1282
+ "agent.duration_ms": event.durationMs || 0,
1283
+ "agent.message_count": event.messageCount || 0,
1284
+ "agent.tool_call_count": event.toolCallCount || 0,
1285
+ "agent.total_tokens": event.usage?.total || 0,
1286
+ }, { usage: event.usage, cost: event.cost });
1287
+ });
1288
+ }
1289
+ // Fallback: on session_end, if agent_end was never fired (old OpenClaw
1290
+ // versions), finalize the trace here so that agent + root spans get ended
1291
+ // and exported.
1292
+ if (shouldHookEnabled("session_end")) {
1293
+ api.on("session_end", async (event, hookCtx) => {
1294
+ const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
1295
+ if (config.debug) {
1296
+ api.logger.info(`[CozeloopTrace] session_end: ${rawChannelId}`);
1297
+ }
1298
+ const ctx = activeAgentCtx || lastUserTraceContext;
1299
+ if (ctx && ctx.rootSpanStartTime) {
1300
+ const channelId = activeAgentChannelId || lastUserChannelId || rawChannelId;
1301
+ if (config.debug) {
1302
+ api.logger.info(`[CozeloopTrace] session_end: finalizing trace as fallback, traceId=${ctx.traceId}`);
1303
+ }
1304
+ finalizeTrace(ctx, channelId);
1305
+ }
1306
+ else {
1307
+ const { channelId } = getOrCreateContext(rawChannelId, undefined, "session_end");
1308
+ endTurn(channelId);
1309
+ }
1310
+ });
1311
+ }
1312
+ api.logger.info(`[CozeloopTrace] Plugin activated (endpoint: ${config.endpoint}, workspace: ${config.workspaceId})`);
1313
+ },
1314
+ };
1315
+ export default cozeloopTracePlugin;