@latitude-data/openclaw-telemetry 0.0.4 → 0.0.5

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/dist/plugin.js CHANGED
@@ -1,8 +1,8 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { arch, hostname, platform, release } from "node:os";
4
3
  import { dirname, join } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
5
+ import { createHash, randomUUID } from "node:crypto";
6
6
  //#region src/client.ts
7
7
  async function postTraces({ baseUrl, apiKey, project, payload, logger, timeoutMs = 1e4 }) {
8
8
  const url = `${baseUrl.replace(/\/+$/, "")}/v1/traces`;
@@ -77,27 +77,10 @@ function createLogger(debugEnabled) {
77
77
  //#endregion
78
78
  //#region src/otlp.ts
79
79
  const SCOPE_NAME = "@latitude-data/openclaw-telemetry";
80
- /**
81
- * Runtime-read package version, so OTLP `scope.version` and `service.version`
82
- * always reflect what's actually installed. Read once at module load and
83
- * cached. Falls back to `"unknown"` if the read fails (an exporter accepting
84
- * any string is more tolerant than a stale hard-coded literal).
85
- *
86
- * Same import.meta.url + ../package.json pattern used by `cli.ts` for the
87
- * `--version` flag — single source of truth in package.json.
88
- */
89
80
  const SCOPE_VERSION = readScopeVersion();
90
- function readScopeVersion() {
91
- try {
92
- const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
93
- return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "unknown";
94
- } catch {
95
- return "unknown";
96
- }
97
- }
98
81
  /** Build an OTLP export request for a single completed agent run. */
99
- function buildOtlpRequest(run, options) {
100
- const spans = buildRunSpans(run, options);
82
+ function buildOtlpRequest(result, options) {
83
+ const spans = result.spans.map((span) => toOtlpSpan(span, options));
101
84
  return { resourceSpans: [{
102
85
  resource: { attributes: resourceAttrs() },
103
86
  scopeSpans: [{
@@ -109,210 +92,158 @@ function buildOtlpRequest(run, options) {
109
92
  }]
110
93
  }] };
111
94
  }
112
- function buildRunSpans(run, options) {
113
- const traceId = hashHex(`${run.sessionId ?? "session"}:${run.runId}`, 32);
114
- const interactionSpanId = hashHex(`${traceId}:run`, 16);
115
- const out = [buildInteractionSpan(traceId, interactionSpanId, run, options)];
116
- run.llmCalls.forEach((call, idx) => {
117
- const callSpanId = hashHex(`${traceId}:call:${idx}`, 16);
118
- out.push(buildLlmSpan(traceId, interactionSpanId, callSpanId, call, idx, run, options));
119
- call.toolCalls.forEach((tool, tIdx) => {
120
- const toolSpanId = hashHex(`${traceId}:call:${idx}:tool:${tIdx}`, 16);
121
- out.push(buildToolSpan(traceId, interactionSpanId, toolSpanId, tool, run, options));
122
- });
123
- });
124
- run.orphanTools.forEach((tool, idx) => {
125
- const toolSpanId = hashHex(`${traceId}:orphan-tool:${idx}`, 16);
126
- out.push(buildToolSpan(traceId, interactionSpanId, toolSpanId, tool, run, options));
127
- });
128
- return out;
129
- }
130
- function buildInteractionSpan(traceId, spanId, run, options) {
131
- const startNs = msToNs(run.startMs);
132
- const endNs = msToNs(run.endMs ?? run.startMs);
133
- const totalTools = run.llmCalls.reduce((sum, c) => sum + c.toolCalls.length, 0) + run.orphanTools.length;
134
- const totalUsage = aggregateUsage(run.llmCalls);
95
+ function toOtlpSpan(span, options) {
96
+ const startNs = msToNs(span.startMs);
97
+ const endNs = msToNs(span.endMs ?? span.startMs);
98
+ const attrs = [];
99
+ for (const [rawKey, value] of Object.entries(span.attrs)) {
100
+ if (value === void 0 || value === null) continue;
101
+ const isGated = rawKey.endsWith(":gated");
102
+ if (isGated && !options.allowConversationAccess) continue;
103
+ const kv = encodeAttr(isGated ? rawKey.slice(0, -6) : rawKey, value);
104
+ if (kv !== void 0) attrs.push(kv);
105
+ }
106
+ attrs.push(bool("latitude.captured.content", options.allowConversationAccess));
107
+ if (span.endMs !== void 0) attrs.push(int("openclaw.duration_ms.computed", Math.max(0, span.endMs - span.startMs)));
108
+ const statusCode = span.outcome === "error" ? 2 : 1;
135
109
  return {
136
- traceId,
137
- spanId,
138
- parentSpanId: "",
139
- name: "interaction",
110
+ traceId: span.traceId,
111
+ spanId: span.spanId,
112
+ parentSpanId: span.parentSpanId,
113
+ name: span.name,
140
114
  kind: 1,
141
115
  startTimeUnixNano: startNs,
142
116
  endTimeUnixNano: endNs,
143
- attributes: stripUndef([
144
- str("span.type", "interaction"),
145
- str("interaction.kind", "agent_run"),
146
- str("openclaw.run.id", run.runId),
147
- run.sessionId ? str("openclaw.session.id", run.sessionId) : void 0,
148
- run.sessionId ? str("session.id", run.sessionId) : void 0,
149
- run.sessionKey ? str("openclaw.session.key", run.sessionKey) : void 0,
150
- run.agentId ? str("openclaw.agent.id", run.agentId) : void 0,
151
- run.agentId ? str("openclaw.agent.name", run.agentId) : void 0,
152
- run.workspaceDir ? str("openclaw.workspace.dir", run.workspaceDir) : void 0,
153
- run.messageProvider ? str("openclaw.message.provider", run.messageProvider) : void 0,
154
- run.channelId ? str("openclaw.channel.id", run.channelId) : void 0,
155
- run.trigger ? str("openclaw.trigger", run.trigger) : void 0,
156
- run.modelProviderId ? str("openclaw.model.provider.id", run.modelProviderId) : void 0,
157
- run.modelId ? str("openclaw.model.id", run.modelId) : void 0,
158
- int("interaction.duration_ms", durationMs(run.startMs, run.endMs)),
159
- int("interaction.call_count", run.llmCalls.length),
160
- int("interaction.tool_call_count", totalTools),
161
- run.success !== void 0 ? bool("openclaw.run.success", run.success) : void 0,
162
- run.error ? str("openclaw.run.error", run.error) : void 0,
163
- totalUsage.input !== void 0 ? int("gen_ai.usage.input_tokens", totalUsage.input) : void 0,
164
- totalUsage.output !== void 0 ? int("gen_ai.usage.output_tokens", totalUsage.output) : void 0,
165
- totalUsage.cacheRead !== void 0 ? int("gen_ai.usage.cache_read_input_tokens", totalUsage.cacheRead) : void 0,
166
- totalUsage.cacheWrite !== void 0 ? int("gen_ai.usage.cache_creation_input_tokens", totalUsage.cacheWrite) : void 0,
167
- totalUsage.total !== void 0 ? int("gen_ai.usage.total_tokens", totalUsage.total) : void 0,
168
- options.allowConversationAccess && run.llmCalls[0]?.prompt ? str("user_prompt", run.llmCalls[0].prompt) : void 0,
169
- bool("latitude.captured.content", options.allowConversationAccess)
170
- ]),
171
- status: { code: run.success === false ? 2 : 1 }
117
+ attributes: attrs,
118
+ status: { code: statusCode }
172
119
  };
173
120
  }
174
- function buildLlmSpan(traceId, parentSpanId, spanId, call, callIdx, run, options) {
175
- const startNs = msToNs(call.startMs);
176
- const endNs = msToNs(call.endMs ?? call.startMs);
177
- const captureContent = options.allowConversationAccess;
178
- const inputMessages = captureContent ? buildInputMessages(call) : void 0;
179
- const outputMessages = captureContent ? buildOutputMessages(call) : void 0;
180
- const systemInstructions = captureContent && call.systemPrompt ? JSON.stringify([{
181
- type: "text",
182
- content: call.systemPrompt
183
- }]) : void 0;
121
+ function encodeAttr(key, value) {
122
+ if (value === void 0 || value === null) return void 0;
123
+ if (typeof value === "string") return str(key, value);
124
+ if (typeof value === "boolean") return bool(key, value);
125
+ if (typeof value === "number") return Number.isInteger(value) ? int(key, value) : {
126
+ key,
127
+ value: { doubleValue: value }
128
+ };
129
+ return str(key, safeJson$1(value));
130
+ }
131
+ function resourceAttrs() {
132
+ return [
133
+ str("service.name", "openclaw"),
134
+ str("service.version", SCOPE_VERSION),
135
+ str("host.name", hostname()),
136
+ str("host.arch", arch()),
137
+ str("os.type", platform()),
138
+ str("os.version", release())
139
+ ];
140
+ }
141
+ function str(key, value) {
184
142
  return {
185
- traceId,
186
- spanId,
187
- parentSpanId,
188
- name: "llm_request",
189
- kind: 3,
190
- startTimeUnixNano: startNs,
191
- endTimeUnixNano: endNs,
192
- attributes: stripUndef([
193
- str("span.type", "llm_request"),
194
- str("gen_ai.operation.name", "chat"),
195
- str("llm_request.context", "interaction"),
196
- int("llm_request.call_index", callIdx),
197
- str("gen_ai.system", call.provider),
198
- str("openclaw.provider", call.provider),
199
- str("gen_ai.request.model", call.requestModel),
200
- str("model", call.requestModel),
201
- call.responseModel ? str("gen_ai.response.model", call.responseModel) : void 0,
202
- call.resolvedRef ? str("openclaw.resolved.ref", call.resolvedRef) : void 0,
203
- run.sessionId ? str("session.id", run.sessionId) : void 0,
204
- run.sessionKey ? str("openclaw.session.key", run.sessionKey) : void 0,
205
- str("openclaw.run.id", call.runId),
206
- call.agentId ? str("openclaw.agent.id", call.agentId) : void 0,
207
- call.agentId ? str("openclaw.agent.name", call.agentId) : void 0,
208
- call.usage?.input !== void 0 ? int("gen_ai.usage.input_tokens", call.usage.input) : void 0,
209
- call.usage?.input !== void 0 ? int("input_tokens", call.usage.input) : void 0,
210
- call.usage?.output !== void 0 ? int("gen_ai.usage.output_tokens", call.usage.output) : void 0,
211
- call.usage?.output !== void 0 ? int("output_tokens", call.usage.output) : void 0,
212
- call.usage?.cacheRead !== void 0 ? int("gen_ai.usage.cache_read_input_tokens", call.usage.cacheRead) : void 0,
213
- call.usage?.cacheRead !== void 0 ? int("cache_read_tokens", call.usage.cacheRead) : void 0,
214
- call.usage?.cacheWrite !== void 0 ? int("gen_ai.usage.cache_creation_input_tokens", call.usage.cacheWrite) : void 0,
215
- call.usage?.cacheWrite !== void 0 ? int("cache_creation_tokens", call.usage.cacheWrite) : void 0,
216
- call.usage?.total !== void 0 ? int("gen_ai.usage.total_tokens", call.usage.total) : void 0,
217
- systemInstructions ? str("gen_ai.system_instructions", systemInstructions) : void 0,
218
- inputMessages ? str("gen_ai.input.messages", JSON.stringify(inputMessages)) : void 0,
219
- outputMessages ? str("gen_ai.output.messages", JSON.stringify(outputMessages)) : void 0,
220
- bool("latitude.captured.content", captureContent),
221
- int("openclaw.images.count", call.imagesCount),
222
- int("llm_request.tool_call_count", call.toolCalls.length),
223
- int("llm_request.duration_ms", durationMs(call.startMs, call.endMs)),
224
- call.error ? str("error.type", "llm_error") : void 0,
225
- call.error ? str("error.message", call.error) : void 0,
226
- str("success", call.error ? "false" : "true"),
227
- str("llm_request.captured", "true")
228
- ]),
229
- status: { code: call.error ? 2 : 1 }
143
+ key,
144
+ value: { stringValue: value }
230
145
  };
231
146
  }
232
- function buildToolSpan(traceId, parentSpanId, spanId, tool, run, options) {
233
- const startNs = msToNs(tool.startMs);
234
- const endNs = msToNs(tool.endMs ?? tool.startMs);
235
- const isError = Boolean(tool.error);
236
- const captureContent = options.allowConversationAccess;
147
+ function int(key, value) {
237
148
  return {
238
- traceId,
239
- spanId,
240
- parentSpanId,
241
- name: `tool:${tool.toolName}`,
242
- kind: 1,
243
- startTimeUnixNano: startNs,
244
- endTimeUnixNano: endNs,
245
- attributes: stripUndef([
246
- str("span.type", "tool_execution"),
247
- str("gen_ai.operation.name", "execute_tool"),
248
- str("gen_ai.tool.name", tool.toolName),
249
- str("gen_ai.tool.call.id", tool.toolCallId),
250
- captureContent ? str("gen_ai.tool.call.arguments", safeJson(tool.params)) : void 0,
251
- captureContent && tool.result !== void 0 ? str("gen_ai.tool.call.result", safeJson(tool.result)) : void 0,
252
- bool("latitude.captured.content", captureContent),
253
- isError ? str("error.type", "tool_error") : void 0,
254
- isError ? str("error.message", tool.error ?? "") : void 0,
255
- bool("tool.is_error", isError),
256
- str("success", isError ? "false" : "true"),
257
- tool.durationMs !== void 0 ? int("tool.duration_ms", tool.durationMs) : void 0,
258
- run.sessionId ? str("session.id", run.sessionId) : void 0,
259
- run.sessionKey ? str("openclaw.session.key", run.sessionKey) : void 0,
260
- str("openclaw.run.id", run.runId),
261
- tool.agentId ? str("openclaw.agent.id", tool.agentId) : void 0,
262
- tool.agentId ? str("openclaw.agent.name", tool.agentId) : void 0
263
- ]),
264
- status: { code: isError ? 2 : 1 }
149
+ key,
150
+ value: { intValue: String(Math.trunc(value)) }
265
151
  };
266
152
  }
267
- function buildInputMessages(call) {
268
- const out = [];
269
- for (const msg of call.historyMessages) {
270
- const normalized = normalizeHistoryMessage(msg);
271
- if (normalized) out.push(normalized);
153
+ function bool(key, value) {
154
+ return {
155
+ key,
156
+ value: { boolValue: value }
157
+ };
158
+ }
159
+ function msToNs(ms) {
160
+ return (BigInt(Math.trunc(ms)) * 1000000n).toString();
161
+ }
162
+ function safeJson$1(value) {
163
+ try {
164
+ if (typeof value === "string") return value;
165
+ return JSON.stringify(value);
166
+ } catch {
167
+ return "";
168
+ }
169
+ }
170
+ /**
171
+ * Runtime-read package version, so OTLP `scope.version` and `service.version`
172
+ * always reflect what's actually installed. Read once at module load and
173
+ * cached. Falls back to `"unknown"` if the read fails.
174
+ *
175
+ * Same import.meta.url + ../package.json pattern used by `cli.ts` for the
176
+ * `--version` flag — single source of truth in package.json.
177
+ */
178
+ function readScopeVersion() {
179
+ try {
180
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
181
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "unknown";
182
+ } catch {
183
+ return "unknown";
272
184
  }
273
- if (call.prompt.length > 0) out.push({
274
- role: "user",
275
- parts: [{
276
- type: "text",
277
- content: call.prompt
278
- }]
279
- });
280
- return out;
281
185
  }
186
+ //#endregion
187
+ //#region src/messages.ts
282
188
  const ALLOWED_ROLES = new Set([
283
189
  "system",
284
190
  "user",
285
191
  "assistant",
286
192
  "tool"
287
193
  ]);
288
- function normalizeRole(raw) {
289
- if (typeof raw !== "string") return "user";
290
- return ALLOWED_ROLES.has(raw) ? raw : "user";
291
- }
292
- function normalizeHistoryMessage(raw) {
194
+ /**
195
+ * Normalize a single message of any of the provider shapes we know about.
196
+ * Returns `undefined` for non-objects so the caller can skip them.
197
+ */
198
+ function normalizeMessage(raw) {
293
199
  if (!raw || typeof raw !== "object") return void 0;
294
200
  const obj = raw;
295
- const role = normalizeRole(obj.role);
201
+ const role = coerceRole(obj.role);
202
+ if (Array.isArray(obj.parts)) {
203
+ const parts = [];
204
+ for (const p of obj.parts) if (p && typeof p === "object") parts.push(p);
205
+ return {
206
+ role,
207
+ parts: parts.length > 0 ? parts : [{
208
+ type: "text",
209
+ content: safeJson(raw)
210
+ }]
211
+ };
212
+ }
296
213
  const content = obj.content ?? obj.text ?? obj.message;
297
- if (typeof content === "string") return {
214
+ if (role === "tool" && obj.tool_call_id !== void 0) return {
298
215
  role,
299
216
  parts: [{
300
- type: "text",
301
- content
217
+ type: "tool_call_response",
218
+ id: typeof obj.tool_call_id === "string" ? obj.tool_call_id : "",
219
+ response: content ?? safeJson(obj)
302
220
  }]
303
221
  };
222
+ if (typeof content === "string") {
223
+ const parts = [{
224
+ type: "text",
225
+ content
226
+ }];
227
+ appendToolCalls(parts, obj.tool_calls);
228
+ return {
229
+ role,
230
+ parts
231
+ };
232
+ }
304
233
  if (Array.isArray(content)) {
305
234
  const parts = [];
306
235
  for (const block of content) {
307
- const part = normalizeContentBlock(block);
236
+ const part = normalizeBlock(block);
308
237
  if (part) parts.push(part);
309
238
  }
239
+ appendToolCalls(parts, obj.tool_calls);
240
+ if (parts.length === 0) parts.push({
241
+ type: "text",
242
+ content: safeJson(content)
243
+ });
310
244
  return {
311
245
  role,
312
- parts: parts.length > 0 ? parts : [{
313
- type: "text",
314
- content: JSON.stringify(content)
315
- }]
246
+ parts
316
247
  };
317
248
  }
318
249
  return {
@@ -323,13 +254,76 @@ function normalizeHistoryMessage(raw) {
323
254
  }]
324
255
  };
325
256
  }
326
- function normalizeContentBlock(raw) {
257
+ /** Normalize an array of provider messages. */
258
+ function normalizeMessages(raw) {
259
+ const out = [];
260
+ for (const m of raw) {
261
+ const norm = normalizeMessage(m);
262
+ if (norm) out.push(norm);
263
+ }
264
+ return out;
265
+ }
266
+ /** Build a single user message from a string prompt. */
267
+ function userMessageFromPrompt(prompt) {
268
+ return {
269
+ role: "user",
270
+ parts: [{
271
+ type: "text",
272
+ content: prompt
273
+ }]
274
+ };
275
+ }
276
+ /** Build a single assistant message from `assistantTexts` + `lastAssistant` fallback. */
277
+ function assistantMessageFromOutput(assistantTexts, lastAssistant) {
278
+ if (lastAssistant !== void 0) {
279
+ const norm = normalizeMessage(lastAssistant);
280
+ if (norm) return {
281
+ ...norm,
282
+ role: "assistant"
283
+ };
284
+ }
285
+ const parts = [];
286
+ for (const text of assistantTexts) if (text.length > 0) parts.push({
287
+ type: "text",
288
+ content: text
289
+ });
290
+ if (parts.length === 0) parts.push({
291
+ type: "text",
292
+ content: ""
293
+ });
294
+ return {
295
+ role: "assistant",
296
+ parts
297
+ };
298
+ }
299
+ /**
300
+ * Wrap a system prompt string into the parts-array shape expected for
301
+ * `gen_ai.system_instructions`. Empty string in → single empty text part out
302
+ * (still a valid array, never `undefined`).
303
+ */
304
+ function systemInstructionsParts(prompt) {
305
+ return [{
306
+ type: "text",
307
+ content: prompt
308
+ }];
309
+ }
310
+ function coerceRole(raw) {
311
+ if (typeof raw !== "string") return "user";
312
+ return ALLOWED_ROLES.has(raw) ? raw : "user";
313
+ }
314
+ function normalizeBlock(raw) {
327
315
  if (typeof raw === "string") return {
328
316
  type: "text",
329
317
  content: raw
330
318
  };
331
319
  if (!raw || typeof raw !== "object") return void 0;
332
320
  const obj = raw;
321
+ if (typeof obj.type === "string" && (typeof obj.content === "string" || obj.content === void 0)) {
322
+ if (obj.type === "text" && typeof obj.content === "string") return {
323
+ type: "text",
324
+ content: obj.content
325
+ };
326
+ }
333
327
  const type = typeof obj.type === "string" ? obj.type : "text";
334
328
  if (type === "text" && typeof obj.text === "string") return {
335
329
  type: "text",
@@ -341,104 +335,66 @@ function normalizeContentBlock(raw) {
341
335
  name: typeof obj.name === "string" ? obj.name : "",
342
336
  arguments: obj.input ?? {}
343
337
  };
338
+ if (type === "tool_call") return {
339
+ type: "tool_call",
340
+ id: typeof obj.id === "string" ? obj.id : "",
341
+ name: typeof obj.name === "string" ? obj.name : "",
342
+ arguments: obj.arguments ?? obj.input ?? {}
343
+ };
344
344
  if (type === "tool_result") return {
345
345
  type: "tool_call_response",
346
346
  id: typeof obj.tool_use_id === "string" ? obj.tool_use_id : "",
347
347
  response: obj.content ?? ""
348
348
  };
349
- if (type === "image") return {
350
- type: "uri",
351
- modality: "image",
352
- uri: safeJson(obj.source ?? obj)
353
- };
354
- return {
355
- type,
356
- content: safeJson(raw)
349
+ if (type === "tool_call_response") return {
350
+ type: "tool_call_response",
351
+ id: typeof obj.id === "string" ? obj.id : "",
352
+ response: obj.response ?? ""
357
353
  };
358
- }
359
- function buildOutputMessages(call) {
360
- const parts = [];
361
- for (const text of call.assistantTexts) if (text.length > 0) parts.push({
362
- type: "text",
363
- content: text
364
- });
365
- for (const tool of call.toolCalls) parts.push({
366
- type: "tool_call",
367
- id: tool.toolCallId,
368
- name: tool.toolName,
369
- arguments: tool.params
370
- });
371
- if (parts.length === 0 && call.lastAssistant !== void 0) parts.push({
372
- type: "text",
373
- content: safeJson(call.lastAssistant)
374
- });
375
- return [{
376
- role: "assistant",
377
- parts
378
- }];
379
- }
380
- function aggregateUsage(calls) {
381
- const agg = {
382
- input: void 0,
383
- output: void 0,
384
- cacheRead: void 0,
385
- cacheWrite: void 0,
386
- total: void 0
354
+ if (type === "thinking" && typeof obj.thinking === "string") return {
355
+ type: "reasoning",
356
+ content: obj.thinking
387
357
  };
388
- const add = (k, v) => {
389
- if (v === void 0) return;
390
- agg[k] = (agg[k] ?? 0) + v;
358
+ if (type === "reasoning" && typeof obj.content === "string") return {
359
+ type: "reasoning",
360
+ content: obj.content
391
361
  };
392
- for (const c of calls) {
393
- if (!c.usage) continue;
394
- add("input", c.usage.input);
395
- add("output", c.usage.output);
396
- add("cacheRead", c.usage.cacheRead);
397
- add("cacheWrite", c.usage.cacheWrite);
398
- add("total", c.usage.total);
362
+ if (type === "image" && obj.source && typeof obj.source === "object") {
363
+ const src = obj.source;
364
+ const uri = src.url ?? (src.data ? `data:${src.media_type ?? "image/unknown"};base64,${src.data}` : "");
365
+ if (uri) return {
366
+ type: "uri",
367
+ modality: "image",
368
+ uri
369
+ };
399
370
  }
400
- return agg;
401
- }
402
- function resourceAttrs() {
403
- return [
404
- str("service.name", "openclaw"),
405
- str("service.version", SCOPE_VERSION),
406
- str("host.name", hostname()),
407
- str("host.arch", arch()),
408
- str("os.type", platform()),
409
- str("os.version", release())
410
- ];
411
- }
412
- function str(key, value) {
413
- return {
414
- key,
415
- value: { stringValue: value }
416
- };
417
- }
418
- function int(key, value) {
419
- return {
420
- key,
421
- value: { intValue: String(Math.trunc(value)) }
422
- };
423
- }
424
- function bool(key, value) {
425
371
  return {
426
- key,
427
- value: { boolValue: value }
372
+ type,
373
+ content: safeJson(raw)
428
374
  };
429
375
  }
430
- function stripUndef(items) {
431
- return items.filter((x) => x !== void 0);
432
- }
433
- function hashHex(input, length) {
434
- return createHash("sha256").update(input).digest("hex").slice(0, length);
435
- }
436
- function msToNs(ms) {
437
- return (BigInt(Math.trunc(ms)) * 1000000n).toString();
438
- }
439
- function durationMs(startMs, endMs) {
440
- if (endMs === void 0) return 0;
441
- return Math.max(0, endMs - startMs);
376
+ /**
377
+ * OpenAI assistant messages put tool calls in a separate `tool_calls` array
378
+ * alongside string content. Append them as parts so the trace shows what the
379
+ * model emitted in that turn.
380
+ */
381
+ function appendToolCalls(parts, raw) {
382
+ if (!Array.isArray(raw)) return;
383
+ for (const tc of raw) {
384
+ if (!tc || typeof tc !== "object") continue;
385
+ const t = tc;
386
+ const fn = t.function;
387
+ let parsedArgs = fn?.arguments;
388
+ if (typeof parsedArgs === "string") try {
389
+ parsedArgs = JSON.parse(parsedArgs);
390
+ } catch {}
391
+ parts.push({
392
+ type: "tool_call",
393
+ id: typeof t.id === "string" ? t.id : "",
394
+ name: fn?.name ?? "",
395
+ arguments: parsedArgs ?? {}
396
+ });
397
+ }
442
398
  }
443
399
  function safeJson(value) {
444
400
  try {
@@ -449,179 +405,484 @@ function safeJson(value) {
449
405
  }
450
406
  }
451
407
  //#endregion
452
- //#region src/turn-builder.ts
453
- /**
454
- * Accumulates OpenClaw hook events per agent run (keyed by `runId`) into a
455
- * `RunRecord` ready to be converted to OTLP spans. All mutation is synchronous
456
- * and non-blocking so the hook runner can stay fire-and-forget.
457
- *
458
- * Event ordering assumptions (verified against OpenClaw
459
- * src/agents/pi-embedded-runner/run/attempt.ts):
460
- *
461
- * session_start? -> [ llm_input -> (before_tool_call -> after_tool_call)* -> llm_output ]+ -> agent_end
462
- *
463
- * Tool calls arriving between an `llm_input` and its `llm_output` are attached
464
- * to the currently-open LLM call. Tools arriving outside that window (e.g.
465
- * `after_tool_call` fires after `llm_output` has already closed the call) are
466
- * stored on the run's `orphanTools` list so we don't drop them.
467
- */
468
- var TurnBuilder = class {
408
+ //#region src/span-builder.ts
409
+ const SUBAGENT_LINK_TTL_MS = 3600 * 1e3;
410
+ const SUBAGENT_LINK_MAX = 1e3;
411
+ var SpanBuilder = class {
469
412
  runs = /* @__PURE__ */ new Map();
470
- onSessionStart(_evt, _ctx) {}
413
+ subagentLinks = /* @__PURE__ */ new Map();
414
+ inflightCount() {
415
+ return this.runs.size;
416
+ }
417
+ /**
418
+ * Open the root `agent` span. If the runId was previously registered as a
419
+ * subagent's child, propagate the parent's traceId and parent the new span
420
+ * under the parent's `subagent` span — so the entire subagent's work nests
421
+ * inside the parent's trace as one waterfall.
422
+ */
423
+ onBeforeAgentStart(evt, ctx) {
424
+ const runId = ctx.runId;
425
+ if (!runId) return;
426
+ if (this.runs.has(runId)) return;
427
+ const link = this.subagentLinks.get(runId);
428
+ const traceId = link?.traceId ?? hashHex(runId, 32);
429
+ const parentSpanId = link?.subagentSpanId ?? "";
430
+ const agent = {
431
+ spanId: hashHex(`${traceId}:${runId}:agent`, 16),
432
+ traceId,
433
+ parentSpanId,
434
+ name: "agent",
435
+ startMs: Date.now(),
436
+ endMs: void 0,
437
+ attrs: {
438
+ ...flattenCtx(ctx),
439
+ ...latitudeAttrs(ctx),
440
+ "openclaw.run.id": runId,
441
+ "before_agent_start.prompt:gated": evt.prompt,
442
+ "before_agent_start.messages:gated": evt.messages ? normalizeMessages(evt.messages) : void 0
443
+ }
444
+ };
445
+ this.runs.set(runId, {
446
+ agent,
447
+ history: [],
448
+ openModelCalls: /* @__PURE__ */ new Map(),
449
+ openToolCalls: /* @__PURE__ */ new Map(),
450
+ openCompaction: void 0,
451
+ closed: [],
452
+ childSubagentSpans: /* @__PURE__ */ new Map()
453
+ });
454
+ }
455
+ /**
456
+ * Enrich the open `agent` span with content + identity from the LLM input.
457
+ * Also seeds the rolling history snapshot used by per-call `model_call`
458
+ * input attributes.
459
+ *
460
+ * Provider-specific message shapes get normalized into the parts-based
461
+ * GenAI format here — that's the contract Latitude's downstream parser
462
+ * expects on `gen_ai.input.messages` and `gen_ai.system_instructions`.
463
+ */
471
464
  onLlmInput(evt, ctx) {
472
- const run = this.ensureRun(evt.runId, ctx);
473
- const call = {
474
- runId: evt.runId,
475
- sessionId: evt.sessionId,
476
- sessionKey: ctx.sessionKey,
477
- agentId: ctx.agentId,
478
- provider: evt.provider,
479
- requestModel: evt.model,
480
- responseModel: void 0,
481
- resolvedRef: void 0,
482
- systemPrompt: evt.systemPrompt,
483
- prompt: evt.prompt,
484
- historyMessages: evt.historyMessages,
485
- imagesCount: evt.imagesCount,
486
- assistantTexts: [],
487
- lastAssistant: void 0,
488
- usage: void 0,
465
+ const run = this.runs.get(ctx.runId ?? evt.runId);
466
+ if (!run) return;
467
+ const inputMessages = [...normalizeMessages(evt.historyMessages)];
468
+ if (evt.prompt) inputMessages.push(userMessageFromPrompt(evt.prompt));
469
+ Object.assign(run.agent.attrs, {
470
+ "gen_ai.system_instructions:gated": evt.systemPrompt ? systemInstructionsParts(evt.systemPrompt) : void 0,
471
+ "user_prompt:gated": evt.prompt,
472
+ "gen_ai.input.messages:gated": inputMessages,
473
+ "openclaw.images.count": evt.imagesCount,
474
+ "gen_ai.request.model": evt.model,
475
+ "gen_ai.system": evt.provider,
476
+ "openclaw.provider": evt.provider
477
+ });
478
+ run.history = inputMessages;
479
+ }
480
+ /**
481
+ * Enrich the agent span with attempt-aggregate output + token usage.
482
+ * (Per-call usage isn't surfaced by OpenClaw today — see PR #2986.)
483
+ */
484
+ onLlmOutput(evt, ctx) {
485
+ const run = this.runs.get(ctx.runId ?? evt.runId);
486
+ if (!run) return;
487
+ const assistantMessage = assistantMessageFromOutput(evt.assistantTexts, evt.lastAssistant);
488
+ Object.assign(run.agent.attrs, {
489
+ "gen_ai.output.messages:gated": [assistantMessage],
490
+ "openclaw.resolved.ref": evt.resolvedRef,
491
+ "openclaw.harness.id": evt.harnessId,
492
+ "gen_ai.response.model": evt.model,
493
+ ...usageAttrs(evt.usage)
494
+ });
495
+ }
496
+ onModelCallStarted(evt, ctx) {
497
+ const run = this.runs.get(evt.runId);
498
+ if (!run) return;
499
+ const span = {
500
+ spanId: hashHex(`${run.agent.traceId}:model_call:${evt.callId}`, 16),
501
+ traceId: run.agent.traceId,
502
+ parentSpanId: run.agent.spanId,
503
+ name: "model_call",
489
504
  startMs: Date.now(),
490
505
  endMs: void 0,
491
- error: void 0,
492
- toolCalls: []
506
+ attrs: {
507
+ ...latitudeAttrs(ctx),
508
+ "openclaw.run.id": evt.runId,
509
+ "openclaw.call.id": evt.callId,
510
+ "gen_ai.system": evt.provider,
511
+ "openclaw.provider": evt.provider,
512
+ "gen_ai.request.model": evt.model,
513
+ "openclaw.api": evt.api,
514
+ "openclaw.transport": evt.transport,
515
+ "gen_ai.input.messages:gated": [...run.history]
516
+ }
493
517
  };
494
- run.llmCalls.push(call);
495
- return call;
518
+ run.openModelCalls.set(evt.callId, span);
519
+ }
520
+ onModelCallEnded(evt, _ctx) {
521
+ const run = this.runs.get(evt.runId);
522
+ if (!run) return;
523
+ const span = run.openModelCalls.get(evt.callId);
524
+ if (!span) return;
525
+ span.endMs = Date.now();
526
+ span.outcome = evt.outcome === "completed" ? "ok" : "error";
527
+ span.errorMessage = evt.errorCategory;
528
+ Object.assign(span.attrs, {
529
+ "openclaw.duration_ms": evt.durationMs,
530
+ "openclaw.outcome": evt.outcome,
531
+ "openclaw.error.category": evt.errorCategory,
532
+ "openclaw.failure.kind": evt.failureKind,
533
+ "openclaw.request.payload_bytes": evt.requestPayloadBytes,
534
+ "openclaw.response.stream_bytes": evt.responseStreamBytes,
535
+ "openclaw.ttfb_ms": evt.timeToFirstByteMs,
536
+ "openclaw.upstream.request_id_hash": evt.upstreamRequestIdHash
537
+ });
538
+ run.openModelCalls.delete(evt.callId);
539
+ run.closed.push(span);
496
540
  }
541
+ /**
542
+ * Open a `tool_call` span as a sibling of the agent span. Also append a
543
+ * synthetic assistant `tool_call` part to the rolling history so the NEXT
544
+ * model_call's input snapshot reflects what the model emitted.
545
+ *
546
+ * IMPORTANT: this runs as a `runModifyingHook` in OpenClaw — returning
547
+ * anything other than `undefined`/falsy from this handler blocks the tool.
548
+ * The plugin-side handler enforces a void return; this method's signature
549
+ * already returns `void`.
550
+ */
497
551
  onBeforeToolCall(evt, ctx) {
498
552
  if (!evt.runId) return;
499
- const run = this.runs.get(evt.runId) ?? this.ensureRun(evt.runId, ctx);
500
- const tool = {
501
- toolCallId: evt.toolCallId ?? `${evt.toolName}:${randomUUID()}`,
502
- toolName: evt.toolName,
503
- params: evt.params,
504
- result: void 0,
505
- error: void 0,
553
+ const run = this.runs.get(evt.runId);
554
+ if (!run) return;
555
+ const toolCallId = evt.toolCallId ?? `${evt.toolName}:${randomUUID()}`;
556
+ const span = {
557
+ spanId: hashHex(`${run.agent.traceId}:tool_call:${toolCallId}`, 16),
558
+ traceId: run.agent.traceId,
559
+ parentSpanId: run.agent.spanId,
560
+ name: `tool_call:${evt.toolName}`,
506
561
  startMs: Date.now(),
507
562
  endMs: void 0,
508
- durationMs: void 0,
509
- agentId: ctx.agentId
563
+ attrs: {
564
+ ...latitudeAttrs(ctx),
565
+ "openclaw.run.id": evt.runId,
566
+ "gen_ai.tool.name": evt.toolName,
567
+ "gen_ai.tool.call.id": toolCallId,
568
+ "gen_ai.tool.call.arguments:gated": evt.params
569
+ }
510
570
  };
511
- const openCall = this.currentOpenCall(run);
512
- if (openCall) openCall.toolCalls.push(tool);
513
- else run.orphanTools.push(tool);
571
+ run.openToolCalls.set(toolCallId, span);
572
+ run.history.push({
573
+ role: "assistant",
574
+ parts: [{
575
+ type: "tool_call",
576
+ id: toolCallId,
577
+ name: evt.toolName,
578
+ arguments: evt.params
579
+ }]
580
+ });
514
581
  }
515
582
  onAfterToolCall(evt, _ctx) {
516
583
  if (!evt.runId) return;
517
584
  const run = this.runs.get(evt.runId);
518
585
  if (!run) return;
519
- const tool = this.findToolRecord(run, evt.toolCallId, evt.toolName);
520
- if (!tool) return;
521
- tool.result = evt.result;
522
- tool.error = evt.error;
523
- tool.durationMs = evt.durationMs;
524
- tool.endMs = Date.now();
586
+ let resolvedId = evt.toolCallId && run.openToolCalls.has(evt.toolCallId) ? evt.toolCallId : void 0;
587
+ if (!resolvedId) resolvedId = this.findOpenToolCallByName(run, evt.toolName);
588
+ if (!resolvedId) return;
589
+ const span = run.openToolCalls.get(resolvedId);
590
+ if (!span) return;
591
+ const toolCallId = resolvedId;
592
+ span.endMs = Date.now();
593
+ span.outcome = Boolean(evt.error) ? "error" : "ok";
594
+ span.errorMessage = evt.error;
595
+ Object.assign(span.attrs, {
596
+ "gen_ai.tool.call.result:gated": evt.result,
597
+ "openclaw.error.message:gated": evt.error,
598
+ "openclaw.duration_ms": evt.durationMs
599
+ });
600
+ run.openToolCalls.delete(toolCallId);
601
+ run.closed.push(span);
602
+ run.history.push({
603
+ role: "tool",
604
+ parts: [{
605
+ type: "tool_call_response",
606
+ id: toolCallId,
607
+ response: evt.result ?? evt.error ?? ""
608
+ }]
609
+ });
610
+ }
611
+ onBeforeCompaction(evt, ctx) {
612
+ const runId = ctx.runId;
613
+ if (!runId) return;
614
+ const run = this.runs.get(runId);
615
+ if (!run) return;
616
+ run.openCompaction = {
617
+ spanId: hashHex(`${run.agent.traceId}:compaction:${run.closed.length}`, 16),
618
+ traceId: run.agent.traceId,
619
+ parentSpanId: run.agent.spanId,
620
+ name: "compaction",
621
+ startMs: Date.now(),
622
+ endMs: void 0,
623
+ attrs: {
624
+ ...latitudeAttrs(ctx),
625
+ "openclaw.run.id": runId,
626
+ "openclaw.compaction.message_count.before": evt.messageCount,
627
+ "openclaw.compaction.session_file": evt.sessionFile,
628
+ "before_compaction.messages:gated": evt.messages ? normalizeMessages(evt.messages) : void 0
629
+ }
630
+ };
525
631
  }
526
- onLlmOutput(evt, _ctx) {
527
- const run = this.runs.get(evt.runId);
528
- if (!run) return void 0;
529
- const openCall = this.currentOpenCall(run);
530
- if (!openCall) return void 0;
531
- openCall.endMs = Date.now();
532
- openCall.assistantTexts = evt.assistantTexts;
533
- openCall.lastAssistant = evt.lastAssistant;
534
- openCall.usage = evt.usage;
535
- openCall.responseModel = evt.model;
536
- openCall.resolvedRef = evt.resolvedRef;
537
- return openCall;
632
+ onAfterCompaction(evt, ctx) {
633
+ const runId = ctx.runId;
634
+ if (!runId) return;
635
+ const run = this.runs.get(runId);
636
+ if (!run?.openCompaction) return;
637
+ const span = run.openCompaction;
638
+ span.endMs = Date.now();
639
+ span.outcome = "ok";
640
+ Object.assign(span.attrs, {
641
+ "openclaw.compaction.message_count.after": evt.messageCount,
642
+ "openclaw.compaction.compacted_count": evt.compactedCount,
643
+ "openclaw.compaction.token_count": evt.tokenCount
644
+ });
645
+ run.openCompaction = void 0;
646
+ run.closed.push(span);
647
+ }
648
+ /**
649
+ * Open a `subagent` span on the parent run AND register the cross-run link
650
+ * so the child's `before_agent_start` can find us.
651
+ */
652
+ onSubagentSpawned(evt, ctx) {
653
+ const parentRunId = ctx.runId;
654
+ if (!parentRunId) return;
655
+ const parent = this.runs.get(parentRunId);
656
+ if (!parent) return;
657
+ const span = {
658
+ spanId: hashHex(`${parent.agent.traceId}:subagent:${evt.runId}`, 16),
659
+ traceId: parent.agent.traceId,
660
+ parentSpanId: parent.agent.spanId,
661
+ name: "subagent",
662
+ startMs: Date.now(),
663
+ endMs: void 0,
664
+ attrs: {
665
+ ...latitudeAttrs(ctx),
666
+ "openclaw.parent.run.id": parentRunId,
667
+ "openclaw.run.id": evt.runId,
668
+ "openclaw.subagent.child_session_key": evt.childSessionKey,
669
+ "openclaw.subagent.agent_id": evt.agentId,
670
+ "openclaw.subagent.label": evt.label,
671
+ "openclaw.subagent.mode": evt.mode,
672
+ "openclaw.subagent.thread_requested": evt.threadRequested,
673
+ "openclaw.subagent.requester.channel": evt.requester?.channel,
674
+ "openclaw.subagent.requester.account_id": evt.requester?.accountId,
675
+ "openclaw.subagent.requester.to": evt.requester?.to,
676
+ "openclaw.subagent.requester.thread_id": typeof evt.requester?.threadId === "string" || typeof evt.requester?.threadId === "number" ? String(evt.requester.threadId) : void 0
677
+ }
678
+ };
679
+ parent.childSubagentSpans.set(evt.runId, span);
680
+ this.evictStaleSubagentLinks();
681
+ this.subagentLinks.set(evt.runId, {
682
+ traceId: parent.agent.traceId,
683
+ subagentSpanId: span.spanId,
684
+ createdAt: Date.now()
685
+ });
686
+ }
687
+ onSubagentEnded(evt, ctx) {
688
+ const parentRunId = ctx.runId;
689
+ if (!parentRunId) return;
690
+ const parent = this.runs.get(parentRunId);
691
+ if (!parent) return;
692
+ const childRunId = evt.runId;
693
+ if (!childRunId) return;
694
+ const span = parent.childSubagentSpans.get(childRunId);
695
+ if (!span) return;
696
+ span.endMs = Date.now();
697
+ span.outcome = evt.outcome === "error" || Boolean(evt.error) ? "error" : "ok";
698
+ span.errorMessage = evt.error;
699
+ Object.assign(span.attrs, {
700
+ "openclaw.subagent.target_session_key": evt.targetSessionKey,
701
+ "openclaw.subagent.target_kind": evt.targetKind,
702
+ "openclaw.subagent.reason": evt.reason,
703
+ "openclaw.subagent.outcome": evt.outcome,
704
+ "openclaw.subagent.send_farewell": evt.sendFarewell,
705
+ "openclaw.subagent.account_id": evt.accountId
706
+ });
707
+ parent.childSubagentSpans.delete(childRunId);
708
+ parent.closed.push(span);
538
709
  }
710
+ /**
711
+ * Close out the run: finish the agent span, abandon any still-open
712
+ * model_calls / tool_calls / compactions, and return everything ready to
713
+ * emit. Removes the subagent link if this was a child run.
714
+ */
539
715
  onAgentEnd(evt, ctx) {
540
716
  const runId = ctx.runId;
541
717
  if (!runId) return void 0;
542
718
  const run = this.runs.get(runId);
543
- if (!run) return void 0;
544
- run.endMs = Date.now();
545
- run.success = evt.success;
546
- run.error = evt.error;
547
- for (const call of run.llmCalls) {
548
- if (call.endMs === void 0) {
549
- call.endMs = run.endMs;
550
- if (evt.error && call.error === void 0) call.error = evt.error;
551
- }
552
- for (const tool of call.toolCalls) if (tool.endMs === void 0) tool.endMs = run.endMs;
719
+ if (!run) {
720
+ this.subagentLinks.delete(runId);
721
+ return;
722
+ }
723
+ const now = Date.now();
724
+ run.agent.endMs = now;
725
+ run.agent.outcome = evt.success ? "ok" : "error";
726
+ run.agent.errorMessage = evt.error;
727
+ Object.assign(run.agent.attrs, {
728
+ "openclaw.duration_ms": evt.durationMs,
729
+ "openclaw.run.success": evt.success,
730
+ "openclaw.error.message:gated": evt.error,
731
+ "agent_end.messages:gated": normalizeMessages(evt.messages)
732
+ });
733
+ for (const span of run.openModelCalls.values()) {
734
+ span.endMs = now;
735
+ span.outcome = "error";
736
+ span.attrs["openclaw.outcome"] = "abandoned";
737
+ run.closed.push(span);
738
+ }
739
+ for (const span of run.openToolCalls.values()) {
740
+ span.endMs = now;
741
+ span.outcome = "error";
742
+ span.attrs["openclaw.outcome"] = "abandoned";
743
+ run.closed.push(span);
744
+ }
745
+ if (run.openCompaction) {
746
+ run.openCompaction.endMs = now;
747
+ run.openCompaction.outcome = "error";
748
+ run.openCompaction.attrs["openclaw.outcome"] = "abandoned";
749
+ run.closed.push(run.openCompaction);
553
750
  }
554
- for (const tool of run.orphanTools) if (tool.endMs === void 0) tool.endMs = run.endMs;
751
+ for (const span of run.childSubagentSpans.values()) {
752
+ span.endMs = now;
753
+ span.outcome = "error";
754
+ span.attrs["openclaw.subagent.outcome"] = "abandoned";
755
+ run.closed.push(span);
756
+ }
757
+ const spans = [run.agent, ...run.closed];
555
758
  this.runs.delete(runId);
556
- return run;
759
+ this.subagentLinks.delete(runId);
760
+ return {
761
+ runId,
762
+ spans
763
+ };
557
764
  }
558
765
  /** Drop a run without emitting — used on errors from the emit path. */
559
766
  abandon(runId) {
560
767
  this.runs.delete(runId);
768
+ this.subagentLinks.delete(runId);
561
769
  }
562
- /** Active runs count, for debug logging. */
563
- inflightCount() {
564
- return this.runs.size;
565
- }
566
- ensureRun(runId, ctx) {
567
- let run = this.runs.get(runId);
568
- if (run) return run;
569
- run = {
570
- runId,
571
- sessionId: ctx.sessionId,
572
- sessionKey: ctx.sessionKey,
573
- agentId: ctx.agentId,
574
- workspaceDir: ctx.workspaceDir,
575
- messageProvider: ctx.messageProvider,
576
- trigger: ctx.trigger,
577
- channelId: ctx.channelId,
578
- modelProviderId: ctx.modelProviderId,
579
- modelId: ctx.modelId,
580
- startMs: Date.now(),
581
- endMs: void 0,
582
- success: void 0,
583
- error: void 0,
584
- llmCalls: [],
585
- orphanTools: []
586
- };
587
- this.runs.set(runId, run);
588
- return run;
770
+ /** Test-only: how many cross-run subagent links we're holding. */
771
+ subagentLinkCount() {
772
+ return this.subagentLinks.size;
589
773
  }
590
- currentOpenCall(run) {
591
- for (let i = run.llmCalls.length - 1; i >= 0; i--) {
592
- const call = run.llmCalls[i];
593
- if (call && call.endMs === void 0) return call;
774
+ /**
775
+ * Drop any subagent links whose child run never reached `agent_end`. Called
776
+ * before every `subagent_spawned` insert so the map stays bounded even when
777
+ * children crash mid-spawn or the plugin reloads.
778
+ *
779
+ * Two passes: TTL eviction (anything older than `SUBAGENT_LINK_TTL_MS`),
780
+ * then a hard size cap (when we're past `SUBAGENT_LINK_MAX`, drop the
781
+ * oldest until we're under).
782
+ */
783
+ evictStaleSubagentLinks() {
784
+ const now = Date.now();
785
+ for (const [runId, link] of this.subagentLinks) if (now - link.createdAt > SUBAGENT_LINK_TTL_MS) this.subagentLinks.delete(runId);
786
+ if (this.subagentLinks.size <= SUBAGENT_LINK_MAX) return;
787
+ const sorted = Array.from(this.subagentLinks.entries()).sort((a, b) => a[1].createdAt - b[1].createdAt);
788
+ const toRemove = this.subagentLinks.size - SUBAGENT_LINK_MAX;
789
+ for (let i = 0; i < toRemove; i++) {
790
+ const entry = sorted[i];
791
+ if (entry) this.subagentLinks.delete(entry[0]);
594
792
  }
595
793
  }
596
- findToolRecord(run, toolCallId, toolName) {
597
- const matchesId = (t) => Boolean(toolCallId && t.toolCallId === toolCallId);
598
- for (const call of run.llmCalls) for (const t of call.toolCalls) if (matchesId(t)) return t;
599
- for (const t of run.orphanTools) if (matchesId(t)) return t;
600
- for (let i = run.llmCalls.length - 1; i >= 0; i--) {
601
- const call = run.llmCalls[i];
602
- if (!call) continue;
603
- for (let j = call.toolCalls.length - 1; j >= 0; j--) {
604
- const t = call.toolCalls[j];
605
- if (t && t.toolName === toolName && t.endMs === void 0) return t;
606
- }
607
- }
608
- for (let i = run.orphanTools.length - 1; i >= 0; i--) {
609
- const t = run.orphanTools[i];
610
- if (t && t.toolName === toolName && t.endMs === void 0) return t;
794
+ findOpenToolCallByName(run, toolName) {
795
+ const target = `tool_call:${toolName}`;
796
+ const entries = Array.from(run.openToolCalls.entries());
797
+ for (let i = entries.length - 1; i >= 0; i--) {
798
+ const entry = entries[i];
799
+ if (!entry) continue;
800
+ const [id, span] = entry;
801
+ if (span.name === target) return id;
611
802
  }
612
803
  }
613
804
  };
805
+ function flattenCtx(ctx) {
806
+ return {
807
+ "openclaw.run.id": ctx.runId,
808
+ "openclaw.session.id": ctx.sessionId,
809
+ "openclaw.session.key": ctx.sessionKey,
810
+ "openclaw.agent.id": ctx.agentId,
811
+ "openclaw.agent.name": ctx.agentId,
812
+ "openclaw.workspace.dir": ctx.workspaceDir,
813
+ "openclaw.message.provider": ctx.messageProvider,
814
+ "openclaw.trigger": ctx.trigger,
815
+ "openclaw.channel.id": ctx.channelId,
816
+ "openclaw.cron.job.id": ctx.jobId,
817
+ "openclaw.model.provider.id": ctx.modelProviderId,
818
+ "openclaw.model.id": ctx.modelId
819
+ };
820
+ }
821
+ /**
822
+ * Build `latitude.tags` and `latitude.metadata` attrs from the hook context.
823
+ * The OTLP encoder JSON-stringifies arrays/objects, which is the encoding
824
+ * Latitude's resolver expects:
825
+ *
826
+ * - `latitude.tags` is a JSON-encoded string array (`fromJsonStringArray`
827
+ * in domain/spans/src/otlp/resolvers/enrichment.ts).
828
+ * - `latitude.metadata` is a JSON-encoded string object (`fromJsonString`).
829
+ *
830
+ * Tags = the agent id, the channel id, and the trigger. When trigger is
831
+ * `cron`, the tag becomes `cron:<jobId>` so dashboards can pivot on the
832
+ * specific cron job. Each tag is conditionally included so absent ctx
833
+ * fields don't produce empty entries.
834
+ *
835
+ * Metadata = every ctx field that's set, namespaced under `openclaw.*` so
836
+ * it can't collide with metadata keys other plugins might emit.
837
+ */
838
+ function latitudeAttrs(ctx) {
839
+ const tags = [];
840
+ if (ctx.agentId) tags.push(ctx.agentId);
841
+ if (ctx.channelId) tags.push(ctx.channelId);
842
+ if (ctx.trigger) tags.push(ctx.trigger === "cron" && ctx.jobId ? `cron:${ctx.jobId}` : ctx.trigger);
843
+ const metadata = {};
844
+ if (ctx.runId) metadata["openclaw.run.id"] = ctx.runId;
845
+ if (ctx.sessionId) metadata["openclaw.session.id"] = ctx.sessionId;
846
+ if (ctx.sessionKey) metadata["openclaw.session.key"] = ctx.sessionKey;
847
+ if (ctx.agentId) metadata["openclaw.agent.id"] = ctx.agentId;
848
+ if (ctx.workspaceDir) metadata["openclaw.workspace.dir"] = ctx.workspaceDir;
849
+ if (ctx.channelId) metadata["openclaw.channel.id"] = ctx.channelId;
850
+ if (ctx.messageProvider) metadata["openclaw.message.provider"] = ctx.messageProvider;
851
+ if (ctx.trigger) metadata["openclaw.trigger"] = ctx.trigger;
852
+ if (ctx.jobId) metadata["openclaw.cron.job.id"] = ctx.jobId;
853
+ if (ctx.modelProviderId) metadata["openclaw.model.provider.id"] = ctx.modelProviderId;
854
+ if (ctx.modelId) metadata["openclaw.model.id"] = ctx.modelId;
855
+ return {
856
+ "latitude.tags": tags.length > 0 ? tags : void 0,
857
+ "latitude.metadata": Object.keys(metadata).length > 0 ? metadata : void 0
858
+ };
859
+ }
860
+ function usageAttrs(usage) {
861
+ if (!usage) return {};
862
+ return {
863
+ "gen_ai.usage.input_tokens": usage.input,
864
+ "gen_ai.usage.output_tokens": usage.output,
865
+ "gen_ai.usage.cache_read_input_tokens": usage.cacheRead,
866
+ "gen_ai.usage.cache_creation_input_tokens": usage.cacheWrite,
867
+ "gen_ai.usage.total_tokens": usage.total
868
+ };
869
+ }
870
+ function hashHex(input, length) {
871
+ return createHash("sha256").update(input).digest("hex").slice(0, length);
872
+ }
614
873
  //#endregion
615
874
  //#region src/plugin.ts
616
875
  /**
617
876
  * Register the Latitude plugin against an OpenClaw plugin API. OpenClaw calls
618
- * this once at plugin activation; we wire up `llm_input`, `llm_output`, tool
619
- * and lifecycle hooks to stream traces to Latitude.
877
+ * this once at plugin activation; we wire up the granular paired hooks
878
+ * (model_call_started/_ended, before_/after_tool_call, before_/after_compaction,
879
+ * subagent_spawned/_ended, before_agent_start/agent_end) plus the
880
+ * data-only feeds (llm_input/llm_output) that enrich the agent span.
620
881
  *
621
- * Every handler is fire-and-forget on OpenClaw's side (see
622
- * `src/plugins/hooks.ts` runLlmInput/runLlmOutput are documented as
623
- * parallel and wrapped with `.catch()` at the call site in attempt.ts), so
624
- * nothing we do here can slow the agent loop.
882
+ * Every typed hook on OpenClaw's side fires fire-and-forget for non-modifying
883
+ * hooks; before_tool_call is a `runModifyingHook` where returning anything
884
+ * other than undefined blocks the tool call. Our handler returns nothing —
885
+ * keep it that way.
625
886
  */
626
887
  function registerLatitudePlugin(api, opts = {}) {
627
888
  const config = opts.config ?? loadConfig(api.pluginConfig);
@@ -632,58 +893,65 @@ function registerLatitudePlugin(api, opts = {}) {
632
893
  return;
633
894
  }
634
895
  logger.debug(`enabled: project=${config.project} base=${config.baseUrl} allowConversationAccess=${config.allowConversationAccess}`);
635
- const builder = new TurnBuilder();
636
- api.on("session_start", (evt, ctx) => {
637
- builder.onSessionStart(evt, ctx);
638
- });
639
- api.on("llm_input", (evt, ctx) => {
640
- try {
641
- builder.onLlmInput(evt, ctx);
642
- } catch (err) {
643
- logger.warn(`llm_input handler failed: ${String(err)}`);
644
- }
645
- });
646
- api.on("before_tool_call", (evt, ctx) => {
647
- try {
648
- builder.onBeforeToolCall(evt, ctx);
649
- } catch (err) {
650
- logger.warn(`before_tool_call handler failed: ${String(err)}`);
651
- }
652
- });
653
- api.on("after_tool_call", (evt, ctx) => {
654
- try {
655
- builder.onAfterToolCall(evt, ctx);
656
- } catch (err) {
657
- logger.warn(`after_tool_call handler failed: ${String(err)}`);
658
- }
659
- });
660
- api.on("llm_output", (evt, ctx) => {
661
- try {
662
- builder.onLlmOutput(evt, ctx);
663
- } catch (err) {
664
- logger.warn(`llm_output handler failed: ${String(err)}`);
665
- }
666
- });
667
- api.on("agent_end", (evt, ctx) => {
668
- try {
669
- const run = builder.onAgentEnd(evt, ctx);
670
- if (!run) {
671
- logger.debug("agent_end fired without a matching run in flight");
672
- return;
896
+ const builder = new SpanBuilder();
897
+ const wrap = (name, fn) => {
898
+ return (evt, ctx) => {
899
+ try {
900
+ fn(evt, ctx);
901
+ } catch (err) {
902
+ logger.warn(`${name} handler failed: ${String(err)}`);
673
903
  }
674
- opts.onEmit?.(run);
675
- const payload = buildOtlpRequest(run, { allowConversationAccess: config.allowConversationAccess });
676
- postTraces({
677
- baseUrl: config.baseUrl,
678
- apiKey: config.apiKey,
679
- project: config.project,
680
- payload,
681
- logger
682
- });
683
- } catch (err) {
684
- logger.warn(`agent_end handler failed: ${String(err)}`);
904
+ };
905
+ };
906
+ api.on("before_agent_start", wrap("before_agent_start", (evt, ctx) => {
907
+ builder.onBeforeAgentStart(evt, ctx);
908
+ }));
909
+ api.on("model_call_started", wrap("model_call_started", (evt, ctx) => {
910
+ builder.onModelCallStarted(evt, ctx);
911
+ }));
912
+ api.on("model_call_ended", wrap("model_call_ended", (evt, ctx) => {
913
+ builder.onModelCallEnded(evt, ctx);
914
+ }));
915
+ api.on("before_tool_call", wrap("before_tool_call", (evt, ctx) => {
916
+ builder.onBeforeToolCall(evt, ctx);
917
+ }));
918
+ api.on("after_tool_call", wrap("after_tool_call", (evt, ctx) => {
919
+ builder.onAfterToolCall(evt, ctx);
920
+ }));
921
+ api.on("before_compaction", wrap("before_compaction", (evt, ctx) => {
922
+ builder.onBeforeCompaction(evt, ctx);
923
+ }));
924
+ api.on("after_compaction", wrap("after_compaction", (evt, ctx) => {
925
+ builder.onAfterCompaction(evt, ctx);
926
+ }));
927
+ api.on("subagent_spawned", wrap("subagent_spawned", (evt, ctx) => {
928
+ builder.onSubagentSpawned(evt, ctx);
929
+ }));
930
+ api.on("subagent_ended", wrap("subagent_ended", (evt, ctx) => {
931
+ builder.onSubagentEnded(evt, ctx);
932
+ }));
933
+ api.on("llm_input", wrap("llm_input", (evt, ctx) => {
934
+ builder.onLlmInput(evt, ctx);
935
+ }));
936
+ api.on("llm_output", wrap("llm_output", (evt, ctx) => {
937
+ builder.onLlmOutput(evt, ctx);
938
+ }));
939
+ api.on("agent_end", wrap("agent_end", (evt, ctx) => {
940
+ const result = builder.onAgentEnd(evt, ctx);
941
+ if (!result) {
942
+ logger.debug("agent_end fired without a matching run in flight");
943
+ return;
685
944
  }
686
- });
945
+ opts.onEmit?.(result);
946
+ const payload = buildOtlpRequest(result, { allowConversationAccess: config.allowConversationAccess });
947
+ postTraces({
948
+ baseUrl: config.baseUrl,
949
+ apiKey: config.apiKey,
950
+ project: config.project,
951
+ payload,
952
+ logger
953
+ });
954
+ }));
687
955
  }
688
956
  //#endregion
689
957
  export { registerLatitudePlugin as default };