openclaw-apm-tracing 0.5.0-beta.2
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 +118 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1404 -0
- package/dist/index.js.map +1 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1404 @@
|
|
|
1
|
+
import { trace, SpanStatusCode, SpanKind, ROOT_CONTEXT, } from "@opentelemetry/api";
|
|
2
|
+
// ─── Span Names ──────────────────────────────────────────────────────────
|
|
3
|
+
const SPAN = {
|
|
4
|
+
MESSAGE: "openclaw.message", // Root: incoming message
|
|
5
|
+
SESSION: "openclaw.session", // Session lifecycle
|
|
6
|
+
AGENT_PROCESSING: "openclaw.agent", // Agent turn (model_resolve → prompt_build → llm → tools → reply)
|
|
7
|
+
MODEL_RESOLVE: "openclaw.model.resolve", // Model selection
|
|
8
|
+
PROMPT_BUILD: "openclaw.prompt.build", // Prompt construction
|
|
9
|
+
LLM_CALL: "openclaw.llm.call", // LLM API call
|
|
10
|
+
TOOL: "openclaw.tool", // Tool execution
|
|
11
|
+
COMPACTION: "openclaw.compaction", // Context compaction
|
|
12
|
+
SUBAGENT: "openclaw.subagent", // Sub-agent delegation
|
|
13
|
+
REPLY: "openclaw.reply.send", // Reply delivery
|
|
14
|
+
};
|
|
15
|
+
// ─── Attribute Keys (OpenTelemetry GenAI Semantic Conventions) ───────────
|
|
16
|
+
//
|
|
17
|
+
// Standard GenAI attributes follow the `gen_ai.*` namespace.
|
|
18
|
+
// OpenClaw-specific attributes use `openclaw.*` for domain context.
|
|
19
|
+
// Prompt/completion are recorded as compact summaries (count + role breakdown +
|
|
20
|
+
// last user message / last assistant reply) to keep span size manageable.
|
|
21
|
+
const A = {
|
|
22
|
+
// ── GenAI standard attributes ─────────────────────────────────────────
|
|
23
|
+
GENAI_SYSTEM: "gen_ai.system",
|
|
24
|
+
GENAI_OPERATION_NAME: "gen_ai.operation.name",
|
|
25
|
+
GENAI_REQUEST_MODEL: "gen_ai.request.model",
|
|
26
|
+
GENAI_RESPONSE_MODEL: "gen_ai.response.model",
|
|
27
|
+
GENAI_REQUEST_TEMPERATURE: "gen_ai.request.temperature",
|
|
28
|
+
GENAI_REQUEST_MAX_TOKENS: "gen_ai.request.max_tokens",
|
|
29
|
+
GENAI_RESPONSE_FINISH_REASONS: "gen_ai.response.finish_reasons",
|
|
30
|
+
GENAI_USAGE_INPUT_TOKENS: "gen_ai.usage.input_tokens",
|
|
31
|
+
GENAI_USAGE_OUTPUT_TOKENS: "gen_ai.usage.output_tokens",
|
|
32
|
+
GENAI_USAGE_CACHE_READ_INPUT: "gen_ai.usage.cache_read.input_tokens",
|
|
33
|
+
GENAI_USAGE_CACHE_CREATION_INPUT: "gen_ai.usage.cache_creation.input_tokens",
|
|
34
|
+
// Legacy aliases for broader compatibility
|
|
35
|
+
GENAI_USAGE_PROMPT_TOKENS: "gen_ai.usage.prompt_tokens",
|
|
36
|
+
GENAI_USAGE_COMPLETION_TOKENS: "gen_ai.usage.completion_tokens",
|
|
37
|
+
GENAI_USAGE_TOTAL_TOKENS: "gen_ai.usage.total_tokens",
|
|
38
|
+
// ── GenAI content attributes ─────────────────────────────────────────
|
|
39
|
+
// NOTE: gen_ai.prompt.{N}.role / gen_ai.completion.{N}.role are set
|
|
40
|
+
// dynamically via setIndexedPromptAttrs / setIndexedCompletionAttrs.
|
|
41
|
+
GENAI_INPUT_MESSAGES: "gen_ai.input.messages", // kept as fallback
|
|
42
|
+
GENAI_OUTPUT_MESSAGES: "gen_ai.output.messages", // kept as fallback
|
|
43
|
+
GENAI_SYSTEM_INSTRUCTIONS: "gen_ai.system_instructions",
|
|
44
|
+
GENAI_MODEL_NAME: "gen_ai.model_name",
|
|
45
|
+
GENAI_PROVIDER_NAME: "gen_ai.provider.name",
|
|
46
|
+
GENAI_SPAN_KIND: "gen_ai.span.kind",
|
|
47
|
+
GENAI_CONVERSATION_ID: "gen_ai.conversation.id",
|
|
48
|
+
GENAI_SESSION_ID: "gen_ai.session.id",
|
|
49
|
+
// LLM request type (for APM LLM tab)
|
|
50
|
+
LLM_REQUEST_TYPE: "llm.request.type",
|
|
51
|
+
// ── Traceloop semantic attributes (APM LLM Tab compat) ────────────────
|
|
52
|
+
// These mirror the attributes set by Traceloop/OpenLLMetry Python SDK.
|
|
53
|
+
// APM uses them to populate the LLM Tab: "操作类型", "操作对象输入/输出", "工作流名称".
|
|
54
|
+
TRACELOOP_SPAN_KIND: "traceloop.span.kind",
|
|
55
|
+
TRACELOOP_ENTITY_NAME: "traceloop.entity.name",
|
|
56
|
+
TRACELOOP_ENTITY_INPUT: "traceloop.entity.input",
|
|
57
|
+
TRACELOOP_ENTITY_OUTPUT: "traceloop.entity.output",
|
|
58
|
+
TRACELOOP_WORKFLOW_NAME: "traceloop.workflow.name",
|
|
59
|
+
TRACELOOP_ENTITY_PATH: "traceloop.entity.path",
|
|
60
|
+
// ── OpenClaw domain attributes ────────────────────────────────────────
|
|
61
|
+
SESSION_KEY: "openclaw.session.key",
|
|
62
|
+
SESSION_ID: "openclaw.session.id",
|
|
63
|
+
AGENT_ID: "openclaw.agent.id",
|
|
64
|
+
CHANNEL: "openclaw.channel",
|
|
65
|
+
RUN_ID: "openclaw.run.id",
|
|
66
|
+
MESSAGE_FROM: "openclaw.message.from",
|
|
67
|
+
MESSAGE_TO: "openclaw.message.to",
|
|
68
|
+
MESSAGE_SUCCESS: "openclaw.message.success",
|
|
69
|
+
LLM_PROVIDER: "gen_ai.system", // reuse standard attr
|
|
70
|
+
LLM_MODEL: "gen_ai.request.model", // reuse standard attr
|
|
71
|
+
LLM_ROUND: "openclaw.llm.round",
|
|
72
|
+
TOOL_NAME: "openclaw.tool.name",
|
|
73
|
+
TOOL_CALL_ID: "openclaw.tool.call_id",
|
|
74
|
+
TOOL_DURATION_MS: "openclaw.tool.duration_ms",
|
|
75
|
+
SESSION_DURATION_MS: "openclaw.session.duration_ms",
|
|
76
|
+
SESSION_MSG_COUNT: "openclaw.session.message_count",
|
|
77
|
+
SUBAGENT_SESSION_KEY: "openclaw.subagent.session_key",
|
|
78
|
+
SUBAGENT_AGENT_ID: "openclaw.subagent.agent_id",
|
|
79
|
+
COMPACTION_MSG_BEFORE: "openclaw.compaction.messages.before",
|
|
80
|
+
COMPACTION_MSG_AFTER: "openclaw.compaction.messages.after",
|
|
81
|
+
COMPACTION_TOKENS_BEFORE: "openclaw.compaction.tokens.before",
|
|
82
|
+
COMPACTION_TOKENS_AFTER: "openclaw.compaction.tokens.after",
|
|
83
|
+
COMPACTION_COMPACTED: "openclaw.compaction.compacted_count",
|
|
84
|
+
MODEL_RESOLVE_PROVIDER: "openclaw.model_resolve.provider",
|
|
85
|
+
MODEL_RESOLVE_MODEL: "openclaw.model_resolve.model",
|
|
86
|
+
PROMPT_BUILD_HAS_SYSTEM: "openclaw.prompt_build.has_system_prompt",
|
|
87
|
+
};
|
|
88
|
+
// ─── Helper: truncate string to avoid huge span attributes ──────────────
|
|
89
|
+
const MAX_CONTENT_LENGTH = 4096;
|
|
90
|
+
function truncate(s, maxLen = MAX_CONTENT_LENGTH) {
|
|
91
|
+
if (!s)
|
|
92
|
+
return "";
|
|
93
|
+
if (s.length <= maxLen)
|
|
94
|
+
return s;
|
|
95
|
+
return s.slice(0, maxLen) + `... [truncated, total ${s.length} chars]`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Ensure a string is valid JSON. If it already parses successfully, return as-is.
|
|
99
|
+
* Otherwise wrap it with JSON.stringify so the APM frontend's JSON.parse() won't fail.
|
|
100
|
+
*
|
|
101
|
+
* APM LLM Tab does `JSON.parse(text)` on traceloop.entity.input/output values.
|
|
102
|
+
* Plain-text strings like "hello world" are not valid JSON and would throw.
|
|
103
|
+
*/
|
|
104
|
+
function ensureJson(s) {
|
|
105
|
+
try {
|
|
106
|
+
JSON.parse(s);
|
|
107
|
+
return s; // already valid JSON
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return JSON.stringify(s); // wrap as JSON string literal
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Truncate a JSON string while keeping it valid JSON.
|
|
115
|
+
*
|
|
116
|
+
* Strategy: if the serialized JSON exceeds maxLen, parse it back,
|
|
117
|
+
* progressively shorten the longest text fields, then re-serialize.
|
|
118
|
+
* As a last resort, return a small valid JSON fallback.
|
|
119
|
+
*
|
|
120
|
+
* This is critical for traceloop.entity.input/output because the APM
|
|
121
|
+
* frontend calls JSON.parse() — a naïvely sliced JSON string is invalid.
|
|
122
|
+
*/
|
|
123
|
+
function truncateJson(jsonStr, maxLen) {
|
|
124
|
+
if (jsonStr.length <= maxLen)
|
|
125
|
+
return jsonStr;
|
|
126
|
+
try {
|
|
127
|
+
const data = JSON.parse(jsonStr);
|
|
128
|
+
// For arrays of messages, trim content of each message
|
|
129
|
+
if (Array.isArray(data)) {
|
|
130
|
+
// First pass: aggressively truncate each message's content field
|
|
131
|
+
let budget = maxLen - 100; // leave room for structural chars
|
|
132
|
+
const perItem = Math.max(200, Math.floor(budget / Math.max(data.length, 1)));
|
|
133
|
+
const trimmed = data.map((item) => {
|
|
134
|
+
if (item && typeof item === "object" && typeof item.content === "string") {
|
|
135
|
+
return { ...item, content: truncate(item.content, perItem) };
|
|
136
|
+
}
|
|
137
|
+
return item;
|
|
138
|
+
});
|
|
139
|
+
let result = JSON.stringify(trimmed);
|
|
140
|
+
if (result.length <= maxLen)
|
|
141
|
+
return result;
|
|
142
|
+
// Second pass: keep only the last few messages (most relevant for LLM context)
|
|
143
|
+
const keep = Math.max(2, Math.min(trimmed.length, 5));
|
|
144
|
+
const sliced = trimmed.slice(-keep);
|
|
145
|
+
result = JSON.stringify([
|
|
146
|
+
{ role: "system", content: `[${trimmed.length - keep} earlier messages omitted]` },
|
|
147
|
+
...sliced,
|
|
148
|
+
]);
|
|
149
|
+
if (result.length <= maxLen)
|
|
150
|
+
return result;
|
|
151
|
+
// Third pass: very aggressive per-content truncation
|
|
152
|
+
const tinyBudget = Math.max(100, Math.floor((maxLen - 100) / Math.max(sliced.length + 1, 1)));
|
|
153
|
+
const tiny = sliced.map((item) => {
|
|
154
|
+
if (item && typeof item === "object" && typeof item.content === "string") {
|
|
155
|
+
return { ...item, content: truncate(item.content, tinyBudget) };
|
|
156
|
+
}
|
|
157
|
+
return item;
|
|
158
|
+
});
|
|
159
|
+
result = JSON.stringify([
|
|
160
|
+
{ role: "system", content: `[${trimmed.length - keep} earlier messages omitted]` },
|
|
161
|
+
...tiny,
|
|
162
|
+
]);
|
|
163
|
+
if (result.length <= maxLen)
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
// For non-array objects or if array trimming wasn't enough
|
|
167
|
+
if (typeof data === "object" && data !== null) {
|
|
168
|
+
const brief = JSON.stringify(data, (_key, val) => typeof val === "string" && val.length > 200
|
|
169
|
+
? val.slice(0, 200) + "...[truncated]"
|
|
170
|
+
: val);
|
|
171
|
+
if (brief.length <= maxLen)
|
|
172
|
+
return brief;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// If parse fails somehow, fall through to fallback
|
|
177
|
+
}
|
|
178
|
+
// Absolute fallback: return a small valid JSON indicating truncation
|
|
179
|
+
return JSON.stringify({ _truncated: true, _originalLength: jsonStr.length });
|
|
180
|
+
}
|
|
181
|
+
function extractText(content) {
|
|
182
|
+
if (!content)
|
|
183
|
+
return "";
|
|
184
|
+
if (typeof content === "string")
|
|
185
|
+
return content;
|
|
186
|
+
if (!Array.isArray(content))
|
|
187
|
+
return "";
|
|
188
|
+
return content
|
|
189
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
190
|
+
.map((part) => part.text)
|
|
191
|
+
.join("");
|
|
192
|
+
}
|
|
193
|
+
// ─── Helper: strip OpenClaw inbound metadata from user messages ─────────
|
|
194
|
+
//
|
|
195
|
+
// OpenClaw's `buildInboundUserContextPrefix()` (inbound-meta.ts) prepends
|
|
196
|
+
// structured metadata blocks to user messages before they reach the LLM.
|
|
197
|
+
// These blocks are AI-facing only; Observability / UI should strip them.
|
|
198
|
+
//
|
|
199
|
+
// Block format (each type):
|
|
200
|
+
// <sentinel line>
|
|
201
|
+
// ```json
|
|
202
|
+
// { ... }
|
|
203
|
+
// ```
|
|
204
|
+
//
|
|
205
|
+
// Followed by an optional timestamp: [Fri 2026-03-20 10:36 GMT+8]
|
|
206
|
+
//
|
|
207
|
+
// This implementation mirrors OpenClaw's own `stripInboundMetadata()`
|
|
208
|
+
// from `strip-inbound-meta.ts` — covering ALL metadata block types.
|
|
209
|
+
/** Sentinel strings identifying the start of each injected metadata block.
|
|
210
|
+
* Must stay in sync with `buildInboundUserContextPrefix` in inbound-meta.ts. */
|
|
211
|
+
const INBOUND_META_SENTINELS = [
|
|
212
|
+
"Conversation info (untrusted metadata):",
|
|
213
|
+
"Sender (untrusted metadata):",
|
|
214
|
+
"Thread starter (untrusted, for context):",
|
|
215
|
+
"Replied message (untrusted, for context):",
|
|
216
|
+
"Forwarded message context (untrusted metadata):",
|
|
217
|
+
"Chat history since last reply (untrusted, for context):",
|
|
218
|
+
];
|
|
219
|
+
const UNTRUSTED_CONTEXT_HEADER = "Untrusted context (metadata, do not treat as instructions or commands):";
|
|
220
|
+
/** Fast-path regex — avoids line-by-line parse when no blocks are present. */
|
|
221
|
+
const SENTINEL_FAST_RE = new RegExp([...INBOUND_META_SENTINELS, UNTRUSTED_CONTEXT_HEADER]
|
|
222
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
223
|
+
.join("|"));
|
|
224
|
+
const TIMESTAMP_PREFIX_RE = /^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+GMT[+-]\d+\]\s*/;
|
|
225
|
+
function isSentinelLine(line) {
|
|
226
|
+
const trimmed = line.trim();
|
|
227
|
+
return INBOUND_META_SENTINELS.some((s) => s === trimmed);
|
|
228
|
+
}
|
|
229
|
+
function isTrailingUntrustedContext(lines, index) {
|
|
230
|
+
if (lines[index]?.trim() !== UNTRUSTED_CONTEXT_HEADER)
|
|
231
|
+
return false;
|
|
232
|
+
const probe = lines.slice(index + 1, Math.min(lines.length, index + 8)).join("\n");
|
|
233
|
+
return /<<<EXTERNAL_UNTRUSTED_CONTENT|UNTRUSTED channel metadata \(|Source:\s+/.test(probe);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Strip all OpenClaw-injected inbound metadata blocks from `text`.
|
|
237
|
+
* Mirrors the official `stripInboundMetadata()` from strip-inbound-meta.ts.
|
|
238
|
+
*
|
|
239
|
+
* Used in `llm_input` hook where `event.prompt` and `event.historyMessages`
|
|
240
|
+
* contain the prefixed content. NOT needed for `message_received` where
|
|
241
|
+
* `event.content` is already the raw user message.
|
|
242
|
+
*/
|
|
243
|
+
function stripInboundMetadata(text) {
|
|
244
|
+
if (!text)
|
|
245
|
+
return text;
|
|
246
|
+
// Fast path: no sentinel markers → return as-is (zero allocation)
|
|
247
|
+
if (!SENTINEL_FAST_RE.test(text)) {
|
|
248
|
+
// Still strip the standalone timestamp prefix
|
|
249
|
+
return text.replace(TIMESTAMP_PREFIX_RE, "").trim() || text;
|
|
250
|
+
}
|
|
251
|
+
const lines = text.split("\n");
|
|
252
|
+
const result = [];
|
|
253
|
+
let inMetaBlock = false;
|
|
254
|
+
let inFencedJson = false;
|
|
255
|
+
for (let i = 0; i < lines.length; i++) {
|
|
256
|
+
const line = lines[i];
|
|
257
|
+
// Trailing untrusted context block → drop it and everything after
|
|
258
|
+
if (!inMetaBlock && isTrailingUntrustedContext(lines, i)) {
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
// Detect start of a metadata block
|
|
262
|
+
if (!inMetaBlock && isSentinelLine(line)) {
|
|
263
|
+
const next = lines[i + 1];
|
|
264
|
+
if (next?.trim() !== "```json") {
|
|
265
|
+
// Not a proper block format — keep the line
|
|
266
|
+
result.push(line);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
inMetaBlock = true;
|
|
270
|
+
inFencedJson = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (inMetaBlock) {
|
|
274
|
+
if (!inFencedJson && line.trim() === "```json") {
|
|
275
|
+
inFencedJson = true;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (inFencedJson) {
|
|
279
|
+
if (line.trim() === "```") {
|
|
280
|
+
inMetaBlock = false;
|
|
281
|
+
inFencedJson = false;
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Blank separator lines between consecutive blocks are dropped
|
|
286
|
+
if (line.trim() === "") {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// Unexpected non-blank line outside a fence — treat as user content
|
|
290
|
+
inMetaBlock = false;
|
|
291
|
+
}
|
|
292
|
+
result.push(line);
|
|
293
|
+
}
|
|
294
|
+
let cleaned = result.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
|
295
|
+
// Strip standalone timestamp prefix (e.g. [Fri 2026-03-20 10:36 GMT+8])
|
|
296
|
+
cleaned = cleaned.replace(TIMESTAMP_PREFIX_RE, "").trim();
|
|
297
|
+
return cleaned;
|
|
298
|
+
}
|
|
299
|
+
function extractToolCallsFromContent(content) {
|
|
300
|
+
if (!Array.isArray(content))
|
|
301
|
+
return [];
|
|
302
|
+
const calls = [];
|
|
303
|
+
for (const block of content) {
|
|
304
|
+
if (!block || typeof block !== "object")
|
|
305
|
+
continue;
|
|
306
|
+
if (block.type === "toolCall" || block.type === "tool_use") {
|
|
307
|
+
calls.push({
|
|
308
|
+
id: block.id ?? `call_${Date.now()}`,
|
|
309
|
+
name: block.name ?? "unknown",
|
|
310
|
+
arguments: typeof block.arguments === "string"
|
|
311
|
+
? block.arguments
|
|
312
|
+
: JSON.stringify(block.arguments ?? {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return calls;
|
|
317
|
+
}
|
|
318
|
+
// ─── APM-compatible indexed attribute setters ────────────────────────────
|
|
319
|
+
//
|
|
320
|
+
// APM platforms parse LLM input/output using
|
|
321
|
+
// indexed attributes like:
|
|
322
|
+
// gen_ai.prompt.0.role = "system"
|
|
323
|
+
// gen_ai.prompt.0.content = "You are..."
|
|
324
|
+
// gen_ai.prompt.1.role = "user"
|
|
325
|
+
// gen_ai.prompt.1.content = "Hello"
|
|
326
|
+
// gen_ai.completion.0.role = "assistant"
|
|
327
|
+
// gen_ai.completion.0.content = "Hi there!"
|
|
328
|
+
// gen_ai.completion.0.finish_reason = "stop"
|
|
329
|
+
// gen_ai.completion.0.tool_calls.0.name = "calculator"
|
|
330
|
+
// gen_ai.completion.0.tool_calls.0.arguments = "{...}"
|
|
331
|
+
// gen_ai.completion.0.tool_calls.0.id = "call_xxx"
|
|
332
|
+
// llm.request.type = "chat"
|
|
333
|
+
// llm.request.functions.0.name = "calculator"
|
|
334
|
+
// llm.request.functions.0.description = "..."
|
|
335
|
+
// llm.request.functions.0.parameters = "{...}"
|
|
336
|
+
//
|
|
337
|
+
// We cap the number of indexed prompt messages to avoid oversized spans.
|
|
338
|
+
const MAX_INDEXED_PROMPT_MESSAGES = 20; // max messages to expand as indexed attrs
|
|
339
|
+
const MAX_ATTR_CONTENT_LENGTH = 4096; // cap individual content attribute length
|
|
340
|
+
function setIndexedPromptAttrs(span, messages) {
|
|
341
|
+
// If too many messages, keep first (system) + last few
|
|
342
|
+
let selected = messages;
|
|
343
|
+
if (messages.length > MAX_INDEXED_PROMPT_MESSAGES) {
|
|
344
|
+
const head = messages.slice(0, 1); // system prompt
|
|
345
|
+
const tail = messages.slice(-(MAX_INDEXED_PROMPT_MESSAGES - 1));
|
|
346
|
+
selected = [...head, ...tail];
|
|
347
|
+
}
|
|
348
|
+
for (let i = 0; i < selected.length; i++) {
|
|
349
|
+
const msg = selected[i];
|
|
350
|
+
const role = msg.role ?? "unknown";
|
|
351
|
+
span.setAttribute(`gen_ai.prompt.${i}.role`, role);
|
|
352
|
+
const text = extractText(msg.content ?? msg.text);
|
|
353
|
+
if (text) {
|
|
354
|
+
span.setAttribute(`gen_ai.prompt.${i}.content`, truncate(text, MAX_ATTR_CONTENT_LENGTH));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function setIndexedCompletionAttrs(span, messages) {
|
|
359
|
+
for (let i = 0; i < messages.length; i++) {
|
|
360
|
+
const msg = messages[i];
|
|
361
|
+
const role = msg.role ?? "assistant";
|
|
362
|
+
span.setAttribute(`gen_ai.completion.${i}.role`, role);
|
|
363
|
+
const text = extractText(msg.content ?? msg.text);
|
|
364
|
+
if (text) {
|
|
365
|
+
span.setAttribute(`gen_ai.completion.${i}.content`, truncate(text, MAX_ATTR_CONTENT_LENGTH));
|
|
366
|
+
}
|
|
367
|
+
const finishReason = msg.finishReason ?? msg.stopReason ?? "stop";
|
|
368
|
+
span.setAttribute(`gen_ai.completion.${i}.finish_reason`, finishReason);
|
|
369
|
+
// Tool calls in completion
|
|
370
|
+
const toolCalls = msg.toolCalls ?? extractToolCallsFromContent(msg.content);
|
|
371
|
+
if (toolCalls.length > 0) {
|
|
372
|
+
// Override finish_reason to tool_calls when there are tool calls
|
|
373
|
+
span.setAttribute(`gen_ai.completion.${i}.finish_reason`, "tool_calls");
|
|
374
|
+
for (let j = 0; j < toolCalls.length; j++) {
|
|
375
|
+
const tc = toolCalls[j];
|
|
376
|
+
span.setAttribute(`gen_ai.completion.${i}.tool_calls.${j}.id`, tc.id);
|
|
377
|
+
span.setAttribute(`gen_ai.completion.${i}.tool_calls.${j}.name`, tc.name);
|
|
378
|
+
span.setAttribute(`gen_ai.completion.${i}.tool_calls.${j}.arguments`, truncate(tc.arguments, MAX_ATTR_CONTENT_LENGTH));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function setIndexedFunctionAttrs(span, tools) {
|
|
384
|
+
for (let i = 0; i < tools.length; i++) {
|
|
385
|
+
const tool = tools[i];
|
|
386
|
+
span.setAttribute(`llm.request.functions.${i}.name`, tool.name);
|
|
387
|
+
if (tool.description) {
|
|
388
|
+
span.setAttribute(`llm.request.functions.${i}.description`, truncate(tool.description, 1024));
|
|
389
|
+
}
|
|
390
|
+
if (tool.parameters) {
|
|
391
|
+
const params = typeof tool.parameters === "string"
|
|
392
|
+
? tool.parameters
|
|
393
|
+
: JSON.stringify(tool.parameters);
|
|
394
|
+
span.setAttribute(`llm.request.functions.${i}.parameters`, truncate(params, 2048));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Compact summary for APM list view (few attributes, quick glance)
|
|
399
|
+
function setPromptSummary(span, totalMessages, roleCounts) {
|
|
400
|
+
span.setAttribute("gen_ai.prompt.num_messages", totalMessages);
|
|
401
|
+
const breakdown = [...roleCounts.entries()].map(([r, c]) => `${r}:${c}`).join(", ");
|
|
402
|
+
span.setAttribute("gen_ai.prompt.role_counts", breakdown);
|
|
403
|
+
}
|
|
404
|
+
// ─── The plugin ──────────────────────────────────────────────────────────
|
|
405
|
+
const MAX_SPAN_AGE_MS = 300_000; // 5 min orphan sweep
|
|
406
|
+
const LOG_PREFIX = "[openclaw-apm-tracing]";
|
|
407
|
+
const plugin = {
|
|
408
|
+
id: "openclaw-apm-tracing",
|
|
409
|
+
name: "OpenClaw APM Tracing",
|
|
410
|
+
description: "OpenClaw APM tracing plugin — full-chain distributed tracing " +
|
|
411
|
+
"with parent-child span tree. Reuses diagnostics-otel OTel SDK.",
|
|
412
|
+
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
413
|
+
register(api) {
|
|
414
|
+
api.registerService({
|
|
415
|
+
id: "openclaw-apm-tracing",
|
|
416
|
+
async start(ctx) {
|
|
417
|
+
const tracer = trace.getTracer("openclaw-apm-tracing", "0.5.0-beta.2");
|
|
418
|
+
const log = ctx.logger;
|
|
419
|
+
log.info(`${LOG_PREFIX} Attaching to global TracerProvider`);
|
|
420
|
+
const states = new Map();
|
|
421
|
+
const sessionKeyMap = new Map();
|
|
422
|
+
// ── Orphan sweep ──────────────────────────────────────────
|
|
423
|
+
const sweepTimer = setInterval(() => {
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
for (const [key, st] of states) {
|
|
426
|
+
if (now - st.createdAt > MAX_SPAN_AGE_MS) {
|
|
427
|
+
log.info(`${LOG_PREFIX} Sweeping orphan trace for key=${key}`);
|
|
428
|
+
endAllChildren(st);
|
|
429
|
+
st.rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
430
|
+
st.rootSpan.setAttribute("openclaw.sweep", true);
|
|
431
|
+
st.rootSpan.end();
|
|
432
|
+
states.delete(key);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}, 60_000);
|
|
436
|
+
// ── Helper: resolve session key ───────────────────────────
|
|
437
|
+
function resolveKey(hookCtx, event) {
|
|
438
|
+
if (hookCtx?.sessionKey) {
|
|
439
|
+
const mapped = sessionKeyMap.get(hookCtx.sessionKey);
|
|
440
|
+
if (mapped && states.has(mapped))
|
|
441
|
+
return mapped;
|
|
442
|
+
if (states.has(hookCtx.sessionKey))
|
|
443
|
+
return hookCtx.sessionKey;
|
|
444
|
+
if (mapped)
|
|
445
|
+
return mapped;
|
|
446
|
+
}
|
|
447
|
+
const channelId = hookCtx?.channelId ?? "unknown";
|
|
448
|
+
const convId = hookCtx?.conversationId ?? event?.metadata?.threadId ?? "default";
|
|
449
|
+
return `${channelId}:${convId}`;
|
|
450
|
+
}
|
|
451
|
+
// Helper: auto-register sessionKey → compositeKey when we first see it
|
|
452
|
+
function ensureSessionKeyMapping(hookCtx) {
|
|
453
|
+
if (!hookCtx?.sessionKey)
|
|
454
|
+
return;
|
|
455
|
+
if (sessionKeyMap.has(hookCtx.sessionKey))
|
|
456
|
+
return;
|
|
457
|
+
// Find the only active composite key and map to it
|
|
458
|
+
// (message_received creates composite key; subsequent hooks have sessionKey)
|
|
459
|
+
for (const [ck] of states) {
|
|
460
|
+
if (!ck.includes(":"))
|
|
461
|
+
continue; // skip non-composite keys
|
|
462
|
+
sessionKeyMap.set(hookCtx.sessionKey, ck);
|
|
463
|
+
log.info(`${LOG_PREFIX} Auto-mapped sessionKey=${hookCtx.sessionKey} → compositeKey=${ck}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Helper: get parent context for "within agent turn" spans
|
|
468
|
+
function agentOrRoot(st) {
|
|
469
|
+
return st.agentCtx ?? st.rootCtx;
|
|
470
|
+
}
|
|
471
|
+
// ════════════════════════════════════════════════════════════
|
|
472
|
+
// 1. message_received → Root Span (SERVER)
|
|
473
|
+
// ════════════════════════════════════════════════════════════
|
|
474
|
+
api.on("message_received", (event, hookCtx) => {
|
|
475
|
+
const channelId = hookCtx?.channelId ?? "unknown";
|
|
476
|
+
const convId = hookCtx?.conversationId ?? "default";
|
|
477
|
+
const compositeKey = `${channelId}:${convId}`;
|
|
478
|
+
log.info(`${LOG_PREFIX} [message_received] key=${compositeKey} ` +
|
|
479
|
+
`from=${event?.from ?? "?"} channelId=${channelId} convId=${convId}`);
|
|
480
|
+
// Gracefully end previous trace for same key
|
|
481
|
+
const prev = states.get(compositeKey);
|
|
482
|
+
if (prev) {
|
|
483
|
+
log.info(`${LOG_PREFIX} Ending previous trace for key=${compositeKey} (new message)`);
|
|
484
|
+
endAllChildren(prev);
|
|
485
|
+
prev.rootSpan.setAttribute("openclaw.message.count", prev.messageCount);
|
|
486
|
+
prev.rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
487
|
+
prev.rootSpan.end();
|
|
488
|
+
states.delete(compositeKey);
|
|
489
|
+
}
|
|
490
|
+
const rootCtx = ROOT_CONTEXT;
|
|
491
|
+
const rootSpan = tracer.startSpan(SPAN.MESSAGE, {
|
|
492
|
+
kind: SpanKind.SERVER,
|
|
493
|
+
attributes: {
|
|
494
|
+
[A.CHANNEL]: channelId,
|
|
495
|
+
[A.MESSAGE_FROM]: event?.from ?? "unknown",
|
|
496
|
+
[A.SESSION_KEY]: compositeKey,
|
|
497
|
+
[A.GENAI_SPAN_KIND]: "ENTRY",
|
|
498
|
+
[A.GENAI_OPERATION_NAME]: "enter",
|
|
499
|
+
// Traceloop compat
|
|
500
|
+
[A.TRACELOOP_SPAN_KIND]: "workflow",
|
|
501
|
+
[A.TRACELOOP_ENTITY_NAME]: "openclaw.message",
|
|
502
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
503
|
+
},
|
|
504
|
+
}, rootCtx);
|
|
505
|
+
const spanCtx = trace.setSpan(rootCtx, rootSpan);
|
|
506
|
+
// Record user message on root span using domain-specific attributes.
|
|
507
|
+
// NOTE: Do NOT set traceloop.entity.input/output on the root span —
|
|
508
|
+
// those attributes cause the APM frontend to render an "LLM" tab,
|
|
509
|
+
// but openclaw.message is a workflow span, not an LLM call.
|
|
510
|
+
// The LLM tab expects JSON-parseable values; plain-text strings
|
|
511
|
+
// would cause JSON.parse errors in the frontend.
|
|
512
|
+
try {
|
|
513
|
+
const userMsg = event?.content;
|
|
514
|
+
if (userMsg) {
|
|
515
|
+
const rawStr = typeof userMsg === "string"
|
|
516
|
+
? userMsg
|
|
517
|
+
: extractText(userMsg);
|
|
518
|
+
rootSpan.setAttribute("openclaw.user.message", truncate(rawStr, MAX_ATTR_CONTENT_LENGTH));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (_) { /* best-effort */ }
|
|
522
|
+
states.set(compositeKey, {
|
|
523
|
+
rootSpan,
|
|
524
|
+
rootCtx: spanCtx,
|
|
525
|
+
llmRound: 0,
|
|
526
|
+
toolSpans: new Map(),
|
|
527
|
+
subagentSpans: new Map(),
|
|
528
|
+
createdAt: Date.now(),
|
|
529
|
+
messageCount: 1,
|
|
530
|
+
});
|
|
531
|
+
log.info(`${LOG_PREFIX} Started root span: traceId=${rootSpan.spanContext().traceId} ` +
|
|
532
|
+
`spanId=${rootSpan.spanContext().spanId} key=${compositeKey}`);
|
|
533
|
+
}, { priority: -100 });
|
|
534
|
+
// ════════════════════════════════════════════════════════════
|
|
535
|
+
// 2. session_start → Session Span (child of root)
|
|
536
|
+
// ════════════════════════════════════════════════════════════
|
|
537
|
+
api.on("session_start", (event, hookCtx) => {
|
|
538
|
+
ensureSessionKeyMapping(hookCtx);
|
|
539
|
+
const key = resolveKey(hookCtx, event);
|
|
540
|
+
const st = states.get(key);
|
|
541
|
+
log.info(`${LOG_PREFIX} [session_start] key=${key} sessionKey=${hookCtx?.sessionKey} ` +
|
|
542
|
+
`sessionId=${event?.sessionId ?? "?"} hasState=${!!st}`);
|
|
543
|
+
if (!st)
|
|
544
|
+
return;
|
|
545
|
+
if (hookCtx?.sessionKey && hookCtx.sessionKey !== key) {
|
|
546
|
+
sessionKeyMap.set(hookCtx.sessionKey, key);
|
|
547
|
+
}
|
|
548
|
+
st.sessionSpan = tracer.startSpan(SPAN.SESSION, {
|
|
549
|
+
attributes: {
|
|
550
|
+
[A.SESSION_KEY]: key,
|
|
551
|
+
[A.SESSION_ID]: event?.sessionId ?? "",
|
|
552
|
+
[A.AGENT_ID]: hookCtx?.agentId ?? "",
|
|
553
|
+
},
|
|
554
|
+
}, st.rootCtx);
|
|
555
|
+
st.sessionCtx = trace.setSpan(st.rootCtx, st.sessionSpan);
|
|
556
|
+
}, { priority: -100 });
|
|
557
|
+
// ════════════════════════════════════════════════════════════
|
|
558
|
+
// 3. before_model_resolve → Model Resolve Span + Agent Span
|
|
559
|
+
// This is the first hook with sessionKey in the agent turn.
|
|
560
|
+
// We use it to create the Agent processing span and Model Resolve span.
|
|
561
|
+
// ════════════════════════════════════════════════════════════
|
|
562
|
+
api.on("before_model_resolve", (event, hookCtx) => {
|
|
563
|
+
ensureSessionKeyMapping(hookCtx);
|
|
564
|
+
const key = resolveKey(hookCtx, event);
|
|
565
|
+
const st = states.get(key);
|
|
566
|
+
log.info(`${LOG_PREFIX} [before_model_resolve] key=${key} sessionKey=${hookCtx?.sessionKey} ` +
|
|
567
|
+
`hasState=${!!st}`);
|
|
568
|
+
if (!st)
|
|
569
|
+
return;
|
|
570
|
+
// Create Agent processing span (covers the entire agent turn)
|
|
571
|
+
if (!st.agentSpan) {
|
|
572
|
+
st.agentSpan = tracer.startSpan(SPAN.AGENT_PROCESSING, {
|
|
573
|
+
attributes: {
|
|
574
|
+
[A.SESSION_KEY]: key,
|
|
575
|
+
[A.AGENT_ID]: hookCtx?.agentId ?? "",
|
|
576
|
+
[A.GENAI_SPAN_KIND]: "AGENT",
|
|
577
|
+
[A.GENAI_OPERATION_NAME]: "invoke_agent",
|
|
578
|
+
// Traceloop compat
|
|
579
|
+
[A.TRACELOOP_SPAN_KIND]: "agent",
|
|
580
|
+
[A.TRACELOOP_ENTITY_NAME]: hookCtx?.agentId ?? "openclaw.agent",
|
|
581
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
582
|
+
},
|
|
583
|
+
}, st.rootCtx);
|
|
584
|
+
st.agentCtx = trace.setSpan(st.rootCtx, st.agentSpan);
|
|
585
|
+
log.info(`${LOG_PREFIX} Started agent processing span for key=${key}`);
|
|
586
|
+
}
|
|
587
|
+
// Model Resolve span (child of agent)
|
|
588
|
+
st.modelResolveSpan = tracer.startSpan(SPAN.MODEL_RESOLVE, {
|
|
589
|
+
attributes: {
|
|
590
|
+
[A.SESSION_KEY]: key,
|
|
591
|
+
},
|
|
592
|
+
}, agentOrRoot(st));
|
|
593
|
+
}, { priority: -100 });
|
|
594
|
+
// ════════════════════════════════════════════════════════════
|
|
595
|
+
// 4. before_prompt_build → Prompt Build Span
|
|
596
|
+
// End model_resolve, start prompt_build
|
|
597
|
+
// ════════════════════════════════════════════════════════════
|
|
598
|
+
api.on("before_prompt_build", (event, hookCtx) => {
|
|
599
|
+
ensureSessionKeyMapping(hookCtx);
|
|
600
|
+
const key = resolveKey(hookCtx, event);
|
|
601
|
+
let st = states.get(key);
|
|
602
|
+
log.info(`${LOG_PREFIX} [before_prompt_build] key=${key} hasState=${!!st}`);
|
|
603
|
+
// Fallback: create root state if not yet created
|
|
604
|
+
if (!st) {
|
|
605
|
+
log.info(`${LOG_PREFIX} [before_prompt_build] Creating fallback root state for key=${key}`);
|
|
606
|
+
const rootCtx = ROOT_CONTEXT;
|
|
607
|
+
const rootSpan = tracer.startSpan(SPAN.MESSAGE, {
|
|
608
|
+
kind: SpanKind.SERVER,
|
|
609
|
+
attributes: {
|
|
610
|
+
[A.CHANNEL]: hookCtx?.channelId ?? "unknown",
|
|
611
|
+
[A.MESSAGE_FROM]: "api",
|
|
612
|
+
[A.SESSION_KEY]: key,
|
|
613
|
+
[A.GENAI_SPAN_KIND]: "ENTRY",
|
|
614
|
+
[A.GENAI_OPERATION_NAME]: "enter",
|
|
615
|
+
"openclaw.fallback_init": true,
|
|
616
|
+
// Traceloop compat
|
|
617
|
+
[A.TRACELOOP_SPAN_KIND]: "workflow",
|
|
618
|
+
[A.TRACELOOP_ENTITY_NAME]: "openclaw.message",
|
|
619
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
620
|
+
},
|
|
621
|
+
}, rootCtx);
|
|
622
|
+
const spanCtx = trace.setSpan(rootCtx, rootSpan);
|
|
623
|
+
st = {
|
|
624
|
+
rootSpan,
|
|
625
|
+
rootCtx: spanCtx,
|
|
626
|
+
llmRound: 0,
|
|
627
|
+
toolSpans: new Map(),
|
|
628
|
+
subagentSpans: new Map(),
|
|
629
|
+
createdAt: Date.now(),
|
|
630
|
+
messageCount: 1,
|
|
631
|
+
};
|
|
632
|
+
states.set(key, st);
|
|
633
|
+
if (hookCtx?.sessionKey && hookCtx.sessionKey !== key) {
|
|
634
|
+
sessionKeyMap.set(hookCtx.sessionKey, key);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// End model resolve span
|
|
638
|
+
if (st.modelResolveSpan) {
|
|
639
|
+
st.modelResolveSpan.setStatus({ code: SpanStatusCode.OK });
|
|
640
|
+
st.modelResolveSpan.end();
|
|
641
|
+
st.modelResolveSpan = undefined;
|
|
642
|
+
log.info(`${LOG_PREFIX} Ended model resolve span for key=${key}`);
|
|
643
|
+
}
|
|
644
|
+
// Start prompt build span
|
|
645
|
+
st.promptBuildSpan = tracer.startSpan(SPAN.PROMPT_BUILD, {
|
|
646
|
+
attributes: {
|
|
647
|
+
[A.SESSION_KEY]: key,
|
|
648
|
+
},
|
|
649
|
+
}, agentOrRoot(st));
|
|
650
|
+
}, { priority: -100 });
|
|
651
|
+
// ════════════════════════════════════════════════════════════
|
|
652
|
+
// 5. before_agent_start (legacy) → Fallback agent span
|
|
653
|
+
// For older codepaths that don't use before_model_resolve
|
|
654
|
+
// Also acts as fallback state creator when message_received was skipped
|
|
655
|
+
// (e.g. /v1/chat/completions API path)
|
|
656
|
+
// ════════════════════════════════════════════════════════════
|
|
657
|
+
api.on("before_agent_start", (event, hookCtx) => {
|
|
658
|
+
ensureSessionKeyMapping(hookCtx);
|
|
659
|
+
const key = resolveKey(hookCtx, event);
|
|
660
|
+
let st = states.get(key);
|
|
661
|
+
log.info(`${LOG_PREFIX} [before_agent_start] key=${key} sessionKey=${hookCtx?.sessionKey} ` +
|
|
662
|
+
`hasState=${!!st}`);
|
|
663
|
+
// Fallback: create root state if message_received was skipped
|
|
664
|
+
if (!st) {
|
|
665
|
+
log.info(`${LOG_PREFIX} [before_agent_start] Creating fallback root state for key=${key}`);
|
|
666
|
+
const rootCtx = ROOT_CONTEXT;
|
|
667
|
+
const rootSpan = tracer.startSpan(SPAN.MESSAGE, {
|
|
668
|
+
kind: SpanKind.SERVER,
|
|
669
|
+
attributes: {
|
|
670
|
+
[A.CHANNEL]: hookCtx?.channelId ?? "unknown",
|
|
671
|
+
[A.MESSAGE_FROM]: "api",
|
|
672
|
+
[A.SESSION_KEY]: key,
|
|
673
|
+
[A.GENAI_SPAN_KIND]: "ENTRY",
|
|
674
|
+
[A.GENAI_OPERATION_NAME]: "enter",
|
|
675
|
+
"openclaw.fallback_init": true,
|
|
676
|
+
// Traceloop compat
|
|
677
|
+
[A.TRACELOOP_SPAN_KIND]: "workflow",
|
|
678
|
+
[A.TRACELOOP_ENTITY_NAME]: "openclaw.message",
|
|
679
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
680
|
+
},
|
|
681
|
+
}, rootCtx);
|
|
682
|
+
const spanCtx = trace.setSpan(rootCtx, rootSpan);
|
|
683
|
+
st = {
|
|
684
|
+
rootSpan,
|
|
685
|
+
rootCtx: spanCtx,
|
|
686
|
+
llmRound: 0,
|
|
687
|
+
toolSpans: new Map(),
|
|
688
|
+
subagentSpans: new Map(),
|
|
689
|
+
createdAt: Date.now(),
|
|
690
|
+
messageCount: 1,
|
|
691
|
+
};
|
|
692
|
+
states.set(key, st);
|
|
693
|
+
// Map sessionKey
|
|
694
|
+
if (hookCtx?.sessionKey && hookCtx.sessionKey !== key) {
|
|
695
|
+
sessionKeyMap.set(hookCtx.sessionKey, key);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Create Agent processing span if not already created
|
|
699
|
+
if (!st.agentSpan) {
|
|
700
|
+
st.agentSpan = tracer.startSpan(SPAN.AGENT_PROCESSING, {
|
|
701
|
+
attributes: {
|
|
702
|
+
[A.SESSION_KEY]: key,
|
|
703
|
+
[A.AGENT_ID]: hookCtx?.agentId ?? "",
|
|
704
|
+
[A.GENAI_SPAN_KIND]: "AGENT",
|
|
705
|
+
[A.GENAI_OPERATION_NAME]: "invoke_agent",
|
|
706
|
+
// Traceloop compat
|
|
707
|
+
[A.TRACELOOP_SPAN_KIND]: "agent",
|
|
708
|
+
[A.TRACELOOP_ENTITY_NAME]: hookCtx?.agentId ?? "openclaw.agent",
|
|
709
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
710
|
+
},
|
|
711
|
+
}, st.rootCtx);
|
|
712
|
+
st.agentCtx = trace.setSpan(st.rootCtx, st.agentSpan);
|
|
713
|
+
log.info(`${LOG_PREFIX} Started agent processing span (legacy) for key=${key}`);
|
|
714
|
+
}
|
|
715
|
+
}, { priority: -100 });
|
|
716
|
+
// ════════════════════════════════════════════════════════════
|
|
717
|
+
// 6. llm_input → LLM Call Span (CLIENT, child of agent)
|
|
718
|
+
// ════════════════════════════════════════════════════════════
|
|
719
|
+
api.on("llm_input", (event, hookCtx) => {
|
|
720
|
+
ensureSessionKeyMapping(hookCtx);
|
|
721
|
+
const key = resolveKey(hookCtx, event);
|
|
722
|
+
const st = states.get(key);
|
|
723
|
+
log.info(`${LOG_PREFIX} [llm_input] key=${key} sessionKey=${hookCtx?.sessionKey} ` +
|
|
724
|
+
`provider=${event?.provider ?? "?"} model=${event?.model ?? "?"} hasState=${!!st}`);
|
|
725
|
+
if (!st)
|
|
726
|
+
return;
|
|
727
|
+
// End prompt build span if still open
|
|
728
|
+
if (st.promptBuildSpan) {
|
|
729
|
+
st.promptBuildSpan.setStatus({ code: SpanStatusCode.OK });
|
|
730
|
+
st.promptBuildSpan.end();
|
|
731
|
+
st.promptBuildSpan = undefined;
|
|
732
|
+
log.info(`${LOG_PREFIX} Ended prompt build span for key=${key}`);
|
|
733
|
+
}
|
|
734
|
+
// End model resolve span if still open (no prompt build path)
|
|
735
|
+
if (st.modelResolveSpan) {
|
|
736
|
+
st.modelResolveSpan.setStatus({ code: SpanStatusCode.OK });
|
|
737
|
+
st.modelResolveSpan.end();
|
|
738
|
+
st.modelResolveSpan = undefined;
|
|
739
|
+
}
|
|
740
|
+
// Close previous LLM span if still open (multi-round)
|
|
741
|
+
if (st.llmSpan) {
|
|
742
|
+
st.llmSpan.setStatus({ code: SpanStatusCode.OK });
|
|
743
|
+
st.llmSpan.end();
|
|
744
|
+
log.info(`${LOG_PREFIX} Ended previous LLM span (new round)`);
|
|
745
|
+
}
|
|
746
|
+
st.llmRound += 1;
|
|
747
|
+
const provider = event?.provider ?? "unknown";
|
|
748
|
+
const model = event?.model ?? "unknown";
|
|
749
|
+
const sessionId = event?.sessionId ?? hookCtx?.sessionId ?? "";
|
|
750
|
+
// Cache for agent-level attributes (first round only)
|
|
751
|
+
if (st.llmRound === 1) {
|
|
752
|
+
st.agentProvider = provider;
|
|
753
|
+
st.agentModel = model;
|
|
754
|
+
if (event?.prompt) {
|
|
755
|
+
// Strip inbound metadata from user prompt (llm_input.prompt
|
|
756
|
+
// comes from prefixedCommandBody which includes metadata)
|
|
757
|
+
st.agentInputUserMessage = stripInboundMetadata(typeof event.prompt === "string" ? event.prompt : extractText(event.prompt));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Use GenAI standard span name: "chat {model}"
|
|
761
|
+
st.llmSpan = tracer.startSpan(`chat ${model}`, {
|
|
762
|
+
kind: SpanKind.CLIENT,
|
|
763
|
+
attributes: {
|
|
764
|
+
[A.SESSION_KEY]: key,
|
|
765
|
+
[A.RUN_ID]: event?.runId ?? hookCtx?.runId ?? "",
|
|
766
|
+
// GenAI standard
|
|
767
|
+
[A.GENAI_SYSTEM]: provider,
|
|
768
|
+
[A.GENAI_OPERATION_NAME]: "chat",
|
|
769
|
+
[A.GENAI_REQUEST_MODEL]: model,
|
|
770
|
+
[A.GENAI_MODEL_NAME]: model,
|
|
771
|
+
[A.GENAI_PROVIDER_NAME]: provider,
|
|
772
|
+
[A.GENAI_SPAN_KIND]: "LLM",
|
|
773
|
+
[A.GENAI_CONVERSATION_ID]: sessionId,
|
|
774
|
+
[A.GENAI_SESSION_ID]: sessionId,
|
|
775
|
+
// OpenClaw domain
|
|
776
|
+
[A.LLM_ROUND]: st.llmRound,
|
|
777
|
+
// Traceloop compat — APM LLM Tab uses these
|
|
778
|
+
[A.TRACELOOP_SPAN_KIND]: "task",
|
|
779
|
+
[A.TRACELOOP_ENTITY_NAME]: `chat ${model}`,
|
|
780
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
781
|
+
[A.LLM_REQUEST_TYPE]: "chat",
|
|
782
|
+
},
|
|
783
|
+
}, agentOrRoot(st));
|
|
784
|
+
st.llmCtx = trace.setSpan(agentOrRoot(st), st.llmSpan);
|
|
785
|
+
// ── Record input messages (indexed attributes for APM) ──
|
|
786
|
+
try {
|
|
787
|
+
const promptMessages = [];
|
|
788
|
+
// System prompt
|
|
789
|
+
if (event?.systemPrompt) {
|
|
790
|
+
promptMessages.push({ role: "system", content: event.systemPrompt });
|
|
791
|
+
}
|
|
792
|
+
// History messages (from session)
|
|
793
|
+
if (Array.isArray(event?.historyMessages)) {
|
|
794
|
+
for (const msg of event.historyMessages) {
|
|
795
|
+
const role = msg?.role ?? "unknown";
|
|
796
|
+
let content = msg?.content ?? msg?.text ?? "";
|
|
797
|
+
if (content) {
|
|
798
|
+
// Strip inbound metadata from user messages in history
|
|
799
|
+
if (role === "user") {
|
|
800
|
+
const textContent = typeof content === "string"
|
|
801
|
+
? content
|
|
802
|
+
: extractText(content);
|
|
803
|
+
content = stripInboundMetadata(textContent);
|
|
804
|
+
}
|
|
805
|
+
promptMessages.push({ role, content });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// Current user prompt
|
|
810
|
+
if (event?.prompt) {
|
|
811
|
+
const cleanedPrompt = stripInboundMetadata(typeof event.prompt === "string" ? event.prompt : extractText(event.prompt));
|
|
812
|
+
promptMessages.push({ role: "user", content: cleanedPrompt });
|
|
813
|
+
}
|
|
814
|
+
if (promptMessages.length > 0) {
|
|
815
|
+
// Set llm.request.type
|
|
816
|
+
st.llmSpan.setAttribute(A.LLM_REQUEST_TYPE, "chat");
|
|
817
|
+
// APM-compatible indexed attributes: gen_ai.prompt.{N}.role/content
|
|
818
|
+
setIndexedPromptAttrs(st.llmSpan, promptMessages);
|
|
819
|
+
// Traceloop entity input — JSON serialization of all prompt messages
|
|
820
|
+
// APM LLM Tab uses this as "操作对象输入"
|
|
821
|
+
try {
|
|
822
|
+
const entityInput = JSON.stringify(promptMessages.map((m) => ({
|
|
823
|
+
role: m.role,
|
|
824
|
+
content: truncate(m.role === "user"
|
|
825
|
+
? stripInboundMetadata(extractText(m.content ?? m.text))
|
|
826
|
+
: extractText(m.content ?? m.text), MAX_ATTR_CONTENT_LENGTH),
|
|
827
|
+
})));
|
|
828
|
+
st.llmSpan.setAttribute(A.TRACELOOP_ENTITY_INPUT, truncateJson(entityInput, MAX_ATTR_CONTENT_LENGTH));
|
|
829
|
+
}
|
|
830
|
+
catch (_) { /* best-effort */ }
|
|
831
|
+
// Compact summary for quick glance
|
|
832
|
+
const roleCounts = new Map();
|
|
833
|
+
for (const m of promptMessages) {
|
|
834
|
+
const r = (typeof m.role === "string" ? m.role : "unknown");
|
|
835
|
+
roleCounts.set(r, (roleCounts.get(r) ?? 0) + 1);
|
|
836
|
+
}
|
|
837
|
+
setPromptSummary(st.llmSpan, promptMessages.length, roleCounts);
|
|
838
|
+
log.info(`${LOG_PREFIX} [llm_input] Set ${promptMessages.length} indexed prompt attrs ` +
|
|
839
|
+
`for key=${key}`);
|
|
840
|
+
}
|
|
841
|
+
// Set llm.request.functions.{N}.* for available tool definitions
|
|
842
|
+
// (extract from historyMessages assistant content blocks that had tool calls)
|
|
843
|
+
// NOTE: Tool definitions aren't in the llm_input event directly.
|
|
844
|
+
// We'll try to extract them from event.tools if provided by future OpenClaw versions.
|
|
845
|
+
if (Array.isArray(event?.tools)) {
|
|
846
|
+
const toolDefs = event.tools.map((t) => ({
|
|
847
|
+
name: t.name ?? "unknown",
|
|
848
|
+
description: t.description ?? "",
|
|
849
|
+
parameters: t.parameters ?? t.inputSchema ?? {},
|
|
850
|
+
}));
|
|
851
|
+
setIndexedFunctionAttrs(st.llmSpan, toolDefs);
|
|
852
|
+
log.info(`${LOG_PREFIX} [llm_input] Set ${toolDefs.length} function attrs for key=${key}`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
log.info(`${LOG_PREFIX} [llm_input] Error setting prompt attributes: ${e}`);
|
|
857
|
+
}
|
|
858
|
+
}, { priority: -100 });
|
|
859
|
+
// ════════════════════════════════════════════════════════════
|
|
860
|
+
// 7. llm_output → End LLM Call Span
|
|
861
|
+
// ════════════════════════════════════════════════════════════
|
|
862
|
+
api.on("llm_output", (event, hookCtx) => {
|
|
863
|
+
ensureSessionKeyMapping(hookCtx);
|
|
864
|
+
const key = resolveKey(hookCtx, event);
|
|
865
|
+
const st = states.get(key);
|
|
866
|
+
if (!st?.llmSpan) {
|
|
867
|
+
log.info(`${LOG_PREFIX} [llm_output] key=${key} — no LLM span to end`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// ── GenAI standard token usage ──
|
|
871
|
+
const usage = event?.usage ?? {};
|
|
872
|
+
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
|
|
873
|
+
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
|
|
874
|
+
const totalTokens = usage.total ?? inputTokens + outputTokens;
|
|
875
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
876
|
+
const cacheWrite = usage.cacheWrite ?? usage.cacheCreation ?? 0;
|
|
877
|
+
st.llmSpan.setAttributes({
|
|
878
|
+
// Standard GenAI token attributes
|
|
879
|
+
[A.GENAI_USAGE_INPUT_TOKENS]: inputTokens,
|
|
880
|
+
[A.GENAI_USAGE_OUTPUT_TOKENS]: outputTokens,
|
|
881
|
+
[A.GENAI_USAGE_CACHE_READ_INPUT]: cacheRead,
|
|
882
|
+
[A.GENAI_USAGE_CACHE_CREATION_INPUT]: cacheWrite,
|
|
883
|
+
// Legacy aliases for compatibility
|
|
884
|
+
[A.GENAI_USAGE_PROMPT_TOKENS]: inputTokens,
|
|
885
|
+
[A.GENAI_USAGE_COMPLETION_TOKENS]: outputTokens,
|
|
886
|
+
[A.GENAI_USAGE_TOTAL_TOKENS]: totalTokens,
|
|
887
|
+
// Response model
|
|
888
|
+
[A.GENAI_RESPONSE_MODEL]: event?.model ?? "unknown",
|
|
889
|
+
});
|
|
890
|
+
// ── Record output messages (indexed attributes for APM) ──
|
|
891
|
+
try {
|
|
892
|
+
const completionMessages = [];
|
|
893
|
+
// assistantTexts is a string[] of all assistant reply segments
|
|
894
|
+
if (Array.isArray(event?.assistantTexts) && event.assistantTexts.length > 0) {
|
|
895
|
+
const fullText = event.assistantTexts.join("");
|
|
896
|
+
const stopReason = event?.lastAssistant?.stopReason ?? "stop";
|
|
897
|
+
const toolCalls = extractToolCallsFromContent(event?.lastAssistant?.content);
|
|
898
|
+
completionMessages.push({
|
|
899
|
+
role: "assistant",
|
|
900
|
+
content: fullText,
|
|
901
|
+
stopReason,
|
|
902
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
else if (event?.lastAssistant) {
|
|
906
|
+
// Fallback: use lastAssistant object
|
|
907
|
+
const la = event.lastAssistant;
|
|
908
|
+
const text = extractText(la.content ?? la.text);
|
|
909
|
+
const toolCalls = extractToolCallsFromContent(la.content);
|
|
910
|
+
completionMessages.push({
|
|
911
|
+
role: "assistant",
|
|
912
|
+
content: text,
|
|
913
|
+
stopReason: la.stopReason ?? "stop",
|
|
914
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
if (completionMessages.length > 0) {
|
|
918
|
+
// APM-compatible indexed attributes: gen_ai.completion.{N}.role/content/finish_reason
|
|
919
|
+
setIndexedCompletionAttrs(st.llmSpan, completionMessages);
|
|
920
|
+
// Traceloop entity output — JSON serialization of completion messages
|
|
921
|
+
// APM LLM Tab uses this as "操作对象输出"
|
|
922
|
+
try {
|
|
923
|
+
const entityOutput = JSON.stringify(completionMessages.map((m) => ({
|
|
924
|
+
role: m.role ?? "assistant",
|
|
925
|
+
content: truncate(typeof m.content === "string" ? m.content : extractText(m.content), MAX_ATTR_CONTENT_LENGTH),
|
|
926
|
+
...(m.toolCalls && m.toolCalls.length > 0
|
|
927
|
+
? { tool_calls: m.toolCalls }
|
|
928
|
+
: {}),
|
|
929
|
+
})));
|
|
930
|
+
st.llmSpan.setAttribute(A.TRACELOOP_ENTITY_OUTPUT, truncateJson(entityOutput, MAX_ATTR_CONTENT_LENGTH));
|
|
931
|
+
}
|
|
932
|
+
catch (_) { /* best-effort */ }
|
|
933
|
+
// Cache for agent-level output (always update to latest round)
|
|
934
|
+
const lastContent = typeof completionMessages[0]?.content === "string"
|
|
935
|
+
? completionMessages[0].content
|
|
936
|
+
: extractText(completionMessages[0]?.content);
|
|
937
|
+
if (lastContent) {
|
|
938
|
+
st.agentOutputAssistantMessage = lastContent;
|
|
939
|
+
}
|
|
940
|
+
// Also cache tool calls for the agent-level completion
|
|
941
|
+
if (completionMessages[0]?.toolCalls) {
|
|
942
|
+
st.agentOutputToolCalls = completionMessages[0].toolCalls;
|
|
943
|
+
}
|
|
944
|
+
log.info(`${LOG_PREFIX} [llm_output] Set indexed completion attrs: ` +
|
|
945
|
+
`${lastContent?.length ?? 0} chars, ` +
|
|
946
|
+
`finishReason=${completionMessages[0]?.stopReason ?? "?"} ` +
|
|
947
|
+
`toolCalls=${completionMessages[0]?.toolCalls?.length ?? 0} ` +
|
|
948
|
+
`for key=${key}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
catch (e) {
|
|
952
|
+
log.info(`${LOG_PREFIX} [llm_output] Error setting completion attributes: ${e}`);
|
|
953
|
+
}
|
|
954
|
+
st.llmSpan.setStatus({ code: SpanStatusCode.OK });
|
|
955
|
+
st.llmSpan.end();
|
|
956
|
+
log.info(`${LOG_PREFIX} [llm_output] Ended LLM span for key=${key} ` +
|
|
957
|
+
`tokens.in=${inputTokens} tokens.out=${outputTokens}`);
|
|
958
|
+
st.llmSpan = undefined;
|
|
959
|
+
st.llmCtx = undefined;
|
|
960
|
+
}, { priority: -100 });
|
|
961
|
+
// ════════════════════════════════════════════════════════════
|
|
962
|
+
// 8. before_tool_call → Tool Span (CLIENT, child of LLM)
|
|
963
|
+
// ════════════════════════════════════════════════════════════
|
|
964
|
+
api.on("before_tool_call", (event, hookCtx) => {
|
|
965
|
+
ensureSessionKeyMapping(hookCtx);
|
|
966
|
+
const key = resolveKey(hookCtx, event);
|
|
967
|
+
const st = states.get(key);
|
|
968
|
+
const toolName = event?.toolName ?? hookCtx?.toolName ?? "unknown";
|
|
969
|
+
const toolCallId = hookCtx?.toolCallId ?? event?.toolCallId ?? `tool-${Date.now()}`;
|
|
970
|
+
log.info(`${LOG_PREFIX} [before_tool_call] key=${key} tool=${toolName} ` +
|
|
971
|
+
`callId=${toolCallId} hasState=${!!st}`);
|
|
972
|
+
if (!st)
|
|
973
|
+
return;
|
|
974
|
+
const parentCtx = st.llmCtx ?? agentOrRoot(st);
|
|
975
|
+
const span = tracer.startSpan(`${SPAN.TOOL}.${toolName}`, {
|
|
976
|
+
kind: SpanKind.CLIENT,
|
|
977
|
+
attributes: {
|
|
978
|
+
[A.SESSION_KEY]: key,
|
|
979
|
+
[A.TOOL_NAME]: toolName,
|
|
980
|
+
[A.TOOL_CALL_ID]: toolCallId,
|
|
981
|
+
[A.RUN_ID]: hookCtx?.runId ?? event?.runId ?? "",
|
|
982
|
+
[A.GENAI_SPAN_KIND]: "TOOL",
|
|
983
|
+
[A.GENAI_OPERATION_NAME]: "execute_tool",
|
|
984
|
+
// Traceloop compat — APM LLM Tab uses these
|
|
985
|
+
[A.TRACELOOP_SPAN_KIND]: "tool",
|
|
986
|
+
[A.TRACELOOP_ENTITY_NAME]: toolName,
|
|
987
|
+
[A.TRACELOOP_WORKFLOW_NAME]: "OpenClaw",
|
|
988
|
+
},
|
|
989
|
+
}, parentCtx);
|
|
990
|
+
// Set traceloop.entity.input from tool arguments
|
|
991
|
+
// NOTE: value MUST be valid JSON — APM frontend calls JSON.parse() on it.
|
|
992
|
+
try {
|
|
993
|
+
const toolInput = event?.arguments ?? event?.input ?? event?.params;
|
|
994
|
+
if (toolInput) {
|
|
995
|
+
const inputStr = typeof toolInput === "string"
|
|
996
|
+
? ensureJson(toolInput)
|
|
997
|
+
: JSON.stringify(toolInput);
|
|
998
|
+
span.setAttribute(A.TRACELOOP_ENTITY_INPUT, truncateJson(inputStr, MAX_ATTR_CONTENT_LENGTH));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
catch (_) { /* best-effort */ }
|
|
1002
|
+
st.toolSpans.set(toolCallId, span);
|
|
1003
|
+
}, { priority: -100 });
|
|
1004
|
+
// ════════════════════════════════════════════════════════════
|
|
1005
|
+
// 9. after_tool_call → End Tool Span
|
|
1006
|
+
// ════════════════════════════════════════════════════════════
|
|
1007
|
+
api.on("after_tool_call", (event, hookCtx) => {
|
|
1008
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1009
|
+
const key = resolveKey(hookCtx, event);
|
|
1010
|
+
const st = states.get(key);
|
|
1011
|
+
if (!st)
|
|
1012
|
+
return;
|
|
1013
|
+
const toolCallId = hookCtx?.toolCallId ?? event?.toolCallId ?? "";
|
|
1014
|
+
const span = st.toolSpans.get(toolCallId);
|
|
1015
|
+
if (!span) {
|
|
1016
|
+
log.info(`${LOG_PREFIX} [after_tool_call] key=${key} callId=${toolCallId} — no matching span`);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (event?.durationMs != null) {
|
|
1020
|
+
span.setAttribute(A.TOOL_DURATION_MS, event.durationMs);
|
|
1021
|
+
}
|
|
1022
|
+
// Set traceloop.entity.output from tool result
|
|
1023
|
+
// NOTE: value MUST be valid JSON — APM frontend calls JSON.parse() on it.
|
|
1024
|
+
try {
|
|
1025
|
+
const toolOutput = event?.result ?? event?.output ?? event?.response;
|
|
1026
|
+
if (toolOutput) {
|
|
1027
|
+
const outputStr = typeof toolOutput === "string"
|
|
1028
|
+
? ensureJson(toolOutput)
|
|
1029
|
+
: JSON.stringify(toolOutput);
|
|
1030
|
+
span.setAttribute(A.TRACELOOP_ENTITY_OUTPUT, truncateJson(outputStr, MAX_ATTR_CONTENT_LENGTH));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
catch (_) { /* best-effort */ }
|
|
1034
|
+
if (event?.error) {
|
|
1035
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(event.error) });
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1039
|
+
}
|
|
1040
|
+
span.end();
|
|
1041
|
+
st.toolSpans.delete(toolCallId);
|
|
1042
|
+
log.info(`${LOG_PREFIX} [after_tool_call] Ended tool span key=${key} ` +
|
|
1043
|
+
`tool=${event?.toolName ?? "?"} callId=${toolCallId}`);
|
|
1044
|
+
}, { priority: -100 });
|
|
1045
|
+
// ════════════════════════════════════════════════════════════
|
|
1046
|
+
// 10. before_compaction → Compaction Span (child of agent)
|
|
1047
|
+
// ════════════════════════════════════════════════════════════
|
|
1048
|
+
api.on("before_compaction", (event, hookCtx) => {
|
|
1049
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1050
|
+
const key = resolveKey(hookCtx, event);
|
|
1051
|
+
const st = states.get(key);
|
|
1052
|
+
log.info(`${LOG_PREFIX} [before_compaction] key=${key} ` +
|
|
1053
|
+
`messageCount=${event?.messageCount ?? "?"} tokenCount=${event?.tokenCount ?? "?"} ` +
|
|
1054
|
+
`hasState=${!!st}`);
|
|
1055
|
+
if (!st)
|
|
1056
|
+
return;
|
|
1057
|
+
st.compactionSpan = tracer.startSpan(SPAN.COMPACTION, {
|
|
1058
|
+
attributes: {
|
|
1059
|
+
[A.SESSION_KEY]: key,
|
|
1060
|
+
[A.COMPACTION_MSG_BEFORE]: event?.messageCount ?? -1,
|
|
1061
|
+
[A.COMPACTION_TOKENS_BEFORE]: event?.tokenCount ?? -1,
|
|
1062
|
+
},
|
|
1063
|
+
}, agentOrRoot(st));
|
|
1064
|
+
}, { priority: -100 });
|
|
1065
|
+
// ════════════════════════════════════════════════════════════
|
|
1066
|
+
// 11. after_compaction → End Compaction Span
|
|
1067
|
+
// ════════════════════════════════════════════════════════════
|
|
1068
|
+
api.on("after_compaction", (event, hookCtx) => {
|
|
1069
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1070
|
+
const key = resolveKey(hookCtx, event);
|
|
1071
|
+
const st = states.get(key);
|
|
1072
|
+
log.info(`${LOG_PREFIX} [after_compaction] key=${key} ` +
|
|
1073
|
+
`messageCount=${event?.messageCount ?? "?"} compactedCount=${event?.compactedCount ?? "?"} ` +
|
|
1074
|
+
`hasState=${!!st}`);
|
|
1075
|
+
if (!st?.compactionSpan)
|
|
1076
|
+
return;
|
|
1077
|
+
st.compactionSpan.setAttributes({
|
|
1078
|
+
[A.COMPACTION_MSG_AFTER]: event?.messageCount ?? -1,
|
|
1079
|
+
[A.COMPACTION_TOKENS_AFTER]: event?.tokenCount ?? -1,
|
|
1080
|
+
[A.COMPACTION_COMPACTED]: event?.compactedCount ?? 0,
|
|
1081
|
+
});
|
|
1082
|
+
st.compactionSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1083
|
+
st.compactionSpan.end();
|
|
1084
|
+
st.compactionSpan = undefined;
|
|
1085
|
+
log.info(`${LOG_PREFIX} Ended compaction span for key=${key}`);
|
|
1086
|
+
}, { priority: -100 });
|
|
1087
|
+
// ════════════════════════════════════════════════════════════
|
|
1088
|
+
// 12. subagent_spawning → Log (pre-spawn phase)
|
|
1089
|
+
// ════════════════════════════════════════════════════════════
|
|
1090
|
+
api.on("subagent_spawning", (event, hookCtx) => {
|
|
1091
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1092
|
+
const key = resolveKey(hookCtx, event);
|
|
1093
|
+
log.info(`${LOG_PREFIX} [subagent_spawning] key=${key} ` +
|
|
1094
|
+
`childSessionKey=${event?.sessionKey ?? "?"}`);
|
|
1095
|
+
// Note: actual span is created in subagent_spawned
|
|
1096
|
+
}, { priority: -100 });
|
|
1097
|
+
// ════════════════════════════════════════════════════════════
|
|
1098
|
+
// 13. subagent_spawned → Subagent Span (child of agent)
|
|
1099
|
+
// ════════════════════════════════════════════════════════════
|
|
1100
|
+
api.on("subagent_spawned", (event, hookCtx) => {
|
|
1101
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1102
|
+
const parentKey = resolveKey(hookCtx, event);
|
|
1103
|
+
const st = states.get(parentKey);
|
|
1104
|
+
if (!st)
|
|
1105
|
+
return;
|
|
1106
|
+
const childKey = event?.sessionKey ?? event?.childSessionKey ?? "unknown-child";
|
|
1107
|
+
const span = tracer.startSpan(SPAN.SUBAGENT, {
|
|
1108
|
+
attributes: {
|
|
1109
|
+
[A.SESSION_KEY]: parentKey,
|
|
1110
|
+
[A.SUBAGENT_SESSION_KEY]: childKey,
|
|
1111
|
+
[A.SUBAGENT_AGENT_ID]: event?.agentId ?? "",
|
|
1112
|
+
[A.GENAI_SPAN_KIND]: "AGENT",
|
|
1113
|
+
[A.GENAI_OPERATION_NAME]: "invoke_agent",
|
|
1114
|
+
},
|
|
1115
|
+
}, agentOrRoot(st));
|
|
1116
|
+
st.subagentSpans.set(childKey, span);
|
|
1117
|
+
log.info(`${LOG_PREFIX} [subagent_spawned] parent=${parentKey} child=${childKey}`);
|
|
1118
|
+
}, { priority: -100 });
|
|
1119
|
+
// ════════════════════════════════════════════════════════════
|
|
1120
|
+
// 14. subagent_ended → End Subagent Span
|
|
1121
|
+
// ════════════════════════════════════════════════════════════
|
|
1122
|
+
api.on("subagent_ended", (event, hookCtx) => {
|
|
1123
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1124
|
+
const parentKey = resolveKey(hookCtx, event);
|
|
1125
|
+
const st = states.get(parentKey);
|
|
1126
|
+
if (!st)
|
|
1127
|
+
return;
|
|
1128
|
+
const childKey = event?.sessionKey ?? event?.childSessionKey ?? "unknown-child";
|
|
1129
|
+
const span = st.subagentSpans.get(childKey);
|
|
1130
|
+
if (!span)
|
|
1131
|
+
return;
|
|
1132
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1133
|
+
span.end();
|
|
1134
|
+
st.subagentSpans.delete(childKey);
|
|
1135
|
+
log.info(`${LOG_PREFIX} [subagent_ended] parent=${parentKey} child=${childKey}`);
|
|
1136
|
+
}, { priority: -100 });
|
|
1137
|
+
// ════════════════════════════════════════════════════════════
|
|
1138
|
+
// 15. message_sending → Reply Span (child of root)
|
|
1139
|
+
// ════════════════════════════════════════════════════════════
|
|
1140
|
+
api.on("message_sending", (event, hookCtx) => {
|
|
1141
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1142
|
+
const key = resolveKey(hookCtx, event);
|
|
1143
|
+
const st = states.get(key);
|
|
1144
|
+
log.info(`${LOG_PREFIX} [message_sending] key=${key} hasState=${!!st}`);
|
|
1145
|
+
if (!st)
|
|
1146
|
+
return;
|
|
1147
|
+
st.replySpan = tracer.startSpan(SPAN.REPLY, {
|
|
1148
|
+
attributes: {
|
|
1149
|
+
[A.SESSION_KEY]: key,
|
|
1150
|
+
[A.MESSAGE_TO]: event?.to ?? "",
|
|
1151
|
+
[A.CHANNEL]: hookCtx?.channelId ?? "unknown",
|
|
1152
|
+
},
|
|
1153
|
+
}, st.rootCtx);
|
|
1154
|
+
}, { priority: -100 });
|
|
1155
|
+
// ════════════════════════════════════════════════════════════
|
|
1156
|
+
// 16. message_sent → End Reply Span + Root Span
|
|
1157
|
+
// ════════════════════════════════════════════════════════════
|
|
1158
|
+
api.on("message_sent", (event, hookCtx) => {
|
|
1159
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1160
|
+
const key = resolveKey(hookCtx, event);
|
|
1161
|
+
const st = states.get(key);
|
|
1162
|
+
log.info(`${LOG_PREFIX} [message_sent] key=${key} hasState=${!!st}`);
|
|
1163
|
+
if (!st)
|
|
1164
|
+
return;
|
|
1165
|
+
// End reply span
|
|
1166
|
+
if (st.replySpan) {
|
|
1167
|
+
st.replySpan.setAttributes({
|
|
1168
|
+
[A.MESSAGE_TO]: event?.to ?? "",
|
|
1169
|
+
[A.MESSAGE_SUCCESS]: event?.success ?? true,
|
|
1170
|
+
});
|
|
1171
|
+
if (event?.error) {
|
|
1172
|
+
st.replySpan.setStatus({
|
|
1173
|
+
code: SpanStatusCode.ERROR,
|
|
1174
|
+
message: String(event.error),
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
st.replySpan.setStatus({ code: SpanStatusCode.OK });
|
|
1179
|
+
}
|
|
1180
|
+
st.replySpan.end();
|
|
1181
|
+
st.replySpan = undefined;
|
|
1182
|
+
}
|
|
1183
|
+
// End all + root
|
|
1184
|
+
endAllChildren(st);
|
|
1185
|
+
st.rootSpan.setAttribute("openclaw.message.count", st.messageCount);
|
|
1186
|
+
st.rootSpan.setAttribute("openclaw.ended_by", "message_sent");
|
|
1187
|
+
// Record assistant reply on root span using domain-specific attribute.
|
|
1188
|
+
// Do NOT use traceloop.entity.output — see note in message_received.
|
|
1189
|
+
try {
|
|
1190
|
+
if (st.agentOutputAssistantMessage) {
|
|
1191
|
+
st.rootSpan.setAttribute("openclaw.assistant.reply", truncate(st.agentOutputAssistantMessage, MAX_ATTR_CONTENT_LENGTH));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
catch (_) { /* best-effort */ }
|
|
1195
|
+
st.rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1196
|
+
st.rootSpan.end();
|
|
1197
|
+
log.info(`${LOG_PREFIX} [message_sent] Completed trace ` +
|
|
1198
|
+
`traceId=${st.rootSpan.spanContext().traceId} key=${key}`);
|
|
1199
|
+
cleanupState(key, st);
|
|
1200
|
+
}, { priority: -100 });
|
|
1201
|
+
// ════════════════════════════════════════════════════════════
|
|
1202
|
+
// 17. session_end → End Session + Root
|
|
1203
|
+
// ════════════════════════════════════════════════════════════
|
|
1204
|
+
api.on("session_end", (event, hookCtx) => {
|
|
1205
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1206
|
+
const key = resolveKey(hookCtx, event);
|
|
1207
|
+
const st = states.get(key);
|
|
1208
|
+
log.info(`${LOG_PREFIX} [session_end] key=${key} hasState=${!!st}`);
|
|
1209
|
+
if (!st)
|
|
1210
|
+
return;
|
|
1211
|
+
if (st.sessionSpan) {
|
|
1212
|
+
if (event?.durationMs != null) {
|
|
1213
|
+
st.sessionSpan.setAttribute(A.SESSION_DURATION_MS, event.durationMs);
|
|
1214
|
+
}
|
|
1215
|
+
if (event?.messageCount != null) {
|
|
1216
|
+
st.sessionSpan.setAttribute(A.SESSION_MSG_COUNT, event.messageCount);
|
|
1217
|
+
}
|
|
1218
|
+
st.sessionSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1219
|
+
st.sessionSpan.end();
|
|
1220
|
+
st.sessionSpan = undefined;
|
|
1221
|
+
st.sessionCtx = undefined;
|
|
1222
|
+
}
|
|
1223
|
+
endAllChildren(st);
|
|
1224
|
+
st.rootSpan.setAttribute("openclaw.message.count", st.messageCount);
|
|
1225
|
+
st.rootSpan.setAttribute("openclaw.ended_by", "session_end");
|
|
1226
|
+
if (event?.durationMs != null) {
|
|
1227
|
+
st.rootSpan.setAttribute(A.SESSION_DURATION_MS, event.durationMs);
|
|
1228
|
+
}
|
|
1229
|
+
// Record assistant reply — use domain attribute, not traceloop.entity.output
|
|
1230
|
+
try {
|
|
1231
|
+
if (st.agentOutputAssistantMessage) {
|
|
1232
|
+
st.rootSpan.setAttribute("openclaw.assistant.reply", truncate(st.agentOutputAssistantMessage, MAX_ATTR_CONTENT_LENGTH));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
catch (_) { /* best-effort */ }
|
|
1236
|
+
st.rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1237
|
+
st.rootSpan.end();
|
|
1238
|
+
log.info(`${LOG_PREFIX} [session_end] Completed trace ` +
|
|
1239
|
+
`traceId=${st.rootSpan.spanContext().traceId} key=${key}`);
|
|
1240
|
+
cleanupState(key, st);
|
|
1241
|
+
}, { priority: -100 });
|
|
1242
|
+
// ════════════════════════════════════════════════════════════
|
|
1243
|
+
// 18. agent_end → Fallback: end entire trace (delayed 2s)
|
|
1244
|
+
// ════════════════════════════════════════════════════════════
|
|
1245
|
+
api.on("agent_end", (event, hookCtx) => {
|
|
1246
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1247
|
+
const key = resolveKey(hookCtx, event);
|
|
1248
|
+
const st = states.get(key);
|
|
1249
|
+
log.info(`${LOG_PREFIX} [agent_end] key=${key} hasState=${!!st}`);
|
|
1250
|
+
if (!st)
|
|
1251
|
+
return;
|
|
1252
|
+
// Delay 2s to let llm_output arrive (it often fires right after agent_end)
|
|
1253
|
+
setTimeout(() => {
|
|
1254
|
+
const currentSt = states.get(key);
|
|
1255
|
+
if (!currentSt) {
|
|
1256
|
+
log.info(`${LOG_PREFIX} [agent_end/delayed] key=${key} already cleaned up`);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
endAllChildren(currentSt);
|
|
1260
|
+
currentSt.rootSpan.setAttribute("openclaw.message.count", currentSt.messageCount);
|
|
1261
|
+
currentSt.rootSpan.setAttribute("openclaw.ended_by", "agent_end");
|
|
1262
|
+
// Record assistant reply — use domain attribute, not traceloop.entity.output
|
|
1263
|
+
try {
|
|
1264
|
+
if (currentSt.agentOutputAssistantMessage) {
|
|
1265
|
+
currentSt.rootSpan.setAttribute("openclaw.assistant.reply", truncate(currentSt.agentOutputAssistantMessage, MAX_ATTR_CONTENT_LENGTH));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
catch (_) { /* best-effort */ }
|
|
1269
|
+
currentSt.rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1270
|
+
currentSt.rootSpan.end();
|
|
1271
|
+
log.info(`${LOG_PREFIX} [agent_end/delayed] Completed trace ` +
|
|
1272
|
+
`traceId=${currentSt.rootSpan.spanContext().traceId} key=${key}`);
|
|
1273
|
+
cleanupState(key, currentSt);
|
|
1274
|
+
}, 2000);
|
|
1275
|
+
}, { priority: -100 });
|
|
1276
|
+
// ════════════════════════════════════════════════════════════
|
|
1277
|
+
// 19. before_reset → Add event to root span
|
|
1278
|
+
// ════════════════════════════════════════════════════════════
|
|
1279
|
+
api.on("before_reset", (event, hookCtx) => {
|
|
1280
|
+
ensureSessionKeyMapping(hookCtx);
|
|
1281
|
+
const key = resolveKey(hookCtx, event);
|
|
1282
|
+
const st = states.get(key);
|
|
1283
|
+
log.info(`${LOG_PREFIX} [before_reset] key=${key} hasState=${!!st}`);
|
|
1284
|
+
if (!st)
|
|
1285
|
+
return;
|
|
1286
|
+
st.rootSpan.addEvent("session.reset", {
|
|
1287
|
+
[A.SESSION_KEY]: key,
|
|
1288
|
+
"openclaw.reset.reason": event?.reason ?? "user_request",
|
|
1289
|
+
});
|
|
1290
|
+
}, { priority: -100 });
|
|
1291
|
+
// ════════════════════════════════════════════════════════════
|
|
1292
|
+
// Helper: cleanup state + sessionKeyMap
|
|
1293
|
+
// ════════════════════════════════════════════════════════════
|
|
1294
|
+
function cleanupState(key, _st) {
|
|
1295
|
+
states.delete(key);
|
|
1296
|
+
for (const [sk, ck] of sessionKeyMap) {
|
|
1297
|
+
if (ck === key)
|
|
1298
|
+
sessionKeyMap.delete(sk);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const hookCount = 19;
|
|
1302
|
+
log.info(`${LOG_PREFIX} Registered ${hookCount} hook handlers. Active tracer: ${tracer ? "yes" : "no"}`);
|
|
1303
|
+
// Store cleanup function
|
|
1304
|
+
this._cleanup = () => {
|
|
1305
|
+
clearInterval(sweepTimer);
|
|
1306
|
+
for (const [key, st] of states) {
|
|
1307
|
+
endAllChildren(st);
|
|
1308
|
+
st.rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1309
|
+
st.rootSpan.setAttribute("openclaw.cleanup", true);
|
|
1310
|
+
st.rootSpan.end();
|
|
1311
|
+
}
|
|
1312
|
+
states.clear();
|
|
1313
|
+
sessionKeyMap.clear();
|
|
1314
|
+
};
|
|
1315
|
+
},
|
|
1316
|
+
async stop() {
|
|
1317
|
+
if (this._cleanup) {
|
|
1318
|
+
this._cleanup();
|
|
1319
|
+
}
|
|
1320
|
+
},
|
|
1321
|
+
});
|
|
1322
|
+
api.logger.info(`${LOG_PREFIX} Plugin registered (service mode)`);
|
|
1323
|
+
},
|
|
1324
|
+
};
|
|
1325
|
+
// ─── Helper: close all child spans ───────────────────────────────────────
|
|
1326
|
+
function endAllChildren(st) {
|
|
1327
|
+
for (const [, span] of st.toolSpans) {
|
|
1328
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1329
|
+
span.end();
|
|
1330
|
+
}
|
|
1331
|
+
st.toolSpans.clear();
|
|
1332
|
+
for (const [, span] of st.subagentSpans) {
|
|
1333
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1334
|
+
span.end();
|
|
1335
|
+
}
|
|
1336
|
+
st.subagentSpans.clear();
|
|
1337
|
+
if (st.llmSpan) {
|
|
1338
|
+
st.llmSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1339
|
+
st.llmSpan.end();
|
|
1340
|
+
st.llmSpan = undefined;
|
|
1341
|
+
st.llmCtx = undefined;
|
|
1342
|
+
}
|
|
1343
|
+
if (st.compactionSpan) {
|
|
1344
|
+
st.compactionSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1345
|
+
st.compactionSpan.end();
|
|
1346
|
+
st.compactionSpan = undefined;
|
|
1347
|
+
}
|
|
1348
|
+
if (st.modelResolveSpan) {
|
|
1349
|
+
st.modelResolveSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1350
|
+
st.modelResolveSpan.end();
|
|
1351
|
+
st.modelResolveSpan = undefined;
|
|
1352
|
+
}
|
|
1353
|
+
if (st.promptBuildSpan) {
|
|
1354
|
+
st.promptBuildSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1355
|
+
st.promptBuildSpan.end();
|
|
1356
|
+
st.promptBuildSpan = undefined;
|
|
1357
|
+
}
|
|
1358
|
+
if (st.replySpan) {
|
|
1359
|
+
st.replySpan.setStatus({ code: SpanStatusCode.OK });
|
|
1360
|
+
st.replySpan.end();
|
|
1361
|
+
st.replySpan = undefined;
|
|
1362
|
+
}
|
|
1363
|
+
if (st.agentSpan) {
|
|
1364
|
+
// Write agent-level input/output using indexed attributes (APM-compatible)
|
|
1365
|
+
try {
|
|
1366
|
+
// Set llm.request.type for the agent span too
|
|
1367
|
+
st.agentSpan.setAttribute("llm.request.type", "chat");
|
|
1368
|
+
if (st.agentInputUserMessage) {
|
|
1369
|
+
setIndexedPromptAttrs(st.agentSpan, [
|
|
1370
|
+
{ role: "user", content: st.agentInputUserMessage },
|
|
1371
|
+
]);
|
|
1372
|
+
}
|
|
1373
|
+
if (st.agentOutputAssistantMessage || st.agentOutputToolCalls) {
|
|
1374
|
+
const completionMsg = {
|
|
1375
|
+
role: "assistant",
|
|
1376
|
+
content: st.agentOutputAssistantMessage ?? "",
|
|
1377
|
+
stopReason: st.agentOutputToolCalls ? "tool_calls" : "stop",
|
|
1378
|
+
toolCalls: st.agentOutputToolCalls,
|
|
1379
|
+
};
|
|
1380
|
+
setIndexedCompletionAttrs(st.agentSpan, [completionMsg]);
|
|
1381
|
+
}
|
|
1382
|
+
if (st.agentProvider) {
|
|
1383
|
+
st.agentSpan.setAttribute(A.GENAI_PROVIDER_NAME, st.agentProvider);
|
|
1384
|
+
st.agentSpan.setAttribute(A.GENAI_SYSTEM, st.agentProvider);
|
|
1385
|
+
}
|
|
1386
|
+
if (st.agentModel) {
|
|
1387
|
+
st.agentSpan.setAttribute(A.GENAI_MODEL_NAME, st.agentModel);
|
|
1388
|
+
st.agentSpan.setAttribute(A.GENAI_REQUEST_MODEL, st.agentModel);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
catch (_) { /* best-effort */ }
|
|
1392
|
+
st.agentSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1393
|
+
st.agentSpan.end();
|
|
1394
|
+
st.agentSpan = undefined;
|
|
1395
|
+
st.agentCtx = undefined;
|
|
1396
|
+
}
|
|
1397
|
+
if (st.sessionSpan) {
|
|
1398
|
+
st.sessionSpan.setStatus({ code: SpanStatusCode.OK });
|
|
1399
|
+
st.sessionSpan.end();
|
|
1400
|
+
st.sessionSpan = undefined;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
export default plugin;
|
|
1404
|
+
//# sourceMappingURL=index.js.map
|