@latitude-data/openclaw-telemetry 0.0.3 → 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,5 +1,8 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { readFileSync } from "node:fs";
2
2
  import { arch, hostname, platform, release } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createHash, randomUUID } from "node:crypto";
3
6
  //#region src/client.ts
4
7
  async function postTraces({ baseUrl, apiKey, project, payload, logger, timeoutMs = 1e4 }) {
5
8
  const url = `${baseUrl.replace(/\/+$/, "")}/v1/traces`;
@@ -74,10 +77,10 @@ function createLogger(debugEnabled) {
74
77
  //#endregion
75
78
  //#region src/otlp.ts
76
79
  const SCOPE_NAME = "@latitude-data/openclaw-telemetry";
77
- const SCOPE_VERSION = "0.0.2";
80
+ const SCOPE_VERSION = readScopeVersion();
78
81
  /** Build an OTLP export request for a single completed agent run. */
79
- function buildOtlpRequest(run, options) {
80
- const spans = buildRunSpans(run, options);
82
+ function buildOtlpRequest(result, options) {
83
+ const spans = result.spans.map((span) => toOtlpSpan(span, options));
81
84
  return { resourceSpans: [{
82
85
  resource: { attributes: resourceAttrs() },
83
86
  scopeSpans: [{
@@ -89,210 +92,158 @@ function buildOtlpRequest(run, options) {
89
92
  }]
90
93
  }] };
91
94
  }
92
- function buildRunSpans(run, options) {
93
- const traceId = hashHex(`${run.sessionId ?? "session"}:${run.runId}`, 32);
94
- const interactionSpanId = hashHex(`${traceId}:run`, 16);
95
- const out = [buildInteractionSpan(traceId, interactionSpanId, run, options)];
96
- run.llmCalls.forEach((call, idx) => {
97
- const callSpanId = hashHex(`${traceId}:call:${idx}`, 16);
98
- out.push(buildLlmSpan(traceId, interactionSpanId, callSpanId, call, idx, run, options));
99
- call.toolCalls.forEach((tool, tIdx) => {
100
- const toolSpanId = hashHex(`${traceId}:call:${idx}:tool:${tIdx}`, 16);
101
- out.push(buildToolSpan(traceId, interactionSpanId, toolSpanId, tool, run, options));
102
- });
103
- });
104
- run.orphanTools.forEach((tool, idx) => {
105
- const toolSpanId = hashHex(`${traceId}:orphan-tool:${idx}`, 16);
106
- out.push(buildToolSpan(traceId, interactionSpanId, toolSpanId, tool, run, options));
107
- });
108
- return out;
109
- }
110
- function buildInteractionSpan(traceId, spanId, run, options) {
111
- const startNs = msToNs(run.startMs);
112
- const endNs = msToNs(run.endMs ?? run.startMs);
113
- const totalTools = run.llmCalls.reduce((sum, c) => sum + c.toolCalls.length, 0) + run.orphanTools.length;
114
- 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;
115
109
  return {
116
- traceId,
117
- spanId,
118
- parentSpanId: "",
119
- name: "interaction",
110
+ traceId: span.traceId,
111
+ spanId: span.spanId,
112
+ parentSpanId: span.parentSpanId,
113
+ name: span.name,
120
114
  kind: 1,
121
115
  startTimeUnixNano: startNs,
122
116
  endTimeUnixNano: endNs,
123
- attributes: stripUndef([
124
- str("span.type", "interaction"),
125
- str("interaction.kind", "agent_run"),
126
- str("openclaw.run.id", run.runId),
127
- run.sessionId ? str("openclaw.session.id", run.sessionId) : void 0,
128
- run.sessionId ? str("session.id", run.sessionId) : void 0,
129
- run.sessionKey ? str("openclaw.session.key", run.sessionKey) : void 0,
130
- run.agentId ? str("openclaw.agent.id", run.agentId) : void 0,
131
- run.agentId ? str("openclaw.agent.name", run.agentId) : void 0,
132
- run.workspaceDir ? str("openclaw.workspace.dir", run.workspaceDir) : void 0,
133
- run.messageProvider ? str("openclaw.message.provider", run.messageProvider) : void 0,
134
- run.channelId ? str("openclaw.channel.id", run.channelId) : void 0,
135
- run.trigger ? str("openclaw.trigger", run.trigger) : void 0,
136
- run.modelProviderId ? str("openclaw.model.provider.id", run.modelProviderId) : void 0,
137
- run.modelId ? str("openclaw.model.id", run.modelId) : void 0,
138
- int("interaction.duration_ms", durationMs(run.startMs, run.endMs)),
139
- int("interaction.call_count", run.llmCalls.length),
140
- int("interaction.tool_call_count", totalTools),
141
- run.success !== void 0 ? bool("openclaw.run.success", run.success) : void 0,
142
- run.error ? str("openclaw.run.error", run.error) : void 0,
143
- totalUsage.input !== void 0 ? int("gen_ai.usage.input_tokens", totalUsage.input) : void 0,
144
- totalUsage.output !== void 0 ? int("gen_ai.usage.output_tokens", totalUsage.output) : void 0,
145
- totalUsage.cacheRead !== void 0 ? int("gen_ai.usage.cache_read_input_tokens", totalUsage.cacheRead) : void 0,
146
- totalUsage.cacheWrite !== void 0 ? int("gen_ai.usage.cache_creation_input_tokens", totalUsage.cacheWrite) : void 0,
147
- totalUsage.total !== void 0 ? int("gen_ai.usage.total_tokens", totalUsage.total) : void 0,
148
- options.allowConversationAccess && run.llmCalls[0]?.prompt ? str("user_prompt", run.llmCalls[0].prompt) : void 0,
149
- bool("latitude.captured.content", options.allowConversationAccess)
150
- ]),
151
- status: { code: run.success === false ? 2 : 1 }
117
+ attributes: attrs,
118
+ status: { code: statusCode }
152
119
  };
153
120
  }
154
- function buildLlmSpan(traceId, parentSpanId, spanId, call, callIdx, run, options) {
155
- const startNs = msToNs(call.startMs);
156
- const endNs = msToNs(call.endMs ?? call.startMs);
157
- const captureContent = options.allowConversationAccess;
158
- const inputMessages = captureContent ? buildInputMessages(call) : void 0;
159
- const outputMessages = captureContent ? buildOutputMessages(call) : void 0;
160
- const systemInstructions = captureContent && call.systemPrompt ? JSON.stringify([{
161
- type: "text",
162
- content: call.systemPrompt
163
- }]) : 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) {
164
142
  return {
165
- traceId,
166
- spanId,
167
- parentSpanId,
168
- name: "llm_request",
169
- kind: 3,
170
- startTimeUnixNano: startNs,
171
- endTimeUnixNano: endNs,
172
- attributes: stripUndef([
173
- str("span.type", "llm_request"),
174
- str("gen_ai.operation.name", "chat"),
175
- str("llm_request.context", "interaction"),
176
- int("llm_request.call_index", callIdx),
177
- str("gen_ai.system", call.provider),
178
- str("openclaw.provider", call.provider),
179
- str("gen_ai.request.model", call.requestModel),
180
- str("model", call.requestModel),
181
- call.responseModel ? str("gen_ai.response.model", call.responseModel) : void 0,
182
- call.resolvedRef ? str("openclaw.resolved.ref", call.resolvedRef) : void 0,
183
- run.sessionId ? str("session.id", run.sessionId) : void 0,
184
- run.sessionKey ? str("openclaw.session.key", run.sessionKey) : void 0,
185
- str("openclaw.run.id", call.runId),
186
- call.agentId ? str("openclaw.agent.id", call.agentId) : void 0,
187
- call.agentId ? str("openclaw.agent.name", call.agentId) : void 0,
188
- call.usage?.input !== void 0 ? int("gen_ai.usage.input_tokens", call.usage.input) : void 0,
189
- call.usage?.input !== void 0 ? int("input_tokens", call.usage.input) : void 0,
190
- call.usage?.output !== void 0 ? int("gen_ai.usage.output_tokens", call.usage.output) : void 0,
191
- call.usage?.output !== void 0 ? int("output_tokens", call.usage.output) : void 0,
192
- call.usage?.cacheRead !== void 0 ? int("gen_ai.usage.cache_read_input_tokens", call.usage.cacheRead) : void 0,
193
- call.usage?.cacheRead !== void 0 ? int("cache_read_tokens", call.usage.cacheRead) : void 0,
194
- call.usage?.cacheWrite !== void 0 ? int("gen_ai.usage.cache_creation_input_tokens", call.usage.cacheWrite) : void 0,
195
- call.usage?.cacheWrite !== void 0 ? int("cache_creation_tokens", call.usage.cacheWrite) : void 0,
196
- call.usage?.total !== void 0 ? int("gen_ai.usage.total_tokens", call.usage.total) : void 0,
197
- systemInstructions ? str("gen_ai.system_instructions", systemInstructions) : void 0,
198
- inputMessages ? str("gen_ai.input.messages", JSON.stringify(inputMessages)) : void 0,
199
- outputMessages ? str("gen_ai.output.messages", JSON.stringify(outputMessages)) : void 0,
200
- bool("latitude.captured.content", captureContent),
201
- int("openclaw.images.count", call.imagesCount),
202
- int("llm_request.tool_call_count", call.toolCalls.length),
203
- int("llm_request.duration_ms", durationMs(call.startMs, call.endMs)),
204
- call.error ? str("error.type", "llm_error") : void 0,
205
- call.error ? str("error.message", call.error) : void 0,
206
- str("success", call.error ? "false" : "true"),
207
- str("llm_request.captured", "true")
208
- ]),
209
- status: { code: call.error ? 2 : 1 }
143
+ key,
144
+ value: { stringValue: value }
210
145
  };
211
146
  }
212
- function buildToolSpan(traceId, parentSpanId, spanId, tool, run, options) {
213
- const startNs = msToNs(tool.startMs);
214
- const endNs = msToNs(tool.endMs ?? tool.startMs);
215
- const isError = Boolean(tool.error);
216
- const captureContent = options.allowConversationAccess;
147
+ function int(key, value) {
217
148
  return {
218
- traceId,
219
- spanId,
220
- parentSpanId,
221
- name: `tool:${tool.toolName}`,
222
- kind: 1,
223
- startTimeUnixNano: startNs,
224
- endTimeUnixNano: endNs,
225
- attributes: stripUndef([
226
- str("span.type", "tool_execution"),
227
- str("gen_ai.operation.name", "execute_tool"),
228
- str("gen_ai.tool.name", tool.toolName),
229
- str("gen_ai.tool.call.id", tool.toolCallId),
230
- captureContent ? str("gen_ai.tool.call.arguments", safeJson(tool.params)) : void 0,
231
- captureContent && tool.result !== void 0 ? str("gen_ai.tool.call.result", safeJson(tool.result)) : void 0,
232
- bool("latitude.captured.content", captureContent),
233
- isError ? str("error.type", "tool_error") : void 0,
234
- isError ? str("error.message", tool.error ?? "") : void 0,
235
- bool("tool.is_error", isError),
236
- str("success", isError ? "false" : "true"),
237
- tool.durationMs !== void 0 ? int("tool.duration_ms", tool.durationMs) : void 0,
238
- run.sessionId ? str("session.id", run.sessionId) : void 0,
239
- run.sessionKey ? str("openclaw.session.key", run.sessionKey) : void 0,
240
- str("openclaw.run.id", run.runId),
241
- tool.agentId ? str("openclaw.agent.id", tool.agentId) : void 0,
242
- tool.agentId ? str("openclaw.agent.name", tool.agentId) : void 0
243
- ]),
244
- status: { code: isError ? 2 : 1 }
149
+ key,
150
+ value: { intValue: String(Math.trunc(value)) }
245
151
  };
246
152
  }
247
- function buildInputMessages(call) {
248
- const out = [];
249
- for (const msg of call.historyMessages) {
250
- const normalized = normalizeHistoryMessage(msg);
251
- 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 "";
252
168
  }
253
- if (call.prompt.length > 0) out.push({
254
- role: "user",
255
- parts: [{
256
- type: "text",
257
- content: call.prompt
258
- }]
259
- });
260
- return out;
261
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";
184
+ }
185
+ }
186
+ //#endregion
187
+ //#region src/messages.ts
262
188
  const ALLOWED_ROLES = new Set([
263
189
  "system",
264
190
  "user",
265
191
  "assistant",
266
192
  "tool"
267
193
  ]);
268
- function normalizeRole(raw) {
269
- if (typeof raw !== "string") return "user";
270
- return ALLOWED_ROLES.has(raw) ? raw : "user";
271
- }
272
- 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) {
273
199
  if (!raw || typeof raw !== "object") return void 0;
274
200
  const obj = raw;
275
- 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
+ }
276
213
  const content = obj.content ?? obj.text ?? obj.message;
277
- if (typeof content === "string") return {
214
+ if (role === "tool" && obj.tool_call_id !== void 0) return {
278
215
  role,
279
216
  parts: [{
280
- type: "text",
281
- content
217
+ type: "tool_call_response",
218
+ id: typeof obj.tool_call_id === "string" ? obj.tool_call_id : "",
219
+ response: content ?? safeJson(obj)
282
220
  }]
283
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
+ }
284
233
  if (Array.isArray(content)) {
285
234
  const parts = [];
286
235
  for (const block of content) {
287
- const part = normalizeContentBlock(block);
236
+ const part = normalizeBlock(block);
288
237
  if (part) parts.push(part);
289
238
  }
239
+ appendToolCalls(parts, obj.tool_calls);
240
+ if (parts.length === 0) parts.push({
241
+ type: "text",
242
+ content: safeJson(content)
243
+ });
290
244
  return {
291
245
  role,
292
- parts: parts.length > 0 ? parts : [{
293
- type: "text",
294
- content: JSON.stringify(content)
295
- }]
246
+ parts
296
247
  };
297
248
  }
298
249
  return {
@@ -303,13 +254,76 @@ function normalizeHistoryMessage(raw) {
303
254
  }]
304
255
  };
305
256
  }
306
- 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) {
307
315
  if (typeof raw === "string") return {
308
316
  type: "text",
309
317
  content: raw
310
318
  };
311
319
  if (!raw || typeof raw !== "object") return void 0;
312
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
+ }
313
327
  const type = typeof obj.type === "string" ? obj.type : "text";
314
328
  if (type === "text" && typeof obj.text === "string") return {
315
329
  type: "text",
@@ -321,104 +335,66 @@ function normalizeContentBlock(raw) {
321
335
  name: typeof obj.name === "string" ? obj.name : "",
322
336
  arguments: obj.input ?? {}
323
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
+ };
324
344
  if (type === "tool_result") return {
325
345
  type: "tool_call_response",
326
346
  id: typeof obj.tool_use_id === "string" ? obj.tool_use_id : "",
327
347
  response: obj.content ?? ""
328
348
  };
329
- if (type === "image") return {
330
- type: "uri",
331
- modality: "image",
332
- uri: safeJson(obj.source ?? obj)
349
+ if (type === "tool_call_response") return {
350
+ type: "tool_call_response",
351
+ id: typeof obj.id === "string" ? obj.id : "",
352
+ response: obj.response ?? ""
333
353
  };
334
- return {
335
- type,
336
- content: safeJson(raw)
354
+ if (type === "thinking" && typeof obj.thinking === "string") return {
355
+ type: "reasoning",
356
+ content: obj.thinking
337
357
  };
338
- }
339
- function buildOutputMessages(call) {
340
- const parts = [];
341
- for (const text of call.assistantTexts) if (text.length > 0) parts.push({
342
- type: "text",
343
- content: text
344
- });
345
- for (const tool of call.toolCalls) parts.push({
346
- type: "tool_call",
347
- id: tool.toolCallId,
348
- name: tool.toolName,
349
- arguments: tool.params
350
- });
351
- if (parts.length === 0 && call.lastAssistant !== void 0) parts.push({
352
- type: "text",
353
- content: safeJson(call.lastAssistant)
354
- });
355
- return [{
356
- role: "assistant",
357
- parts
358
- }];
359
- }
360
- function aggregateUsage(calls) {
361
- const agg = {
362
- input: void 0,
363
- output: void 0,
364
- cacheRead: void 0,
365
- cacheWrite: void 0,
366
- total: void 0
358
+ if (type === "reasoning" && typeof obj.content === "string") return {
359
+ type: "reasoning",
360
+ content: obj.content
367
361
  };
368
- const add = (k, v) => {
369
- if (v === void 0) return;
370
- agg[k] = (agg[k] ?? 0) + v;
371
- };
372
- for (const c of calls) {
373
- if (!c.usage) continue;
374
- add("input", c.usage.input);
375
- add("output", c.usage.output);
376
- add("cacheRead", c.usage.cacheRead);
377
- add("cacheWrite", c.usage.cacheWrite);
378
- 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
+ };
379
370
  }
380
- return agg;
381
- }
382
- function resourceAttrs() {
383
- return [
384
- str("service.name", "openclaw"),
385
- str("service.version", SCOPE_VERSION),
386
- str("host.name", hostname()),
387
- str("host.arch", arch()),
388
- str("os.type", platform()),
389
- str("os.version", release())
390
- ];
391
- }
392
- function str(key, value) {
393
371
  return {
394
- key,
395
- value: { stringValue: value }
396
- };
397
- }
398
- function int(key, value) {
399
- return {
400
- key,
401
- value: { intValue: String(Math.trunc(value)) }
402
- };
403
- }
404
- function bool(key, value) {
405
- return {
406
- key,
407
- value: { boolValue: value }
372
+ type,
373
+ content: safeJson(raw)
408
374
  };
409
375
  }
410
- function stripUndef(items) {
411
- return items.filter((x) => x !== void 0);
412
- }
413
- function hashHex(input, length) {
414
- return createHash("sha256").update(input).digest("hex").slice(0, length);
415
- }
416
- function msToNs(ms) {
417
- return (BigInt(Math.trunc(ms)) * 1000000n).toString();
418
- }
419
- function durationMs(startMs, endMs) {
420
- if (endMs === void 0) return 0;
421
- 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
+ }
422
398
  }
423
399
  function safeJson(value) {
424
400
  try {
@@ -429,179 +405,484 @@ function safeJson(value) {
429
405
  }
430
406
  }
431
407
  //#endregion
432
- //#region src/turn-builder.ts
433
- /**
434
- * Accumulates OpenClaw hook events per agent run (keyed by `runId`) into a
435
- * `RunRecord` ready to be converted to OTLP spans. All mutation is synchronous
436
- * and non-blocking so the hook runner can stay fire-and-forget.
437
- *
438
- * Event ordering assumptions (verified against OpenClaw
439
- * src/agents/pi-embedded-runner/run/attempt.ts):
440
- *
441
- * session_start? -> [ llm_input -> (before_tool_call -> after_tool_call)* -> llm_output ]+ -> agent_end
442
- *
443
- * Tool calls arriving between an `llm_input` and its `llm_output` are attached
444
- * to the currently-open LLM call. Tools arriving outside that window (e.g.
445
- * `after_tool_call` fires after `llm_output` has already closed the call) are
446
- * stored on the run's `orphanTools` list so we don't drop them.
447
- */
448
- 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 {
449
412
  runs = /* @__PURE__ */ new Map();
450
- 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
+ */
451
464
  onLlmInput(evt, ctx) {
452
- const run = this.ensureRun(evt.runId, ctx);
453
- const call = {
454
- runId: evt.runId,
455
- sessionId: evt.sessionId,
456
- sessionKey: ctx.sessionKey,
457
- agentId: ctx.agentId,
458
- provider: evt.provider,
459
- requestModel: evt.model,
460
- responseModel: void 0,
461
- resolvedRef: void 0,
462
- systemPrompt: evt.systemPrompt,
463
- prompt: evt.prompt,
464
- historyMessages: evt.historyMessages,
465
- imagesCount: evt.imagesCount,
466
- assistantTexts: [],
467
- lastAssistant: void 0,
468
- 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",
469
504
  startMs: Date.now(),
470
505
  endMs: void 0,
471
- error: void 0,
472
- 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
+ }
473
517
  };
474
- run.llmCalls.push(call);
475
- 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);
476
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
+ */
477
551
  onBeforeToolCall(evt, ctx) {
478
552
  if (!evt.runId) return;
479
- const run = this.runs.get(evt.runId) ?? this.ensureRun(evt.runId, ctx);
480
- const tool = {
481
- toolCallId: evt.toolCallId ?? `${evt.toolName}:${randomUUID()}`,
482
- toolName: evt.toolName,
483
- params: evt.params,
484
- result: void 0,
485
- 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}`,
486
561
  startMs: Date.now(),
487
562
  endMs: void 0,
488
- durationMs: void 0,
489
- 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
+ }
490
570
  };
491
- const openCall = this.currentOpenCall(run);
492
- if (openCall) openCall.toolCalls.push(tool);
493
- 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
+ });
494
581
  }
495
582
  onAfterToolCall(evt, _ctx) {
496
583
  if (!evt.runId) return;
497
584
  const run = this.runs.get(evt.runId);
498
585
  if (!run) return;
499
- const tool = this.findToolRecord(run, evt.toolCallId, evt.toolName);
500
- if (!tool) return;
501
- tool.result = evt.result;
502
- tool.error = evt.error;
503
- tool.durationMs = evt.durationMs;
504
- 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
+ });
505
610
  }
506
- onLlmOutput(evt, _ctx) {
507
- const run = this.runs.get(evt.runId);
508
- if (!run) return void 0;
509
- const openCall = this.currentOpenCall(run);
510
- if (!openCall) return void 0;
511
- openCall.endMs = Date.now();
512
- openCall.assistantTexts = evt.assistantTexts;
513
- openCall.lastAssistant = evt.lastAssistant;
514
- openCall.usage = evt.usage;
515
- openCall.responseModel = evt.model;
516
- openCall.resolvedRef = evt.resolvedRef;
517
- return openCall;
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
+ };
518
631
  }
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);
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
+ */
519
715
  onAgentEnd(evt, ctx) {
520
716
  const runId = ctx.runId;
521
717
  if (!runId) return void 0;
522
718
  const run = this.runs.get(runId);
523
- if (!run) return void 0;
524
- run.endMs = Date.now();
525
- run.success = evt.success;
526
- run.error = evt.error;
527
- for (const call of run.llmCalls) {
528
- if (call.endMs === void 0) {
529
- call.endMs = run.endMs;
530
- if (evt.error && call.error === void 0) call.error = evt.error;
531
- }
532
- 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);
533
744
  }
534
- for (const tool of run.orphanTools) if (tool.endMs === void 0) tool.endMs = run.endMs;
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);
750
+ }
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];
535
758
  this.runs.delete(runId);
536
- return run;
759
+ this.subagentLinks.delete(runId);
760
+ return {
761
+ runId,
762
+ spans
763
+ };
537
764
  }
538
765
  /** Drop a run without emitting — used on errors from the emit path. */
539
766
  abandon(runId) {
540
767
  this.runs.delete(runId);
768
+ this.subagentLinks.delete(runId);
541
769
  }
542
- /** Active runs count, for debug logging. */
543
- inflightCount() {
544
- return this.runs.size;
770
+ /** Test-only: how many cross-run subagent links we're holding. */
771
+ subagentLinkCount() {
772
+ return this.subagentLinks.size;
545
773
  }
546
- ensureRun(runId, ctx) {
547
- let run = this.runs.get(runId);
548
- if (run) return run;
549
- run = {
550
- runId,
551
- sessionId: ctx.sessionId,
552
- sessionKey: ctx.sessionKey,
553
- agentId: ctx.agentId,
554
- workspaceDir: ctx.workspaceDir,
555
- messageProvider: ctx.messageProvider,
556
- trigger: ctx.trigger,
557
- channelId: ctx.channelId,
558
- modelProviderId: ctx.modelProviderId,
559
- modelId: ctx.modelId,
560
- startMs: Date.now(),
561
- endMs: void 0,
562
- success: void 0,
563
- error: void 0,
564
- llmCalls: [],
565
- orphanTools: []
566
- };
567
- this.runs.set(runId, run);
568
- return run;
569
- }
570
- currentOpenCall(run) {
571
- for (let i = run.llmCalls.length - 1; i >= 0; i--) {
572
- const call = run.llmCalls[i];
573
- 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]);
574
792
  }
575
793
  }
576
- findToolRecord(run, toolCallId, toolName) {
577
- const matchesId = (t) => Boolean(toolCallId && t.toolCallId === toolCallId);
578
- for (const call of run.llmCalls) for (const t of call.toolCalls) if (matchesId(t)) return t;
579
- for (const t of run.orphanTools) if (matchesId(t)) return t;
580
- for (let i = run.llmCalls.length - 1; i >= 0; i--) {
581
- const call = run.llmCalls[i];
582
- if (!call) continue;
583
- for (let j = call.toolCalls.length - 1; j >= 0; j--) {
584
- const t = call.toolCalls[j];
585
- if (t && t.toolName === toolName && t.endMs === void 0) return t;
586
- }
587
- }
588
- for (let i = run.orphanTools.length - 1; i >= 0; i--) {
589
- const t = run.orphanTools[i];
590
- 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;
591
802
  }
592
803
  }
593
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
+ }
594
873
  //#endregion
595
874
  //#region src/plugin.ts
596
875
  /**
597
876
  * Register the Latitude plugin against an OpenClaw plugin API. OpenClaw calls
598
- * this once at plugin activation; we wire up `llm_input`, `llm_output`, tool
599
- * 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.
600
881
  *
601
- * Every handler is fire-and-forget on OpenClaw's side (see
602
- * `src/plugins/hooks.ts` runLlmInput/runLlmOutput are documented as
603
- * parallel and wrapped with `.catch()` at the call site in attempt.ts), so
604
- * 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.
605
886
  */
606
887
  function registerLatitudePlugin(api, opts = {}) {
607
888
  const config = opts.config ?? loadConfig(api.pluginConfig);
@@ -612,58 +893,65 @@ function registerLatitudePlugin(api, opts = {}) {
612
893
  return;
613
894
  }
614
895
  logger.debug(`enabled: project=${config.project} base=${config.baseUrl} allowConversationAccess=${config.allowConversationAccess}`);
615
- const builder = new TurnBuilder();
616
- api.on("session_start", (evt, ctx) => {
617
- builder.onSessionStart(evt, ctx);
618
- });
619
- api.on("llm_input", (evt, ctx) => {
620
- try {
621
- builder.onLlmInput(evt, ctx);
622
- } catch (err) {
623
- logger.warn(`llm_input handler failed: ${String(err)}`);
624
- }
625
- });
626
- api.on("before_tool_call", (evt, ctx) => {
627
- try {
628
- builder.onBeforeToolCall(evt, ctx);
629
- } catch (err) {
630
- logger.warn(`before_tool_call handler failed: ${String(err)}`);
631
- }
632
- });
633
- api.on("after_tool_call", (evt, ctx) => {
634
- try {
635
- builder.onAfterToolCall(evt, ctx);
636
- } catch (err) {
637
- logger.warn(`after_tool_call handler failed: ${String(err)}`);
638
- }
639
- });
640
- api.on("llm_output", (evt, ctx) => {
641
- try {
642
- builder.onLlmOutput(evt, ctx);
643
- } catch (err) {
644
- logger.warn(`llm_output handler failed: ${String(err)}`);
645
- }
646
- });
647
- api.on("agent_end", (evt, ctx) => {
648
- try {
649
- const run = builder.onAgentEnd(evt, ctx);
650
- if (!run) {
651
- logger.debug("agent_end fired without a matching run in flight");
652
- 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)}`);
653
903
  }
654
- opts.onEmit?.(run);
655
- const payload = buildOtlpRequest(run, { allowConversationAccess: config.allowConversationAccess });
656
- postTraces({
657
- baseUrl: config.baseUrl,
658
- apiKey: config.apiKey,
659
- project: config.project,
660
- payload,
661
- logger
662
- });
663
- } catch (err) {
664
- 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;
665
944
  }
666
- });
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
+ }));
667
955
  }
668
956
  //#endregion
669
957
  export { registerLatitudePlugin as default };