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.
- package/README.md +75 -0
- package/index.js +4996 -0
- package/package.json +35 -0
- package/scripts/claude-code/cozeloop_hook.py +1303 -0
- package/scripts/codex/cozeloop_hook.py +1051 -0
- package/scripts/openclaw/dist/cozeloop-exporter.js +442 -0
- package/scripts/openclaw/dist/index.js +1315 -0
- package/scripts/openclaw/dist/span-manager.js +77 -0
- package/scripts/openclaw/dist/types.js +1 -0
- package/scripts/openclaw/openclaw.plugin.json +55 -0
- package/scripts/openclaw/package.json +45 -0
- package/scripts/shared/cozeloop_refresh.py +53 -0
|
@@ -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;
|