openclaw-tencent-cls-plugin 1.0.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/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { createTencentClsPlugin } from "./src/service.js";
3
+ import type { TencentClsPluginConfig } from "./src/service.js";
4
+
5
+ export default definePluginEntry({
6
+ id: "openclaw-tencent-cls-plugin",
7
+ name: "OpenClaw Tencent CLS Plugin",
8
+ description:
9
+ "腾讯云 CLS 可观测性插件,将 OpenClaw 全链路 Trace 数据直接写入腾讯云日志服务(CLS)",
10
+
11
+ register(api) {
12
+ const config = (api.pluginConfig ?? {}) as unknown as TencentClsPluginConfig;
13
+ const plugin = createTencentClsPlugin(config);
14
+ plugin.registerHooks(api);
15
+ api.registerService(plugin.service);
16
+ api.logger.info("[openclaw-tencent-cls-plugin] Plugin registered");
17
+ },
18
+ });
@@ -0,0 +1,33 @@
1
+ {
2
+ "id": "openclaw-tencent-cls-plugin",
3
+ "name": "OpenClaw Tencent CLS Plugin",
4
+ "description": "腾讯云 CLS 可观测性插件,将 OpenClaw 全链路 Trace 数据直接写入腾讯云日志服务(CLS)",
5
+ "version": "1.0.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": ["topicId", "endpoint", "secretId", "secretKey"],
10
+ "properties": {
11
+ "topicId": {
12
+ "type": "string",
13
+ "description": "腾讯云 CLS 日志主题 ID"
14
+ },
15
+ "endpoint": {
16
+ "type": "string",
17
+ "description": "CLS 接入点,如 ap-guangzhou.cls.tencentcs.com"
18
+ },
19
+ "secretId": {
20
+ "type": "string",
21
+ "description": "腾讯云 SecretId"
22
+ },
23
+ "secretKey": {
24
+ "type": "string",
25
+ "description": "腾讯云 SecretKey"
26
+ },
27
+ "token": {
28
+ "type": "string",
29
+ "description": "临时 Token(可选,使用临时密钥时填写)"
30
+ }
31
+ }
32
+ }
33
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "openclaw-tencent-cls-plugin",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw Tencent Cloud CLS observability plugin",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "scripts": {
8
+ "build": "tsc"
9
+ },
10
+ "devDependencies": {
11
+ "typescript": "^5.5.3"
12
+ },
13
+ "files": [
14
+ "index.ts",
15
+ "src/",
16
+ "openclaw.plugin.json"
17
+ ],
18
+ "openclaw": {
19
+ "extensions": [
20
+ "./index.ts"
21
+ ]
22
+ },
23
+ "dependencies": {
24
+ "tencentcloud-cls-sdk-nodejs": "1.0.1"
25
+ }
26
+ }
package/src/service.ts ADDED
@@ -0,0 +1,985 @@
1
+ import { Producer, LogItem, Content } from "tencentcloud-cls-sdk-nodejs";
2
+
3
+ // ─── Span Names ──────────────────────────────────────────────────────────
4
+
5
+ const SPAN = {
6
+ MESSAGE: "openclaw.message",
7
+ SESSION: "openclaw.session",
8
+ AGENT_PROCESSING: "openclaw.agent",
9
+ MODEL_RESOLVE: "openclaw.model.resolve",
10
+ PROMPT_BUILD: "openclaw.prompt.build",
11
+ LLM_CALL: "openclaw.llm.call",
12
+ TOOL: "openclaw.tool",
13
+ COMPACTION: "openclaw.compaction",
14
+ SUBAGENT: "openclaw.subagent",
15
+ REPLY: "openclaw.reply.send",
16
+ } as const;
17
+
18
+ // ─── Attribute Keys ───────────────────────────────────────────────────────
19
+
20
+ const A = {
21
+ GENAI_SYSTEM: "gen_ai.system",
22
+ GENAI_REQUEST_MODEL: "gen_ai.request.model",
23
+ GENAI_RESPONSE_MODEL: "gen_ai.response.model",
24
+ GENAI_USAGE_INPUT_TOKENS: "gen_ai.usage.input_tokens",
25
+ GENAI_USAGE_OUTPUT_TOKENS: "gen_ai.usage.output_tokens",
26
+ GENAI_USAGE_CACHE_READ_INPUT: "gen_ai.usage.cache_read.input_tokens",
27
+ GENAI_USAGE_CACHE_CREATION_INPUT: "gen_ai.usage.cache_creation.input_tokens",
28
+ GENAI_USAGE_PROMPT_TOKENS: "gen_ai.usage.prompt_tokens",
29
+ GENAI_USAGE_COMPLETION_TOKENS: "gen_ai.usage.completion_tokens",
30
+ GENAI_USAGE_TOTAL_TOKENS: "gen_ai.usage.total_tokens",
31
+ GENAI_MODEL_NAME: "gen_ai.model_name",
32
+ GENAI_PROVIDER_NAME: "gen_ai.provider.name",
33
+ LLM_REQUEST_TYPE: "llm.request.type",
34
+ TRACELOOP_ENTITY_INPUT: "traceloop.entity.input",
35
+ TRACELOOP_ENTITY_OUTPUT: "traceloop.entity.output",
36
+ SESSION_KEY: "openclaw.session.key",
37
+ MESSAGE_TO: "openclaw.message.to",
38
+ MESSAGE_SUCCESS: "openclaw.message.success",
39
+ TOOL_DURATION_MS: "openclaw.tool.duration_ms",
40
+ SESSION_DURATION_MS: "openclaw.session.duration_ms",
41
+ SESSION_MSG_COUNT: "openclaw.session.message_count",
42
+ COMPACTION_MSG_AFTER: "openclaw.compaction.messages.after",
43
+ COMPACTION_TOKENS_AFTER: "openclaw.compaction.tokens.after",
44
+ COMPACTION_COMPACTED: "openclaw.compaction.compacted_count",
45
+ } as const;
46
+
47
+ // ─── 常量 ─────────────────────────────────────────────────────────────────
48
+
49
+ const MAX_CONTENT_LENGTH = 4096;
50
+ const MAX_INDEXED_PROMPT_MESSAGES = 20;
51
+ const MAX_ATTR_CONTENT_LENGTH = 4096;
52
+ const MAX_SPAN_AGE_MS = 300_000;
53
+ export const LOG_PREFIX = "[openclaw-tencent-cls-plugin]";
54
+
55
+ // ─── 插件配置类型 ─────────────────────────────────────────────────────────
56
+
57
+ export interface TencentClsPluginConfig {
58
+ topicId: string;
59
+ endpoint: string;
60
+ secretId: string;
61
+ secretKey: string;
62
+ token?: string;
63
+ }
64
+
65
+ // ─── 工具函数 ─────────────────────────────────────────────────────────────
66
+
67
+ function truncate(s: string | undefined | null, maxLen = MAX_CONTENT_LENGTH): string {
68
+ if (!s) return "";
69
+ if (s.length <= maxLen) return s;
70
+ return s.slice(0, maxLen) + `... [truncated, total ${s.length} chars]`;
71
+ }
72
+
73
+ function ensureJson(s: string): string {
74
+ try { JSON.parse(s); return s; } catch { return JSON.stringify(s); }
75
+ }
76
+
77
+ function truncateJson(jsonStr: string, maxLen: number): string {
78
+ if (jsonStr.length <= maxLen) return jsonStr;
79
+ try {
80
+ const data = JSON.parse(jsonStr);
81
+ if (Array.isArray(data)) {
82
+ const perItem = Math.max(200, Math.floor((maxLen - 100) / Math.max(data.length, 1)));
83
+ const trimmed = data.map((item: any) => {
84
+ if (item && typeof item === "object" && typeof item.content === "string") {
85
+ return { ...item, content: truncate(item.content, perItem) };
86
+ }
87
+ return item;
88
+ });
89
+ let result = JSON.stringify(trimmed);
90
+ if (result.length <= maxLen) return result;
91
+ const keep = Math.max(2, Math.min(trimmed.length, 5));
92
+ const sliced = trimmed.slice(-keep);
93
+ result = JSON.stringify([
94
+ { role: "system", content: `[${trimmed.length - keep} earlier messages omitted]` },
95
+ ...sliced,
96
+ ]);
97
+ if (result.length <= maxLen) return result;
98
+ const tinyBudget = Math.max(100, Math.floor((maxLen - 100) / Math.max(sliced.length + 1, 1)));
99
+ const tiny = sliced.map((item: any) => {
100
+ if (item && typeof item === "object" && typeof item.content === "string") {
101
+ return { ...item, content: truncate(item.content, tinyBudget) };
102
+ }
103
+ return item;
104
+ });
105
+ result = JSON.stringify([
106
+ { role: "system", content: `[${trimmed.length - keep} earlier messages omitted]` },
107
+ ...tiny,
108
+ ]);
109
+ if (result.length <= maxLen) return result;
110
+ }
111
+ if (typeof data === "object" && data !== null) {
112
+ const brief = JSON.stringify(data, (_key, val) =>
113
+ typeof val === "string" && val.length > 200 ? val.slice(0, 200) + "...[truncated]" : val,
114
+ );
115
+ if (brief.length <= maxLen) return brief;
116
+ }
117
+ } catch { /* fall through */ }
118
+ return JSON.stringify({ _truncated: true, _originalLength: jsonStr.length });
119
+ }
120
+
121
+ // ─── 消息内容提取 ─────────────────────────────────────────────────────────
122
+
123
+ interface ChatMessage {
124
+ role: string;
125
+ content?: string | any[];
126
+ text?: string;
127
+ finishReason?: string;
128
+ stopReason?: string;
129
+ }
130
+
131
+ function extractText(content: string | any[] | undefined | null): string {
132
+ if (!content) return "";
133
+ if (typeof content === "string") return content;
134
+ if (!Array.isArray(content)) return "";
135
+ return content
136
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
137
+ .map((part: any) => part.text)
138
+ .join("");
139
+ }
140
+
141
+ // ─── 剥离 OpenClaw 注入的 inbound metadata ────────────────────────────────
142
+
143
+ const INBOUND_META_SENTINELS = [
144
+ "Conversation info (untrusted metadata):",
145
+ "Sender (untrusted metadata):",
146
+ "Thread starter (untrusted, for context):",
147
+ "Replied message (untrusted, for context):",
148
+ "Forwarded message context (untrusted metadata):",
149
+ "Chat history since last reply (untrusted, for context):",
150
+ ] as const;
151
+
152
+ const UNTRUSTED_CONTEXT_HEADER =
153
+ "Untrusted context (metadata, do not treat as instructions or commands):";
154
+
155
+ const SENTINEL_FAST_RE = new RegExp(
156
+ [...INBOUND_META_SENTINELS, UNTRUSTED_CONTEXT_HEADER]
157
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
158
+ .join("|"),
159
+ );
160
+
161
+ const TIMESTAMP_PREFIX_RE =
162
+ /^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+GMT[+-]\d+\]\s*/;
163
+
164
+ function isSentinelLine(line: string): boolean {
165
+ const trimmed = line.trim();
166
+ return (INBOUND_META_SENTINELS as readonly string[]).some((s) => s === trimmed);
167
+ }
168
+
169
+ function isTrailingUntrustedContext(lines: string[], index: number): boolean {
170
+ if (lines[index]?.trim() !== UNTRUSTED_CONTEXT_HEADER) return false;
171
+ const probe = lines.slice(index + 1, Math.min(lines.length, index + 8)).join("\n");
172
+ return /<<<EXTERNAL_UNTRUSTED_CONTENT|UNTRUSTED channel metadata \(|Source:\s+/.test(probe);
173
+ }
174
+
175
+ export function stripInboundMetadata(text: string): string {
176
+ if (!text) return text;
177
+ if (!SENTINEL_FAST_RE.test(text)) {
178
+ return text.replace(TIMESTAMP_PREFIX_RE, "").trim() || text;
179
+ }
180
+ const lines = text.split("\n");
181
+ const result: string[] = [];
182
+ let inMetaBlock = false;
183
+ let inFencedJson = false;
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const line = lines[i];
186
+ if (!inMetaBlock && isTrailingUntrustedContext(lines, i)) break;
187
+ if (!inMetaBlock && isSentinelLine(line)) {
188
+ const next = lines[i + 1];
189
+ if (next?.trim() !== "```json") { result.push(line); continue; }
190
+ inMetaBlock = true; inFencedJson = false; continue;
191
+ }
192
+ if (inMetaBlock) {
193
+ if (!inFencedJson && line.trim() === "```json") { inFencedJson = true; continue; }
194
+ if (inFencedJson) {
195
+ if (line.trim() === "```") { inMetaBlock = false; inFencedJson = false; }
196
+ continue;
197
+ }
198
+ if (line.trim() === "") continue;
199
+ inMetaBlock = false;
200
+ }
201
+ result.push(line);
202
+ }
203
+ let cleaned = result.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
204
+ cleaned = cleaned.replace(TIMESTAMP_PREFIX_RE, "").trim();
205
+ return cleaned;
206
+ }
207
+
208
+ function extractToolCallsFromContent(content: any[] | undefined): Array<{
209
+ id: string; name: string; arguments: string;
210
+ }> {
211
+ if (!Array.isArray(content)) return [];
212
+ const calls: Array<{ id: string; name: string; arguments: string }> = [];
213
+ for (const block of content) {
214
+ if (!block || typeof block !== "object") continue;
215
+ if (block.type === "toolCall" || block.type === "tool_use") {
216
+ calls.push({
217
+ id: block.id ?? `call_${Date.now()}`,
218
+ name: block.name ?? "unknown",
219
+ arguments: typeof block.arguments === "string"
220
+ ? block.arguments
221
+ : JSON.stringify(block.arguments ?? {}),
222
+ });
223
+ }
224
+ }
225
+ return calls;
226
+ }
227
+
228
+ // ─── APM 兼容的索引属性设置 ───────────────────────────────────────────────
229
+
230
+ function setIndexedPromptAttrs(attrs: Record<string, any>, messages: ChatMessage[]): void {
231
+ let selected = messages;
232
+ if (messages.length > MAX_INDEXED_PROMPT_MESSAGES) {
233
+ selected = [...messages.slice(0, 1), ...messages.slice(-(MAX_INDEXED_PROMPT_MESSAGES - 1))];
234
+ }
235
+ for (let i = 0; i < selected.length; i++) {
236
+ const msg = selected[i];
237
+ attrs[`gen_ai.prompt.${i}.role`] = msg.role ?? "unknown";
238
+ const text = extractText(msg.content ?? msg.text);
239
+ if (text) attrs[`gen_ai.prompt.${i}.content`] = truncate(text, MAX_ATTR_CONTENT_LENGTH);
240
+ }
241
+ }
242
+
243
+ function setIndexedCompletionAttrs(
244
+ attrs: Record<string, any>,
245
+ messages: Array<ChatMessage & { toolCalls?: Array<{ id: string; name: string; arguments: string }> }>,
246
+ ): void {
247
+ for (let i = 0; i < messages.length; i++) {
248
+ const msg = messages[i];
249
+ attrs[`gen_ai.completion.${i}.role`] = msg.role ?? "assistant";
250
+ const text = extractText(msg.content ?? msg.text);
251
+ if (text) attrs[`gen_ai.completion.${i}.content`] = truncate(text, MAX_ATTR_CONTENT_LENGTH);
252
+ const finishReason = msg.finishReason ?? msg.stopReason ?? "stop";
253
+ attrs[`gen_ai.completion.${i}.finish_reason`] = finishReason;
254
+ const toolCalls = msg.toolCalls ?? extractToolCallsFromContent(msg.content as any[]);
255
+ if (toolCalls.length > 0) {
256
+ attrs[`gen_ai.completion.${i}.finish_reason`] = "tool_calls";
257
+ for (let j = 0; j < toolCalls.length; j++) {
258
+ const tc = toolCalls[j];
259
+ attrs[`gen_ai.completion.${i}.tool_calls.${j}.id`] = tc.id;
260
+ attrs[`gen_ai.completion.${i}.tool_calls.${j}.name`] = tc.name;
261
+ attrs[`gen_ai.completion.${i}.tool_calls.${j}.arguments`] = truncate(tc.arguments, MAX_ATTR_CONTENT_LENGTH);
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ interface ToolDefinition { name: string; description?: string; parameters?: any; }
268
+
269
+ function setIndexedFunctionAttrs(attrs: Record<string, any>, tools: ToolDefinition[]): void {
270
+ for (let i = 0; i < tools.length; i++) {
271
+ const tool = tools[i];
272
+ attrs[`llm.request.functions.${i}.name`] = tool.name;
273
+ if (tool.description) attrs[`llm.request.functions.${i}.description`] = truncate(tool.description, 1024);
274
+ if (tool.parameters) {
275
+ const params = typeof tool.parameters === "string" ? tool.parameters : JSON.stringify(tool.parameters);
276
+ attrs[`llm.request.functions.${i}.parameters`] = truncate(params, 2048);
277
+ }
278
+ }
279
+ }
280
+
281
+ function setPromptSummary(attrs: Record<string, any>, totalMessages: number, roleCounts: Map<string, number>) {
282
+ attrs["gen_ai.prompt.num_messages"] = totalMessages;
283
+ attrs["gen_ai.prompt.role_counts"] = [...roleCounts.entries()].map(([r, c]) => `${r}:${c}`).join(", ");
284
+ }
285
+
286
+ // ─── 轻量级 Span 实现 ────────────────────────────────────────────────────
287
+
288
+ type SpanStatusCode = "UNSET" | "OK" | "ERROR";
289
+ interface SpanStatus { code: SpanStatusCode; message?: string; }
290
+
291
+ function genTraceId(): string {
292
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
293
+ }
294
+
295
+ function genSpanId(): string {
296
+ return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
297
+ }
298
+
299
+ export class ClsSpan {
300
+ readonly traceId: string;
301
+ readonly spanId: string;
302
+ readonly parentSpanId: string | undefined;
303
+ readonly name: string;
304
+ readonly startTimeMs: number;
305
+ private endTimeMs: number | undefined;
306
+ public status: SpanStatus = { code: "UNSET" };
307
+ public attrs: Record<string, any> = {};
308
+ private events: Array<{ name: string; attrs?: Record<string, any>; timeMs: number }> = [];
309
+ private _ended = false;
310
+ onEnd?: (span: ClsSpan) => void;
311
+
312
+ constructor(name: string, traceId: string, parentSpanId?: string) {
313
+ this.name = name;
314
+ this.traceId = traceId;
315
+ this.spanId = genSpanId();
316
+ this.parentSpanId = parentSpanId;
317
+ this.startTimeMs = Date.now();
318
+ }
319
+
320
+ setAttribute(key: string, value: any): this { this.attrs[key] = value; return this; }
321
+ setAttributes(kv: Record<string, any>): this { Object.assign(this.attrs, kv); return this; }
322
+ setStatus(status: SpanStatus): this { this.status = status; return this; }
323
+ addEvent(name: string, attrs?: Record<string, any>): this {
324
+ this.events.push({ name, attrs, timeMs: Date.now() }); return this;
325
+ }
326
+ end(): void {
327
+ if (this._ended) return;
328
+ this._ended = true;
329
+ this.endTimeMs = Date.now();
330
+ this.onEnd?.(this);
331
+ }
332
+ isEnded(): boolean { return this._ended; }
333
+
334
+ toRecord(): Record<string, string> {
335
+ const rec: Record<string, string> = {
336
+ "trace.id": this.traceId,
337
+ "span.id": this.spanId,
338
+ "span.name": this.name,
339
+ "span.kind": this.attrs["gen_ai.span.kind"] ?? "INTERNAL",
340
+ "span.status": this.status.code,
341
+ "span.start_time": new Date(this.startTimeMs).toISOString(),
342
+ "span.end_time": this.endTimeMs ? new Date(this.endTimeMs).toISOString() : "",
343
+ "span.duration_ms": String(this.endTimeMs ? this.endTimeMs - this.startTimeMs : 0),
344
+ };
345
+ if (this.parentSpanId) rec["span.parent_id"] = this.parentSpanId;
346
+ if (this.status.message) rec["span.status_message"] = this.status.message;
347
+ for (const [k, v] of Object.entries(this.attrs)) {
348
+ if (v === undefined || v === null) continue;
349
+ rec[k] = typeof v === "string" ? v : String(v);
350
+ }
351
+ if (this.events.length > 0) rec["span.events"] = JSON.stringify(this.events);
352
+ return rec;
353
+ }
354
+ }
355
+
356
+ // ─── CLS 发送器 ───────────────────────────────────────────────────────────
357
+
358
+ export class ClsSender {
359
+ private producer: Producer;
360
+ constructor(producer: Producer) { this.producer = producer; }
361
+
362
+ async sendSpan(span: ClsSpan): Promise<void> {
363
+ try {
364
+ const record = span.toRecord();
365
+ const logItem = new LogItem();
366
+ logItem.setTime(Math.floor(span.startTimeMs / 1000));
367
+ for (const [k, v] of Object.entries(record)) {
368
+ logItem.pushBack(new Content(k, v));
369
+ }
370
+ await this.producer.send(logItem);
371
+ } catch (_e) { /* 发送失败静默处理 */ }
372
+ }
373
+ }
374
+
375
+ // ─── 每会话 Trace 状态 ────────────────────────────────────────────────────
376
+
377
+ export interface TraceState {
378
+ traceId: string;
379
+ rootSpan: ClsSpan;
380
+ sessionSpan?: ClsSpan;
381
+ agentSpan?: ClsSpan;
382
+ modelResolveSpan?: ClsSpan;
383
+ promptBuildSpan?: ClsSpan;
384
+ llmSpan?: ClsSpan;
385
+ llmRound: number;
386
+ toolSpans: Map<string, ClsSpan>;
387
+ subagentSpans: Map<string, ClsSpan>;
388
+ compactionSpan?: ClsSpan;
389
+ replySpan?: ClsSpan;
390
+ createdAt: number;
391
+ messageCount: number;
392
+ agentInputUserMessage?: string;
393
+ agentOutputAssistantMessage?: string;
394
+ agentOutputToolCalls?: Array<{ id: string; name: string; arguments: string }>;
395
+ agentProvider?: string;
396
+ agentModel?: string;
397
+ }
398
+
399
+ // ─── Helper: close all child spans ───────────────────────────────────────
400
+
401
+ export function endAllChildren(st: TraceState): void {
402
+ for (const [, span] of st.toolSpans) { span.setStatus({ code: "OK" }); span.end(); }
403
+ st.toolSpans.clear();
404
+ for (const [, span] of st.subagentSpans) { span.setStatus({ code: "OK" }); span.end(); }
405
+ st.subagentSpans.clear();
406
+ if (st.llmSpan) { st.llmSpan.setStatus({ code: "OK" }); st.llmSpan.end(); st.llmSpan = undefined; }
407
+ if (st.compactionSpan) { st.compactionSpan.setStatus({ code: "OK" }); st.compactionSpan.end(); st.compactionSpan = undefined; }
408
+ if (st.modelResolveSpan) { st.modelResolveSpan.setStatus({ code: "OK" }); st.modelResolveSpan.end(); st.modelResolveSpan = undefined; }
409
+ if (st.promptBuildSpan) { st.promptBuildSpan.setStatus({ code: "OK" }); st.promptBuildSpan.end(); st.promptBuildSpan = undefined; }
410
+ if (st.replySpan) { st.replySpan.setStatus({ code: "OK" }); st.replySpan.end(); st.replySpan = undefined; }
411
+ if (st.agentSpan) {
412
+ try {
413
+ st.agentSpan.setAttribute("llm.request.type", "chat");
414
+ if (st.agentInputUserMessage) {
415
+ setIndexedPromptAttrs(st.agentSpan.attrs, [{ role: "user", content: st.agentInputUserMessage }]);
416
+ }
417
+ if (st.agentOutputAssistantMessage || st.agentOutputToolCalls) {
418
+ const completionMsg: ChatMessage & { toolCalls?: Array<{ id: string; name: string; arguments: string }> } = {
419
+ role: "assistant",
420
+ content: st.agentOutputAssistantMessage ?? "",
421
+ stopReason: st.agentOutputToolCalls ? "tool_calls" : "stop",
422
+ toolCalls: st.agentOutputToolCalls,
423
+ };
424
+ setIndexedCompletionAttrs(st.agentSpan.attrs, [completionMsg]);
425
+ }
426
+ if (st.agentProvider) {
427
+ st.agentSpan.setAttribute(A.GENAI_PROVIDER_NAME, st.agentProvider);
428
+ st.agentSpan.setAttribute(A.GENAI_SYSTEM, st.agentProvider);
429
+ }
430
+ if (st.agentModel) {
431
+ st.agentSpan.setAttribute(A.GENAI_MODEL_NAME, st.agentModel);
432
+ st.agentSpan.setAttribute(A.GENAI_REQUEST_MODEL, st.agentModel);
433
+ }
434
+ } catch (_) { /* best-effort */ }
435
+ st.agentSpan.setStatus({ code: "OK" }); st.agentSpan.end(); st.agentSpan = undefined;
436
+ }
437
+ if (st.sessionSpan) { st.sessionSpan.setStatus({ code: "OK" }); st.sessionSpan.end(); st.sessionSpan = undefined; }
438
+ }
439
+
440
+ // ─── 插件核心:创建 CLS 插件实例(service + hook 注册器)────────────────
441
+
442
+ export interface TencentClsPlugin {
443
+ /** 注册所有 hook 到 api.on() */
444
+ registerHooks(api: any): void;
445
+ /** OpenClawPluginService 对象,用于 api.registerService() */
446
+ service: {
447
+ id: string;
448
+ start(ctx: any): void | Promise<void>;
449
+ stop(ctx: any): void | Promise<void>;
450
+ };
451
+ }
452
+
453
+ export function createTencentClsPlugin(config: TencentClsPluginConfig): TencentClsPlugin {
454
+ const topicId = config.topicId ?? "";
455
+ const endpoint = config.endpoint ?? "";
456
+ const secretId = config.secretId ?? "";
457
+ const secretKey = config.secretKey ?? "";
458
+ const token = config.token;
459
+
460
+ let sender: ClsSender | null = null;
461
+ const states = new Map<string, TraceState>();
462
+ const sessionKeyMap = new Map<string, string>();
463
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
464
+
465
+ // ── Span 工厂 ──────────────────────────────────────────────────────────
466
+ function makeSpan(name: string, traceId: string, parentSpanId?: string): ClsSpan {
467
+ const span = new ClsSpan(name, traceId, parentSpanId);
468
+ span.onEnd = (s) => { sender?.sendSpan(s).catch(() => {}); };
469
+ return span;
470
+ }
471
+
472
+ // ── Helper: resolve session key ────────────────────────────────────────
473
+ function resolveKey(hookCtx: any, event?: any): string {
474
+ if (hookCtx?.sessionKey) {
475
+ const mapped = sessionKeyMap.get(hookCtx.sessionKey);
476
+ if (mapped && states.has(mapped)) return mapped;
477
+ if (states.has(hookCtx.sessionKey)) return hookCtx.sessionKey;
478
+ if (mapped) return mapped;
479
+ }
480
+ const channelId = hookCtx?.channelId ?? "unknown";
481
+ const convId = hookCtx?.conversationId ?? event?.metadata?.threadId ?? "default";
482
+ return `${channelId}:${convId}`;
483
+ }
484
+
485
+ function ensureSessionKeyMapping(hookCtx: any, log: any) {
486
+ if (!hookCtx?.sessionKey) return;
487
+ if (sessionKeyMap.has(hookCtx.sessionKey)) return;
488
+ for (const [ck] of states) {
489
+ if (!ck.includes(":")) continue;
490
+ sessionKeyMap.set(hookCtx.sessionKey, ck);
491
+ log.info(`${LOG_PREFIX} Auto-mapped sessionKey=${hookCtx.sessionKey} → compositeKey=${ck}`);
492
+ return;
493
+ }
494
+ }
495
+
496
+ function agentOrRoot(st: TraceState): ClsSpan | undefined {
497
+ return st.agentSpan ?? st.rootSpan;
498
+ }
499
+
500
+ function cleanupState(key: string) {
501
+ states.delete(key);
502
+ for (const [sk, ck] of sessionKeyMap) {
503
+ if (ck === key) sessionKeyMap.delete(sk);
504
+ }
505
+ }
506
+
507
+ // ── Service ────────────────────────────────────────────────────────────
508
+ const service = {
509
+ id: "openclaw-tencent-cls-plugin",
510
+
511
+ async start(ctx: any) {
512
+ const log = ctx.logger;
513
+ if (!topicId || !endpoint || !secretId || !secretKey) {
514
+ log.warn(`${LOG_PREFIX} CLS 配置不完整(topicId/endpoint/secretId/secretKey),trace 数据将不会发送`);
515
+ }
516
+ const producer = new Producer({
517
+ topic_id: topicId,
518
+ endpoint,
519
+ credential: { secretId, secretKey, token },
520
+ onSendLogsError: (res: any) => {
521
+ log.warn(`${LOG_PREFIX} CLS 发送失败: ${JSON.stringify(res)}`);
522
+ },
523
+ });
524
+ sender = new ClsSender(producer);
525
+ log.info(`${LOG_PREFIX} CLS Producer 已初始化,endpoint=${endpoint} topicId=${topicId}`);
526
+
527
+ sweepTimer = setInterval(() => {
528
+ const now = Date.now();
529
+ for (const [key, st] of states) {
530
+ if (now - st.createdAt > MAX_SPAN_AGE_MS) {
531
+ log.info(`${LOG_PREFIX} Sweeping orphan trace for key=${key}`);
532
+ endAllChildren(st);
533
+ st.rootSpan.setStatus({ code: "OK" });
534
+ st.rootSpan.setAttribute("openclaw.sweep", true);
535
+ st.rootSpan.end();
536
+ states.delete(key);
537
+ }
538
+ }
539
+ }, 60_000);
540
+ },
541
+
542
+ async stop(_ctx: any) {
543
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
544
+ for (const [, st] of states) {
545
+ endAllChildren(st);
546
+ st.rootSpan.setStatus({ code: "OK" });
547
+ st.rootSpan.setAttribute("openclaw.cleanup", true);
548
+ st.rootSpan.end();
549
+ }
550
+ states.clear();
551
+ sessionKeyMap.clear();
552
+ sender = null;
553
+ },
554
+ };
555
+
556
+ // ── Hook 注册器 ────────────────────────────────────────────────────────
557
+ function registerHooks(api: any) {
558
+ const log = api.logger;
559
+
560
+ // 1. message_received → Root Span
561
+ api.on("message_received", (event: any, hookCtx: any) => {
562
+ const channelId = hookCtx?.channelId ?? "unknown";
563
+ const convId = hookCtx?.conversationId ?? "default";
564
+ const compositeKey = `${channelId}:${convId}`;
565
+ log.info(`${LOG_PREFIX} [message_received] key=${compositeKey} from=${event?.from ?? "?"}`);
566
+ const prev = states.get(compositeKey);
567
+ if (prev) {
568
+ endAllChildren(prev);
569
+ prev.rootSpan.setAttribute("openclaw.message.count", prev.messageCount);
570
+ prev.rootSpan.setStatus({ code: "OK" });
571
+ prev.rootSpan.end();
572
+ states.delete(compositeKey);
573
+ }
574
+ const traceId = genTraceId();
575
+ const rootSpan = makeSpan(SPAN.MESSAGE, traceId);
576
+ try {
577
+ const userMsg = event?.content;
578
+ if (userMsg) {
579
+ const rawStr = typeof userMsg === "string" ? userMsg : extractText(userMsg);
580
+ rootSpan.setAttribute("openclaw.user.message", truncate(rawStr, MAX_ATTR_CONTENT_LENGTH));
581
+ }
582
+ } catch (_) { /* best-effort */ }
583
+ states.set(compositeKey, {
584
+ traceId, rootSpan, llmRound: 0,
585
+ toolSpans: new Map(), subagentSpans: new Map(),
586
+ createdAt: Date.now(), messageCount: 1,
587
+ });
588
+ log.info(`${LOG_PREFIX} Started root span: traceId=${rootSpan.traceId} key=${compositeKey}`);
589
+ }, { priority: -100 });
590
+
591
+ // 2. session_start → Session Span
592
+ api.on("session_start", (event: any, hookCtx: any) => {
593
+ ensureSessionKeyMapping(hookCtx, log);
594
+ const key = resolveKey(hookCtx, event);
595
+ const st = states.get(key);
596
+ log.info(`${LOG_PREFIX} [session_start] key=${key} hasState=${!!st}`);
597
+ if (!st) return;
598
+ if (hookCtx?.sessionKey && hookCtx.sessionKey !== key) sessionKeyMap.set(hookCtx.sessionKey, key);
599
+ st.sessionSpan = makeSpan(SPAN.SESSION, st.traceId, st.rootSpan.spanId);
600
+ }, { priority: -100 });
601
+
602
+ // 3. before_model_resolve → Model Resolve Span + Agent Span
603
+ api.on("before_model_resolve", (event: any, hookCtx: any) => {
604
+ ensureSessionKeyMapping(hookCtx, log);
605
+ const key = resolveKey(hookCtx, event);
606
+ const st = states.get(key);
607
+ log.info(`${LOG_PREFIX} [before_model_resolve] key=${key} hasState=${!!st}`);
608
+ if (!st) return;
609
+ if (!st.agentSpan) st.agentSpan = makeSpan(SPAN.AGENT_PROCESSING, st.traceId, st.rootSpan.spanId);
610
+ st.modelResolveSpan = makeSpan(SPAN.MODEL_RESOLVE, st.traceId, st.agentSpan.spanId);
611
+ }, { priority: -100 });
612
+
613
+ // 4. before_prompt_build → Prompt Build Span
614
+ api.on("before_prompt_build", (event: any, hookCtx: any) => {
615
+ ensureSessionKeyMapping(hookCtx, log);
616
+ const key = resolveKey(hookCtx, event);
617
+ let st = states.get(key);
618
+ log.info(`${LOG_PREFIX} [before_prompt_build] key=${key} hasState=${!!st}`);
619
+ if (!st) {
620
+ const traceId = genTraceId();
621
+ const rootSpan = makeSpan(SPAN.MESSAGE, traceId);
622
+ st = { traceId, rootSpan, llmRound: 0, toolSpans: new Map(), subagentSpans: new Map(), createdAt: Date.now(), messageCount: 1 };
623
+ states.set(key, st);
624
+ if (hookCtx?.sessionKey && hookCtx.sessionKey !== key) sessionKeyMap.set(hookCtx.sessionKey, key);
625
+ }
626
+ if (st.modelResolveSpan) { st.modelResolveSpan.setStatus({ code: "OK" }); st.modelResolveSpan.end(); st.modelResolveSpan = undefined; }
627
+ st.promptBuildSpan = makeSpan(SPAN.PROMPT_BUILD, st.traceId, agentOrRoot(st)?.spanId);
628
+ }, { priority: -100 });
629
+
630
+ // 5. before_agent_start → Fallback agent span
631
+ api.on("before_agent_start", (event: any, hookCtx: any) => {
632
+ ensureSessionKeyMapping(hookCtx, log);
633
+ const key = resolveKey(hookCtx, event);
634
+ let st = states.get(key);
635
+ log.info(`${LOG_PREFIX} [before_agent_start] key=${key} hasState=${!!st}`);
636
+ if (!st) {
637
+ const traceId = genTraceId();
638
+ const rootSpan = makeSpan(SPAN.MESSAGE, traceId);
639
+ st = { traceId, rootSpan, llmRound: 0, toolSpans: new Map(), subagentSpans: new Map(), createdAt: Date.now(), messageCount: 1 };
640
+ states.set(key, st);
641
+ if (hookCtx?.sessionKey && hookCtx.sessionKey !== key) sessionKeyMap.set(hookCtx.sessionKey, key);
642
+ }
643
+ if (!st.agentSpan) st.agentSpan = makeSpan(SPAN.AGENT_PROCESSING, st.traceId, st.rootSpan.spanId);
644
+ }, { priority: -100 });
645
+
646
+ // 6. llm_input → LLM Call Span
647
+ api.on("llm_input", (event: any, hookCtx: any) => {
648
+ ensureSessionKeyMapping(hookCtx, log);
649
+ const key = resolveKey(hookCtx, event);
650
+ const st = states.get(key);
651
+ log.info(`${LOG_PREFIX} [llm_input] key=${key} provider=${event?.provider ?? "?"} model=${event?.model ?? "?"} hasState=${!!st}`);
652
+ if (!st) return;
653
+ if (st.promptBuildSpan) { st.promptBuildSpan.setStatus({ code: "OK" }); st.promptBuildSpan.end(); st.promptBuildSpan = undefined; }
654
+ if (st.modelResolveSpan) { st.modelResolveSpan.setStatus({ code: "OK" }); st.modelResolveSpan.end(); st.modelResolveSpan = undefined; }
655
+ if (st.llmSpan) { st.llmSpan.setStatus({ code: "OK" }); st.llmSpan.end(); }
656
+ st.llmRound += 1;
657
+ const provider = event?.provider ?? "unknown";
658
+ const model = event?.model ?? "unknown";
659
+ if (st.llmRound === 1) {
660
+ st.agentProvider = provider;
661
+ st.agentModel = model;
662
+ if (event?.prompt) {
663
+ st.agentInputUserMessage = stripInboundMetadata(
664
+ typeof event.prompt === "string" ? event.prompt : extractText(event.prompt),
665
+ );
666
+ }
667
+ }
668
+ st.llmSpan = makeSpan(`chat ${model}`, st.traceId, agentOrRoot(st)?.spanId);
669
+ try {
670
+ const promptMessages: ChatMessage[] = [];
671
+ if (event?.systemPrompt) promptMessages.push({ role: "system", content: event.systemPrompt });
672
+ if (Array.isArray(event?.historyMessages)) {
673
+ for (const msg of event.historyMessages) {
674
+ const role = msg?.role ?? "unknown";
675
+ let content = msg?.content ?? msg?.text ?? "";
676
+ if (content) {
677
+ if (role === "user") {
678
+ const textContent = typeof content === "string" ? content : extractText(content);
679
+ content = stripInboundMetadata(textContent);
680
+ }
681
+ promptMessages.push({ role, content });
682
+ }
683
+ }
684
+ }
685
+ if (event?.prompt) {
686
+ const cleanedPrompt = stripInboundMetadata(
687
+ typeof event.prompt === "string" ? event.prompt : extractText(event.prompt),
688
+ );
689
+ promptMessages.push({ role: "user", content: cleanedPrompt });
690
+ }
691
+ if (promptMessages.length > 0) {
692
+ st.llmSpan.setAttribute(A.LLM_REQUEST_TYPE, "chat");
693
+ setIndexedPromptAttrs(st.llmSpan.attrs, promptMessages);
694
+ try {
695
+ const entityInput = JSON.stringify(promptMessages.map((m) => ({
696
+ role: m.role,
697
+ content: truncate(
698
+ m.role === "user"
699
+ ? stripInboundMetadata(extractText(m.content ?? m.text))
700
+ : extractText(m.content ?? m.text),
701
+ MAX_ATTR_CONTENT_LENGTH,
702
+ ),
703
+ })));
704
+ st.llmSpan.setAttribute(A.TRACELOOP_ENTITY_INPUT, truncateJson(entityInput, MAX_ATTR_CONTENT_LENGTH));
705
+ } catch (_) { /* best-effort */ }
706
+ const roleCounts = new Map<string, number>();
707
+ for (const m of promptMessages) {
708
+ const r = typeof m.role === "string" ? m.role : "unknown";
709
+ roleCounts.set(r, (roleCounts.get(r) ?? 0) + 1);
710
+ }
711
+ setPromptSummary(st.llmSpan.attrs, promptMessages.length, roleCounts);
712
+ }
713
+ if (Array.isArray(event?.tools)) {
714
+ const toolDefs: ToolDefinition[] = event.tools.map((t: any) => ({
715
+ name: t.name ?? "unknown", description: t.description ?? "", parameters: t.parameters ?? t.inputSchema ?? {},
716
+ }));
717
+ setIndexedFunctionAttrs(st.llmSpan.attrs, toolDefs);
718
+ }
719
+ } catch (e) {
720
+ log.info(`${LOG_PREFIX} [llm_input] Error setting prompt attributes: ${e}`);
721
+ }
722
+ }, { priority: -100 });
723
+
724
+ // 7. llm_output → End LLM Call Span
725
+ api.on("llm_output", (event: any, hookCtx: any) => {
726
+ ensureSessionKeyMapping(hookCtx, log);
727
+ const key = resolveKey(hookCtx, event);
728
+ const st = states.get(key);
729
+ if (!st?.llmSpan) { log.info(`${LOG_PREFIX} [llm_output] key=${key} — no LLM span to end`); return; }
730
+ const usage = event?.usage ?? {};
731
+ const inputTokens = usage.input ?? usage.promptTokens ?? 0;
732
+ const outputTokens = usage.output ?? usage.completionTokens ?? 0;
733
+ const totalTokens = usage.total ?? (inputTokens + outputTokens);
734
+ const cacheRead = usage.cacheRead ?? 0;
735
+ const cacheWrite = usage.cacheWrite ?? usage.cacheCreation ?? 0;
736
+ st.llmSpan.setAttributes({
737
+ [A.GENAI_USAGE_INPUT_TOKENS]: inputTokens,
738
+ [A.GENAI_USAGE_OUTPUT_TOKENS]: outputTokens,
739
+ [A.GENAI_USAGE_CACHE_READ_INPUT]: cacheRead,
740
+ [A.GENAI_USAGE_CACHE_CREATION_INPUT]: cacheWrite,
741
+ [A.GENAI_USAGE_PROMPT_TOKENS]: inputTokens,
742
+ [A.GENAI_USAGE_COMPLETION_TOKENS]: outputTokens,
743
+ [A.GENAI_USAGE_TOTAL_TOKENS]: totalTokens,
744
+ [A.GENAI_RESPONSE_MODEL]: event?.model ?? "unknown",
745
+ });
746
+ try {
747
+ const completionMessages: Array<ChatMessage & { toolCalls?: Array<{ id: string; name: string; arguments: string }> }> = [];
748
+ if (Array.isArray(event?.assistantTexts) && event.assistantTexts.length > 0) {
749
+ const fullText = event.assistantTexts.join("");
750
+ const toolCalls = extractToolCallsFromContent(event?.lastAssistant?.content);
751
+ completionMessages.push({ role: "assistant", content: fullText, stopReason: event?.lastAssistant?.stopReason ?? "stop", toolCalls: toolCalls.length > 0 ? toolCalls : undefined });
752
+ } else if (event?.lastAssistant) {
753
+ const la = event.lastAssistant;
754
+ const text = extractText(la.content ?? la.text);
755
+ const toolCalls = extractToolCallsFromContent(la.content);
756
+ completionMessages.push({ role: "assistant", content: text, stopReason: la.stopReason ?? "stop", toolCalls: toolCalls.length > 0 ? toolCalls : undefined });
757
+ }
758
+ if (completionMessages.length > 0) {
759
+ setIndexedCompletionAttrs(st.llmSpan.attrs, completionMessages);
760
+ try {
761
+ const entityOutput = JSON.stringify(completionMessages.map((m) => ({
762
+ role: m.role ?? "assistant",
763
+ content: truncate(typeof m.content === "string" ? m.content : extractText(m.content), MAX_ATTR_CONTENT_LENGTH),
764
+ ...(m.toolCalls && m.toolCalls.length > 0 ? { tool_calls: m.toolCalls } : {}),
765
+ })));
766
+ st.llmSpan.setAttribute(A.TRACELOOP_ENTITY_OUTPUT, truncateJson(entityOutput, MAX_ATTR_CONTENT_LENGTH));
767
+ } catch (_) { /* best-effort */ }
768
+ const lastContent = typeof completionMessages[0]?.content === "string" ? completionMessages[0].content : extractText(completionMessages[0]?.content);
769
+ if (lastContent) st.agentOutputAssistantMessage = lastContent;
770
+ if (completionMessages[0]?.toolCalls) st.agentOutputToolCalls = completionMessages[0].toolCalls;
771
+ }
772
+ } catch (e) {
773
+ log.info(`${LOG_PREFIX} [llm_output] Error setting completion attributes: ${e}`);
774
+ }
775
+ st.llmSpan.setStatus({ code: "OK" });
776
+ st.llmSpan.end();
777
+ log.info(`${LOG_PREFIX} [llm_output] Ended LLM span for key=${key} tokens.in=${inputTokens} tokens.out=${outputTokens}`);
778
+ st.llmSpan = undefined;
779
+ }, { priority: -100 });
780
+
781
+ // 8. before_tool_call → Tool Span
782
+ api.on("before_tool_call", (event: any, hookCtx: any) => {
783
+ ensureSessionKeyMapping(hookCtx, log);
784
+ const key = resolveKey(hookCtx, event);
785
+ const st = states.get(key);
786
+ const toolName = event?.toolName ?? hookCtx?.toolName ?? "unknown";
787
+ const toolCallId = hookCtx?.toolCallId ?? event?.toolCallId ?? `tool-${Date.now()}`;
788
+ log.info(`${LOG_PREFIX} [before_tool_call] key=${key} tool=${toolName} callId=${toolCallId} hasState=${!!st}`);
789
+ if (!st) return;
790
+ const span = makeSpan(`${SPAN.TOOL}.${toolName}`, st.traceId, st.llmSpan?.spanId);
791
+ try {
792
+ const toolInput = event?.arguments ?? event?.input ?? event?.params;
793
+ if (toolInput) {
794
+ const inputStr = typeof toolInput === "string" ? ensureJson(toolInput) : JSON.stringify(toolInput);
795
+ span.setAttribute(A.TRACELOOP_ENTITY_INPUT, truncateJson(inputStr, MAX_ATTR_CONTENT_LENGTH));
796
+ }
797
+ } catch (_) { /* best-effort */ }
798
+ st.toolSpans.set(toolCallId, span);
799
+ }, { priority: -100 });
800
+
801
+ // 9. after_tool_call → End Tool Span
802
+ api.on("after_tool_call", (event: any, hookCtx: any) => {
803
+ ensureSessionKeyMapping(hookCtx, log);
804
+ const key = resolveKey(hookCtx, event);
805
+ const st = states.get(key);
806
+ if (!st) return;
807
+ const toolCallId = hookCtx?.toolCallId ?? event?.toolCallId ?? "";
808
+ const span = st.toolSpans.get(toolCallId);
809
+ if (!span) { log.info(`${LOG_PREFIX} [after_tool_call] key=${key} callId=${toolCallId} — no matching span`); return; }
810
+ if (event?.durationMs != null) span.setAttribute(A.TOOL_DURATION_MS, event.durationMs);
811
+ try {
812
+ const toolOutput = event?.result ?? event?.output ?? event?.response;
813
+ if (toolOutput) {
814
+ const outputStr = typeof toolOutput === "string" ? ensureJson(toolOutput) : JSON.stringify(toolOutput);
815
+ span.setAttribute(A.TRACELOOP_ENTITY_OUTPUT, truncateJson(outputStr, MAX_ATTR_CONTENT_LENGTH));
816
+ }
817
+ } catch (_) { /* best-effort */ }
818
+ if (event?.error) { span.setStatus({ code: "ERROR", message: String(event.error) }); } else { span.setStatus({ code: "OK" }); }
819
+ span.end();
820
+ st.toolSpans.delete(toolCallId);
821
+ log.info(`${LOG_PREFIX} [after_tool_call] Ended tool span key=${key} tool=${event?.toolName ?? "?"} callId=${toolCallId}`);
822
+ }, { priority: -100 });
823
+
824
+ // 10. before_compaction → Compaction Span
825
+ api.on("before_compaction", (event: any, hookCtx: any) => {
826
+ ensureSessionKeyMapping(hookCtx, log);
827
+ const key = resolveKey(hookCtx, event);
828
+ const st = states.get(key);
829
+ log.info(`${LOG_PREFIX} [before_compaction] key=${key} hasState=${!!st}`);
830
+ if (!st) return;
831
+ st.compactionSpan = makeSpan(SPAN.COMPACTION, st.traceId, agentOrRoot(st)?.spanId);
832
+ }, { priority: -100 });
833
+
834
+ // 11. after_compaction → End Compaction Span
835
+ api.on("after_compaction", (event: any, hookCtx: any) => {
836
+ ensureSessionKeyMapping(hookCtx, log);
837
+ const key = resolveKey(hookCtx, event);
838
+ const st = states.get(key);
839
+ log.info(`${LOG_PREFIX} [after_compaction] key=${key} hasState=${!!st}`);
840
+ if (!st?.compactionSpan) return;
841
+ st.compactionSpan.setAttributes({
842
+ [A.COMPACTION_MSG_AFTER]: event?.messageCount ?? -1,
843
+ [A.COMPACTION_TOKENS_AFTER]: event?.tokenCount ?? -1,
844
+ [A.COMPACTION_COMPACTED]: event?.compactedCount ?? 0,
845
+ });
846
+ st.compactionSpan.setStatus({ code: "OK" });
847
+ st.compactionSpan.end();
848
+ st.compactionSpan = undefined;
849
+ }, { priority: -100 });
850
+
851
+ // 12. subagent_spawning → Log
852
+ api.on("subagent_spawning", (event: any, hookCtx: any) => {
853
+ ensureSessionKeyMapping(hookCtx, log);
854
+ const key = resolveKey(hookCtx, event);
855
+ log.info(`${LOG_PREFIX} [subagent_spawning] key=${key} childSessionKey=${event?.sessionKey ?? "?"}`);
856
+ }, { priority: -100 });
857
+
858
+ // 13. subagent_spawned → Subagent Span
859
+ api.on("subagent_spawned", (event: any, hookCtx: any) => {
860
+ ensureSessionKeyMapping(hookCtx, log);
861
+ const parentKey = resolveKey(hookCtx, event);
862
+ const st = states.get(parentKey);
863
+ if (!st) return;
864
+ const childKey = event?.sessionKey ?? event?.childSessionKey ?? "unknown-child";
865
+ const span = makeSpan(SPAN.SUBAGENT, st.traceId, st.agentSpan?.spanId);
866
+ st.subagentSpans.set(childKey, span);
867
+ log.info(`${LOG_PREFIX} [subagent_spawned] parent=${parentKey} child=${childKey}`);
868
+ }, { priority: -100 });
869
+
870
+ // 14. subagent_ended → End Subagent Span
871
+ api.on("subagent_ended", (event: any, hookCtx: any) => {
872
+ ensureSessionKeyMapping(hookCtx, log);
873
+ const parentKey = resolveKey(hookCtx, event);
874
+ const st = states.get(parentKey);
875
+ if (!st) return;
876
+ const childKey = event?.sessionKey ?? event?.childSessionKey ?? "unknown-child";
877
+ const span = st.subagentSpans.get(childKey);
878
+ if (!span) return;
879
+ span.setStatus({ code: "OK" }); span.end(); st.subagentSpans.delete(childKey);
880
+ log.info(`${LOG_PREFIX} [subagent_ended] parent=${parentKey} child=${childKey}`);
881
+ }, { priority: -100 });
882
+
883
+ // 15. message_sending → Reply Span
884
+ api.on("message_sending", (event: any, hookCtx: any) => {
885
+ ensureSessionKeyMapping(hookCtx, log);
886
+ const key = resolveKey(hookCtx, event);
887
+ const st = states.get(key);
888
+ log.info(`${LOG_PREFIX} [message_sending] key=${key} hasState=${!!st}`);
889
+ if (!st) return;
890
+ st.replySpan = makeSpan(SPAN.REPLY, st.traceId, st.rootSpan.spanId);
891
+ }, { priority: -100 });
892
+
893
+ // 16. message_sent → End Reply Span + Root Span
894
+ api.on("message_sent", (event: any, hookCtx: any) => {
895
+ ensureSessionKeyMapping(hookCtx, log);
896
+ const key = resolveKey(hookCtx, event);
897
+ const st = states.get(key);
898
+ log.info(`${LOG_PREFIX} [message_sent] key=${key} hasState=${!!st}`);
899
+ if (!st) return;
900
+ if (st.replySpan) {
901
+ st.replySpan.setAttributes({ [A.MESSAGE_TO]: event?.to ?? "", [A.MESSAGE_SUCCESS]: event?.success ?? true });
902
+ if (event?.error) { st.replySpan.setStatus({ code: "ERROR", message: String(event.error) }); } else { st.replySpan.setStatus({ code: "OK" }); }
903
+ st.replySpan.end(); st.replySpan = undefined;
904
+ }
905
+ endAllChildren(st);
906
+ st.rootSpan.setAttribute("openclaw.message.count", st.messageCount);
907
+ st.rootSpan.setAttribute("openclaw.ended_by", "message_sent");
908
+ try {
909
+ if (st.agentOutputAssistantMessage) {
910
+ st.rootSpan.setAttribute("openclaw.assistant.reply", truncate(st.agentOutputAssistantMessage, MAX_ATTR_CONTENT_LENGTH));
911
+ }
912
+ } catch (_) { /* best-effort */ }
913
+ st.rootSpan.setStatus({ code: "OK" }); st.rootSpan.end();
914
+ log.info(`${LOG_PREFIX} [message_sent] Completed trace traceId=${st.rootSpan.traceId} key=${key}`);
915
+ cleanupState(key);
916
+ }, { priority: -100 });
917
+
918
+ // 17. session_end → End Session + Root
919
+ api.on("session_end", (event: any, hookCtx: any) => {
920
+ ensureSessionKeyMapping(hookCtx, log);
921
+ const key = resolveKey(hookCtx, event);
922
+ const st = states.get(key);
923
+ log.info(`${LOG_PREFIX} [session_end] key=${key} hasState=${!!st}`);
924
+ if (!st) return;
925
+ if (st.sessionSpan) {
926
+ if (event?.durationMs != null) st.sessionSpan.setAttribute(A.SESSION_DURATION_MS, event.durationMs);
927
+ if (event?.messageCount != null) st.sessionSpan.setAttribute(A.SESSION_MSG_COUNT, event.messageCount);
928
+ st.sessionSpan.setStatus({ code: "OK" }); st.sessionSpan.end(); st.sessionSpan = undefined;
929
+ }
930
+ endAllChildren(st);
931
+ st.rootSpan.setAttribute("openclaw.message.count", st.messageCount);
932
+ st.rootSpan.setAttribute("openclaw.ended_by", "session_end");
933
+ if (event?.durationMs != null) st.rootSpan.setAttribute(A.SESSION_DURATION_MS, event.durationMs);
934
+ try {
935
+ if (st.agentOutputAssistantMessage) {
936
+ st.rootSpan.setAttribute("openclaw.assistant.reply", truncate(st.agentOutputAssistantMessage, MAX_ATTR_CONTENT_LENGTH));
937
+ }
938
+ } catch (_) { /* best-effort */ }
939
+ st.rootSpan.setStatus({ code: "OK" }); st.rootSpan.end();
940
+ log.info(`${LOG_PREFIX} [session_end] Completed trace traceId=${st.rootSpan.traceId} key=${key}`);
941
+ cleanupState(key);
942
+ }, { priority: -100 });
943
+
944
+ // 18. agent_end → Fallback (delayed 2s)
945
+ api.on("agent_end", (event: any, hookCtx: any) => {
946
+ ensureSessionKeyMapping(hookCtx, log);
947
+ const key = resolveKey(hookCtx, event);
948
+ const st = states.get(key);
949
+ log.info(`${LOG_PREFIX} [agent_end] key=${key} hasState=${!!st}`);
950
+ if (!st) return;
951
+ setTimeout(() => {
952
+ const currentSt = states.get(key);
953
+ if (!currentSt) { log.info(`${LOG_PREFIX} [agent_end/delayed] key=${key} already cleaned up`); return; }
954
+ endAllChildren(currentSt);
955
+ currentSt.rootSpan.setAttribute("openclaw.message.count", currentSt.messageCount);
956
+ currentSt.rootSpan.setAttribute("openclaw.ended_by", "agent_end");
957
+ try {
958
+ if (currentSt.agentOutputAssistantMessage) {
959
+ currentSt.rootSpan.setAttribute("openclaw.assistant.reply", truncate(currentSt.agentOutputAssistantMessage, MAX_ATTR_CONTENT_LENGTH));
960
+ }
961
+ } catch (_) { /* best-effort */ }
962
+ currentSt.rootSpan.setStatus({ code: "OK" }); currentSt.rootSpan.end();
963
+ log.info(`${LOG_PREFIX} [agent_end/delayed] Completed trace traceId=${currentSt.rootSpan.traceId} key=${key}`);
964
+ cleanupState(key);
965
+ }, 2000);
966
+ }, { priority: -100 });
967
+
968
+ // 19. before_reset → Add event to root span
969
+ api.on("before_reset", (event: any, hookCtx: any) => {
970
+ ensureSessionKeyMapping(hookCtx, log);
971
+ const key = resolveKey(hookCtx, event);
972
+ const st = states.get(key);
973
+ log.info(`${LOG_PREFIX} [before_reset] key=${key} hasState=${!!st}`);
974
+ if (!st) return;
975
+ st.rootSpan.addEvent("session.reset", {
976
+ [A.SESSION_KEY]: key,
977
+ "openclaw.reset.reason": event?.reason ?? "user_request",
978
+ });
979
+ }, { priority: -100 });
980
+
981
+ log.info(`${LOG_PREFIX} Registered 19 hook handlers.`);
982
+ }
983
+
984
+ return { service, registerHooks };
985
+ }