@latitude-data/openclaw-telemetry 0.0.4 → 0.0.6

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