@vymalo/opencode-core-otel 0.14.1

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.
@@ -0,0 +1,795 @@
1
+ import { ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
+ import { SeverityNumber } from "@opentelemetry/api-logs";
3
+ import { createInstruments, detectLanguage } from "./instruments.js";
4
+ /**
5
+ * `diffs` is keyed by session *and* file. The separator is a NUL because it
6
+ * cannot occur in either part — and it lives here, behind two functions,
7
+ * because an invisible character duplicated across a writer and a reader is
8
+ * exactly the kind of literal that silently stops matching.
9
+ */
10
+ const DIFF_KEY_SEPARATOR = "\0";
11
+ function diffKey(sessionID, file) {
12
+ return `${sessionID}${DIFF_KEY_SEPARATOR}${file}`;
13
+ }
14
+ function diffKeyPrefix(sessionID) {
15
+ return `${sessionID}${DIFF_KEY_SEPARATOR}`;
16
+ }
17
+ /**
18
+ * Translates the OpenCode event stream and hook callbacks into OTel signals.
19
+ *
20
+ * Deliberately holds no content: lengths, counts, durations and outcomes only.
21
+ * See `plans/otel.md` → "No content capture in v1".
22
+ */
23
+ export class TelemetryRecorder {
24
+ deps;
25
+ instruments;
26
+ now;
27
+ sessions = new Map();
28
+ chats = new Map();
29
+ /**
30
+ * Chats opened by `chat.params` but not yet matched to an assistant message.
31
+ * `chat.params` fires *before* the provider request goes out, so without this
32
+ * the very first request of a turn has no span to propagate trace context
33
+ * from. Keyed by session; adopted by the next assistant message.
34
+ */
35
+ pendingChats = new Map();
36
+ tools = new Map();
37
+ /** Completed assistant text length per message, from `experimental.text.complete`. */
38
+ responseLengths = new Map();
39
+ /** Permissions already counted at `permission.ask` time, so a later reply cannot double-count. */
40
+ autoDecided = new Set();
41
+ /**
42
+ * Terminal tool outcomes already recorded, so the hook and the part update
43
+ * cannot double-count. Keyed by call id, valued by session so the entry can
44
+ * be pruned when that session ends.
45
+ */
46
+ finishedTools = new Map();
47
+ /**
48
+ * Assistant messages already finalized — `message.updated` fires repeatedly
49
+ * with cumulative totals. Valued by session, for pruning.
50
+ */
51
+ finalizedMessages = new Map();
52
+ /** Pending permission prompts, so `permission.replied` can name the tool it resolved. */
53
+ permissions = new Map();
54
+ /**
55
+ * Last-seen cumulative diff per `sessionID\0file`. `session.diff` reports the
56
+ * session's whole diff each time, so only the delta may be counted.
57
+ */
58
+ diffs = new Map();
59
+ constructor(deps) {
60
+ this.deps = deps;
61
+ this.now = deps.now ?? Date.now;
62
+ this.instruments = deps.providers.meter ? createInstruments(deps.providers.meter) : undefined;
63
+ }
64
+ // ---------------------------------------------------------------- helpers
65
+ /**
66
+ * Session id as a *metric* attribute — omitted unless `includeSessionId`,
67
+ * because it is unbounded cardinality and metric backends bill per series.
68
+ * Logs and spans always carry it.
69
+ */
70
+ metricSession(sessionID) {
71
+ return this.deps.config.includeSessionId ? { "gen_ai.conversation.id": sessionID } : {};
72
+ }
73
+ emit(name, attributes, severity = SeverityNumber.INFO, context) {
74
+ this.deps.providers.otelLogger?.emit({
75
+ timestamp: this.now(),
76
+ severityNumber: severity,
77
+ severityText: severity >= SeverityNumber.ERROR ? "ERROR" : "INFO",
78
+ body: name,
79
+ attributes: {
80
+ "event.name": name,
81
+ ...attributes
82
+ },
83
+ context
84
+ });
85
+ }
86
+ session(sessionID) {
87
+ let state = this.sessions.get(sessionID);
88
+ if (!state) {
89
+ // A session we never saw created (the plugin loaded mid-session, or the
90
+ // host replayed history) still gets a state so its metrics are not lost.
91
+ state = {
92
+ context: ROOT_CONTEXT,
93
+ startedAt: this.now(),
94
+ requests: 0,
95
+ cost: 0,
96
+ inputTokens: 0,
97
+ outputTokens: 0
98
+ };
99
+ this.sessions.set(sessionID, state);
100
+ }
101
+ return state;
102
+ }
103
+ // ----------------------------------------------------------------- events
104
+ onEvent(event) {
105
+ try {
106
+ this.dispatch(event);
107
+ } catch (error) {
108
+ // Telemetry must never break the host. Swallow, but say so.
109
+ this.deps.logger.warn("otel_event_failed", {
110
+ type: event?.type,
111
+ error: error instanceof Error ? error.message : String(error)
112
+ });
113
+ }
114
+ }
115
+ dispatch(event) {
116
+ switch (event.type) {
117
+ case "session.created":
118
+ this.onSessionCreated(event.properties.info);
119
+ return;
120
+ case "session.idle":
121
+ this.onSessionIdle(event.properties.sessionID);
122
+ return;
123
+ case "session.status":
124
+ this.onSessionStatus(event.properties.sessionID, event.properties.status);
125
+ return;
126
+ case "session.compacted":
127
+ this.onCompacted(event.properties.sessionID);
128
+ return;
129
+ case "session.error":
130
+ this.onSessionError(event.properties.sessionID, event.properties.error);
131
+ return;
132
+ case "session.diff":
133
+ this.onSessionDiff(event.properties.sessionID, event.properties.diff);
134
+ return;
135
+ case "message.updated":
136
+ this.onMessageUpdated(event.properties.info);
137
+ return;
138
+ case "message.part.updated":
139
+ this.onPartUpdated(event.properties.part);
140
+ return;
141
+ case "permission.updated":
142
+ this.permissions.set(event.properties.id, {
143
+ type: event.properties.type,
144
+ sessionID: event.properties.sessionID
145
+ });
146
+ return;
147
+ case "permission.replied":
148
+ this.onPermissionReplied(event.properties.sessionID, event.properties.permissionID, event.properties.response);
149
+ return;
150
+ case "command.executed":
151
+ this.onCommandExecuted(event.properties.sessionID, event.properties.name, event.properties.arguments);
152
+ return;
153
+ case "installation.updated":
154
+ // The only channel the host version arrives on. See `deferred.ts`.
155
+ this.deps.resourceSinks?.version?.(event.properties.version);
156
+ return;
157
+ case "vcs.branch.updated":
158
+ if (event.properties.branch) {
159
+ this.deps.resourceSinks?.branch?.(event.properties.branch);
160
+ }
161
+ return;
162
+ case "todo.updated":
163
+ this.onTodoUpdated(event.properties.sessionID, event.properties.todos);
164
+ return;
165
+ case "session.deleted":
166
+ this.forgetSession(event.properties.info.id);
167
+ return;
168
+ case "server.instance.disposed":
169
+ // A real shutdown signal from the host — more reliable than waiting for
170
+ // a process signal that a supervised runtime may never deliver.
171
+ void this.shutdown().catch(() => {
172
+ /* best-effort */
173
+ });
174
+ return;
175
+ default: return;
176
+ }
177
+ }
178
+ onSessionCreated(info) {
179
+ const kind = info.parentID ? "child" : "root";
180
+ const startedAt = this.now();
181
+ const span = this.deps.providers.tracer?.startSpan("invoke_agent opencode", {
182
+ kind: SpanKind.INTERNAL,
183
+ startTime: startedAt,
184
+ attributes: {
185
+ "gen_ai.operation.name": "invoke_agent",
186
+ "gen_ai.agent.name": "opencode",
187
+ "gen_ai.conversation.id": info.id,
188
+ "opencode.session.kind": kind
189
+ }
190
+ }, ROOT_CONTEXT);
191
+ const context = span ? trace.setSpan(ROOT_CONTEXT, span) : ROOT_CONTEXT;
192
+ this.sessions.set(info.id, {
193
+ span,
194
+ context,
195
+ startedAt,
196
+ requests: 0,
197
+ cost: 0,
198
+ inputTokens: 0,
199
+ outputTokens: 0
200
+ });
201
+ this.instruments?.sessions.add(1, {
202
+ "opencode.session.kind": kind,
203
+ ...this.metricSession(info.id)
204
+ });
205
+ this.emit("opencode.session_start", {
206
+ "gen_ai.conversation.id": info.id,
207
+ "opencode.session.kind": kind,
208
+ ...info.parentID ? { "opencode.session.parent_id": info.parentID } : {},
209
+ ...info.directory ? { "opencode.directory": info.directory } : {}
210
+ }, SeverityNumber.INFO, context);
211
+ }
212
+ onSessionStatus(sessionID, status) {
213
+ const state = this.session(sessionID);
214
+ if (status.type === "busy") {
215
+ state.busySince ??= this.now();
216
+ return;
217
+ }
218
+ if (status.type === "retry") {
219
+ this.emit("opencode.api_error", {
220
+ "gen_ai.conversation.id": sessionID,
221
+ "error.type": "retry",
222
+ "opencode.error.retryable": true,
223
+ ...typeof status.attempt === "number" ? { "opencode.retry.attempt": status.attempt } : {}
224
+ }, SeverityNumber.WARN, state.context);
225
+ return;
226
+ }
227
+ this.settleActiveTime(sessionID, state);
228
+ }
229
+ settleActiveTime(sessionID, state) {
230
+ if (state.busySince === undefined) {
231
+ return;
232
+ }
233
+ const seconds = Math.max(0, (this.now() - state.busySince) / 1e3);
234
+ state.busySince = undefined;
235
+ this.instruments?.activeTime.add(seconds, this.metricSession(sessionID));
236
+ }
237
+ onSessionIdle(sessionID) {
238
+ const state = this.sessions.get(sessionID);
239
+ if (!state) {
240
+ return;
241
+ }
242
+ this.settleActiveTime(sessionID, state);
243
+ this.emit("opencode.session_idle", {
244
+ "gen_ai.conversation.id": sessionID,
245
+ "opencode.session.duration_ms": this.now() - state.startedAt,
246
+ "opencode.session.request_count": state.requests,
247
+ "opencode.cost.usage": state.cost,
248
+ "gen_ai.usage.input_tokens": state.inputTokens,
249
+ "gen_ai.usage.output_tokens": state.outputTokens
250
+ }, SeverityNumber.INFO, state.context);
251
+ if (state.span) {
252
+ state.span.setAttribute("opencode.session.request_count", state.requests);
253
+ state.span.setAttribute("opencode.cost.usage", state.cost);
254
+ state.span.end();
255
+ }
256
+ this.sessions.delete(sessionID);
257
+ // A turn ended: the dedupe bookkeeping for its finished messages and tools
258
+ // is dead weight from here on. `diffs` deliberately survives — the session
259
+ // may resume, and its diff totals are cumulative.
260
+ this.pruneCompleted(sessionID);
261
+ // A short CLI invocation may exit right after idle, so drain now rather
262
+ // than waiting for the batch interval.
263
+ void this.deps.providers.forceFlush().catch(() => {
264
+ /* best-effort */
265
+ });
266
+ }
267
+ onCompacted(sessionID) {
268
+ const state = this.session(sessionID);
269
+ this.instruments?.compactions.add(1, this.metricSession(sessionID));
270
+ this.deps.providers.tracer?.startSpan("session_compaction", { startTime: this.now() }, state.context).end();
271
+ this.emit("opencode.compaction", { "gen_ai.conversation.id": sessionID }, SeverityNumber.INFO, state.context);
272
+ }
273
+ onSessionError(sessionID, error) {
274
+ const named = error;
275
+ const state = sessionID ? this.session(sessionID) : undefined;
276
+ this.emit("opencode.api_error", {
277
+ ...sessionID ? { "gen_ai.conversation.id": sessionID } : {},
278
+ "error.type": named?.name ?? "UnknownError",
279
+ ...typeof named?.data?.statusCode === "number" ? { "http.response.status_code": named.data.statusCode } : {},
280
+ ...typeof named?.data?.isRetryable === "boolean" ? { "opencode.error.retryable": named.data.isRetryable } : {}
281
+ }, SeverityNumber.ERROR, state?.context);
282
+ }
283
+ onSessionDiff(sessionID, diff) {
284
+ const state = this.session(sessionID);
285
+ for (const entry of diff) {
286
+ const key = diffKey(sessionID, entry.file);
287
+ const previous = this.diffs.get(key) ?? {
288
+ additions: 0,
289
+ deletions: 0
290
+ };
291
+ // Cumulative source → count only what is new since the last report.
292
+ const added = Math.max(0, (entry.additions ?? 0) - previous.additions);
293
+ const removed = Math.max(0, (entry.deletions ?? 0) - previous.deletions);
294
+ this.diffs.set(key, {
295
+ additions: entry.additions ?? 0,
296
+ deletions: entry.deletions ?? 0
297
+ });
298
+ if (added === 0 && removed === 0) {
299
+ continue;
300
+ }
301
+ const language = detectLanguage(entry.file);
302
+ const languageAttr = language ? { "code.language": language } : {};
303
+ if (added > 0) {
304
+ this.instruments?.linesOfCode.add(added, {
305
+ "opencode.change.type": "added",
306
+ ...languageAttr,
307
+ ...this.metricSession(sessionID)
308
+ });
309
+ }
310
+ if (removed > 0) {
311
+ this.instruments?.linesOfCode.add(removed, {
312
+ "opencode.change.type": "removed",
313
+ ...languageAttr,
314
+ ...this.metricSession(sessionID)
315
+ });
316
+ }
317
+ this.emit("opencode.file_edited", {
318
+ "gen_ai.conversation.id": sessionID,
319
+ ...languageAttr,
320
+ "opencode.file.additions": added,
321
+ "opencode.file.deletions": removed
322
+ }, SeverityNumber.INFO, state.context);
323
+ }
324
+ }
325
+ onMessageUpdated(info) {
326
+ const message = info;
327
+ if (message.role !== "assistant" || this.finalizedMessages.has(message.id)) {
328
+ return;
329
+ }
330
+ const session = this.session(message.sessionID);
331
+ const baseAttributes = {
332
+ "gen_ai.operation.name": "chat",
333
+ "gen_ai.conversation.id": message.sessionID,
334
+ ...message.modelID ? { "gen_ai.request.model": message.modelID } : {},
335
+ ...message.providerID ? { "gen_ai.provider.name": message.providerID } : {},
336
+ ...message.mode ? { "gen_ai.agent.name": message.mode } : {}
337
+ };
338
+ let chat = this.chats.get(message.id);
339
+ if (!chat) {
340
+ // Adopt the span `chat.params` opened for this session, if there is one —
341
+ // it started before the provider request went out, so it covers the whole
342
+ // round-trip rather than only the part after the response began arriving.
343
+ const pending = this.pendingChats.get(message.sessionID);
344
+ if (pending) {
345
+ this.pendingChats.delete(message.sessionID);
346
+ pending.span?.updateName(`chat ${message.modelID ?? "unknown"}`);
347
+ pending.span?.setAttributes(baseAttributes);
348
+ chat = pending;
349
+ } else {
350
+ chat = {
351
+ sessionID: message.sessionID,
352
+ span: this.deps.providers.tracer?.startSpan(`chat ${message.modelID ?? "unknown"}`, {
353
+ kind: SpanKind.CLIENT,
354
+ startTime: message.time?.created ?? this.now(),
355
+ attributes: baseAttributes
356
+ }, session.context)
357
+ };
358
+ }
359
+ this.chats.set(message.id, chat);
360
+ }
361
+ if (message.time?.completed === undefined) {
362
+ return;
363
+ }
364
+ this.finalizeMessage(message, chat, session, baseAttributes);
365
+ }
366
+ finalizeMessage(message, chat, session, baseAttributes) {
367
+ this.finalizedMessages.set(message.id, message.sessionID);
368
+ this.chats.delete(message.id);
369
+ const responseLength = this.responseLengths.get(message.id);
370
+ this.responseLengths.delete(message.id);
371
+ const created = message.time?.created ?? this.now();
372
+ const completed = message.time?.completed ?? this.now();
373
+ const durationSeconds = Math.max(0, (completed - created) / 1e3);
374
+ const errorType = message.error?.name;
375
+ const metricAttributes = {
376
+ "gen_ai.operation.name": "chat",
377
+ ...message.modelID ? { "gen_ai.request.model": message.modelID } : {},
378
+ ...message.providerID ? { "gen_ai.provider.name": message.providerID } : {},
379
+ ...message.mode ? { "gen_ai.agent.name": message.mode } : {},
380
+ ...this.metricSession(message.sessionID)
381
+ };
382
+ // Real USD, straight from the host — no price table to drift out of date.
383
+ const cost = typeof message.cost === "number" ? message.cost : 0;
384
+ if (cost > 0) {
385
+ this.instruments?.cost.add(cost, metricAttributes);
386
+ }
387
+ const tokens = message.tokens;
388
+ if (tokens) {
389
+ const byType = [
390
+ ["input", tokens.input],
391
+ ["output", tokens.output],
392
+ ["reasoning", tokens.reasoning],
393
+ ["cache_read", tokens.cache?.read ?? 0],
394
+ ["cache_write", tokens.cache?.write ?? 0]
395
+ ];
396
+ for (const [type, value] of byType) {
397
+ // Cache and reasoning tokens are recorded as first-class types: on a
398
+ // cached agentic session cache-read is routinely the majority of
399
+ // tokens, so summing input+output alone measures the wrong thing.
400
+ if (typeof value === "number" && value > 0) {
401
+ this.instruments?.tokens.record(value, {
402
+ ...metricAttributes,
403
+ "gen_ai.token.type": type
404
+ });
405
+ }
406
+ }
407
+ }
408
+ this.instruments?.requests.add(1, metricAttributes);
409
+ this.instruments?.duration.record(durationSeconds, {
410
+ ...metricAttributes,
411
+ ...errorType ? { "error.type": errorType } : {}
412
+ });
413
+ session.requests += 1;
414
+ session.cost += cost;
415
+ session.inputTokens += tokens?.input ?? 0;
416
+ session.outputTokens += tokens?.output ?? 0;
417
+ const usageAttributes = {
418
+ ...baseAttributes,
419
+ "gen_ai.usage.input_tokens": tokens?.input ?? 0,
420
+ "gen_ai.usage.output_tokens": tokens?.output ?? 0,
421
+ "gen_ai.usage.reasoning_tokens": tokens?.reasoning ?? 0,
422
+ "gen_ai.usage.cache_read_tokens": tokens?.cache?.read ?? 0,
423
+ "gen_ai.usage.cache_write_tokens": tokens?.cache?.write ?? 0,
424
+ "opencode.cost.usage": cost,
425
+ "opencode.response.duration_ms": completed - created,
426
+ // Size of the assistant's text, never the text itself.
427
+ ...responseLength !== undefined ? { "opencode.response.length": responseLength } : {},
428
+ ...chat.params ?? {},
429
+ ...message.finish ? { "gen_ai.response.finish_reasons": [message.finish] } : {},
430
+ ...errorType ? { "error.type": errorType } : {}
431
+ };
432
+ if (chat.span) {
433
+ chat.span.setAttributes(usageAttributes);
434
+ if (errorType) {
435
+ chat.span.setStatus({
436
+ code: SpanStatusCode.ERROR,
437
+ message: errorType
438
+ });
439
+ }
440
+ chat.span.end(completed);
441
+ }
442
+ this.emit("opencode.assistant_response", usageAttributes, errorType ? SeverityNumber.ERROR : SeverityNumber.INFO, session.context);
443
+ if (message.error) {
444
+ this.onSessionError(message.sessionID, message.error);
445
+ }
446
+ }
447
+ onPartUpdated(part) {
448
+ const typed = part;
449
+ if (typed.type !== "tool" || !typed.callID || !typed.state) {
450
+ return;
451
+ }
452
+ const status = typed.state.status;
453
+ if (status !== "completed" && status !== "error") {
454
+ return;
455
+ }
456
+ const time = typed.state.time;
457
+ const durationMs = time?.end !== undefined && time?.start !== undefined ? Math.max(0, time.end - time.start) : undefined;
458
+ this.finishTool(typed.callID, {
459
+ tool: typed.tool ?? "unknown",
460
+ sessionID: typed.sessionID ?? "",
461
+ status: status === "error" ? "error" : "ok",
462
+ durationMs
463
+ });
464
+ }
465
+ onPermissionReplied(sessionID, permissionID, response) {
466
+ // Already counted at `permission.ask` time as an auto-decision.
467
+ if (this.autoDecided.delete(permissionID)) {
468
+ return;
469
+ }
470
+ const pending = this.permissions.get(permissionID);
471
+ this.permissions.delete(permissionID);
472
+ this.recordDecision(sessionID, permissionID, response, "user", pending?.type);
473
+ }
474
+ recordDecision(sessionID, permissionID, decision, source, tool) {
475
+ const state = this.session(sessionID);
476
+ const toolAttr = tool ? { "gen_ai.tool.name": tool } : {};
477
+ this.instruments?.permissionDecisions.add(1, {
478
+ "opencode.permission.decision": decision,
479
+ "opencode.permission.source": source,
480
+ ...toolAttr,
481
+ ...this.metricSession(sessionID)
482
+ });
483
+ this.emit("opencode.tool_decision", {
484
+ "gen_ai.conversation.id": sessionID,
485
+ "opencode.permission.decision": decision,
486
+ "opencode.permission.source": source,
487
+ "opencode.permission.id": permissionID,
488
+ ...toolAttr
489
+ }, SeverityNumber.INFO, state.context);
490
+ }
491
+ /**
492
+ * Drop every per-session entry. Without this the bookkeeping sets grow for
493
+ * the life of the process — fine for a CLI invocation, a slow leak in a
494
+ * long-running OpenCode server.
495
+ */
496
+ forgetSession(sessionID) {
497
+ const state = this.sessions.get(sessionID);
498
+ if (state) {
499
+ this.settleActiveTime(sessionID, state);
500
+ state.span?.end();
501
+ this.sessions.delete(sessionID);
502
+ }
503
+ this.pendingChats.get(sessionID)?.span?.end();
504
+ this.pendingChats.delete(sessionID);
505
+ for (const [id, chat] of this.chats) {
506
+ if (chat.sessionID === sessionID) {
507
+ chat.span?.end();
508
+ this.chats.delete(id);
509
+ }
510
+ }
511
+ for (const [callID, tool] of this.tools) {
512
+ if (tool.sessionID === sessionID) {
513
+ tool.span?.end();
514
+ this.tools.delete(callID);
515
+ }
516
+ }
517
+ for (const key of this.diffs.keys()) {
518
+ if (key.startsWith(diffKeyPrefix(sessionID))) {
519
+ this.diffs.delete(key);
520
+ }
521
+ }
522
+ for (const [id, permission] of this.permissions) {
523
+ if (permission.sessionID === sessionID) {
524
+ this.permissions.delete(id);
525
+ this.autoDecided.delete(id);
526
+ }
527
+ }
528
+ this.pruneCompleted(sessionID);
529
+ }
530
+ /**
531
+ * Drop the dedupe bookkeeping for a session's finished messages and tool
532
+ * calls. Safe to run at every idle — those ids are never reused, so nothing
533
+ * can be double-counted afterwards. Deliberately does **not** touch `diffs`:
534
+ * `session.diff` is cumulative, so forgetting the last-seen totals for a
535
+ * session that later resumes would re-count its whole diff.
536
+ */
537
+ pruneCompleted(sessionID) {
538
+ for (const [id, owner] of this.finalizedMessages) {
539
+ if (owner === sessionID) {
540
+ this.finalizedMessages.delete(id);
541
+ this.responseLengths.delete(id);
542
+ }
543
+ }
544
+ for (const [callID, owner] of this.finishedTools) {
545
+ if (owner === sessionID) {
546
+ this.finishedTools.delete(callID);
547
+ }
548
+ }
549
+ }
550
+ onCommandExecuted(sessionID, name, args) {
551
+ const state = this.session(sessionID);
552
+ this.instruments?.commands.add(1, {
553
+ "opencode.command.name": name,
554
+ ...this.metricSession(sessionID)
555
+ });
556
+ this.emit("opencode.command_executed", {
557
+ "gen_ai.conversation.id": sessionID,
558
+ "opencode.command.name": name,
559
+ // Whether arguments were passed, never what they were.
560
+ "opencode.command.has_arguments": Boolean(args && args.trim() !== "")
561
+ }, SeverityNumber.INFO, state.context);
562
+ }
563
+ onTodoUpdated(sessionID, todos) {
564
+ const state = this.session(sessionID);
565
+ const byStatus = {};
566
+ for (const todo of todos ?? []) {
567
+ const status = todo?.status ?? "unknown";
568
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
569
+ }
570
+ this.emit("opencode.todo_updated", {
571
+ "gen_ai.conversation.id": sessionID,
572
+ "opencode.todo.total": todos?.length ?? 0,
573
+ ...Object.fromEntries(Object.entries(byStatus).map(([status, count]) => [`opencode.todo.${status}`, count]))
574
+ }, SeverityNumber.INFO, state.context);
575
+ }
576
+ // ------------------------------------------------------------------ hooks
577
+ onChatMessage(input, output) {
578
+ const session = this.session(input.sessionID);
579
+ const parts = output?.parts ?? [];
580
+ // Length, not content — see the no-content-capture decision.
581
+ let promptLength = 0;
582
+ const partTypes = {};
583
+ for (const part of parts) {
584
+ const typed = part;
585
+ const type = typed.type ?? "unknown";
586
+ partTypes[type] = (partTypes[type] ?? 0) + 1;
587
+ if (typeof typed.text === "string") {
588
+ promptLength += typed.text.length;
589
+ }
590
+ }
591
+ this.emit("opencode.user_prompt", {
592
+ "gen_ai.conversation.id": input.sessionID,
593
+ "opencode.prompt.length": promptLength,
594
+ "opencode.prompt.part_count": parts.length,
595
+ ...Object.fromEntries(Object.entries(partTypes).map(([type, count]) => [`opencode.prompt.parts.${type}`, count])),
596
+ ...input.agent ? { "gen_ai.agent.name": input.agent } : {},
597
+ ...input.model?.modelID ? { "gen_ai.request.model": input.model.modelID } : {},
598
+ ...input.model?.providerID ? { "gen_ai.provider.name": input.model.providerID } : {}
599
+ }, SeverityNumber.INFO, session.context);
600
+ }
601
+ /**
602
+ * `chat.params` runs immediately before the provider request. Opening the
603
+ * `chat` span here rather than at the first `message.updated` is what makes
604
+ * trace-context propagation work at all for the first request of a turn —
605
+ * otherwise the fetch happens while no chat span exists.
606
+ */
607
+ onChatParams(input, output) {
608
+ const session = this.session(input.sessionID);
609
+ const model = input.model;
610
+ const modelId = model?.id ?? model?.modelID;
611
+ const params = {};
612
+ const numeric = [
613
+ ["gen_ai.request.temperature", output?.temperature],
614
+ ["gen_ai.request.top_p", output?.topP],
615
+ ["gen_ai.request.top_k", output?.topK],
616
+ ["gen_ai.request.max_tokens", output?.maxOutputTokens]
617
+ ];
618
+ for (const [key, value] of numeric) {
619
+ if (typeof value === "number" && Number.isFinite(value)) {
620
+ params[key] = value;
621
+ }
622
+ }
623
+ // A previous pending span for this session means the last request never
624
+ // produced an assistant message (aborted, or errored before streaming).
625
+ this.pendingChats.get(input.sessionID)?.span?.end();
626
+ this.pendingChats.set(input.sessionID, {
627
+ sessionID: input.sessionID,
628
+ params,
629
+ span: this.deps.providers.tracer?.startSpan(`chat ${modelId ?? "unknown"}`, {
630
+ kind: SpanKind.CLIENT,
631
+ startTime: this.now(),
632
+ attributes: {
633
+ "gen_ai.operation.name": "chat",
634
+ "gen_ai.conversation.id": input.sessionID,
635
+ ...modelId ? { "gen_ai.request.model": modelId } : {},
636
+ ...input.agent ? { "gen_ai.agent.name": input.agent } : {},
637
+ ...params
638
+ }
639
+ }, session.context)
640
+ });
641
+ }
642
+ /** Assistant text finished streaming — record its size, never its content. */
643
+ onTextComplete(input, output) {
644
+ if (typeof output?.text !== "string") {
645
+ return;
646
+ }
647
+ const previous = this.responseLengths.get(input.messageID) ?? 0;
648
+ this.responseLengths.set(input.messageID, previous + output.text.length);
649
+ }
650
+ /**
651
+ * Every permission evaluation passes through here, including the ones config
652
+ * auto-resolves. Only an already-decided prompt is counted now — an `ask`
653
+ * waits for `permission.replied`, so the two paths never double-count. Without
654
+ * this hook, auto-allowed permissions were invisible and the decision counter
655
+ * silently undercounted.
656
+ */
657
+ onPermissionAsk(input, output) {
658
+ const decision = output?.status;
659
+ if (!decision || decision === "ask") {
660
+ return;
661
+ }
662
+ this.autoDecided.add(input.id);
663
+ this.recordDecision(input.sessionID, input.id, decision, "auto", input.type);
664
+ }
665
+ /** Compaction finished — `overflow` says whether the context forced it. */
666
+ onCompactionAutocontinue(input, output) {
667
+ const state = this.session(input.sessionID);
668
+ this.emit("opencode.compaction_autocontinue", {
669
+ "gen_ai.conversation.id": input.sessionID,
670
+ "opencode.compaction.overflow": Boolean(input.overflow),
671
+ "opencode.compaction.autocontinue_enabled": output?.enabled !== false,
672
+ ...input.agent ? { "gen_ai.agent.name": input.agent } : {}
673
+ }, SeverityNumber.INFO, state.context);
674
+ }
675
+ onToolBefore(input) {
676
+ const session = this.session(input.sessionID);
677
+ const filtered = this.deps.config.filteredTools.has(input.tool);
678
+ this.tools.set(input.callID, {
679
+ tool: input.tool,
680
+ sessionID: input.sessionID,
681
+ startedAt: this.now(),
682
+ // Filtered tools still produce metrics; they just skip the span, which is
683
+ // what keeps a `read`-heavy session's trace readable.
684
+ span: filtered ? undefined : this.deps.providers.tracer?.startSpan(`execute_tool ${input.tool}`, {
685
+ kind: SpanKind.INTERNAL,
686
+ startTime: this.now(),
687
+ attributes: {
688
+ "gen_ai.operation.name": "execute_tool",
689
+ "gen_ai.tool.name": input.tool,
690
+ "gen_ai.tool.call.id": input.callID,
691
+ "gen_ai.conversation.id": input.sessionID
692
+ }
693
+ }, session.context)
694
+ });
695
+ }
696
+ onToolAfter(input, output) {
697
+ this.finishTool(input.callID, {
698
+ tool: input.tool,
699
+ sessionID: input.sessionID,
700
+ status: "ok",
701
+ outputSize: typeof output?.output === "string" ? output.output.length : undefined
702
+ });
703
+ }
704
+ /**
705
+ * Record a tool's terminal outcome exactly once. Both `tool.execute.after`
706
+ * and the tool part reaching a terminal state report it, and which arrives
707
+ * depends on whether the tool succeeded — first writer wins, so a failing
708
+ * tool (no `after` hook) is still counted.
709
+ */
710
+ finishTool(callID, outcome) {
711
+ if (this.finishedTools.has(callID)) {
712
+ return;
713
+ }
714
+ const started = this.tools.get(callID);
715
+ this.tools.delete(callID);
716
+ const tool = started?.tool ?? outcome.tool;
717
+ const sessionID = started?.sessionID || outcome.sessionID;
718
+ this.finishedTools.set(callID, sessionID);
719
+ const durationMs = outcome.durationMs ?? (started ? Math.max(0, this.now() - started.startedAt) : undefined);
720
+ this.instruments?.toolInvocations.add(1, {
721
+ "gen_ai.tool.name": tool,
722
+ "opencode.tool.status": outcome.status,
723
+ ...this.metricSession(sessionID)
724
+ });
725
+ const attributes = {
726
+ "gen_ai.tool.name": tool,
727
+ "gen_ai.tool.call.id": callID,
728
+ "opencode.tool.status": outcome.status,
729
+ ...sessionID ? { "gen_ai.conversation.id": sessionID } : {},
730
+ ...durationMs !== undefined ? { "opencode.tool.duration_ms": durationMs } : {},
731
+ ...outcome.outputSize !== undefined ? { "opencode.tool.output.size": outcome.outputSize } : {}
732
+ };
733
+ if (started?.span) {
734
+ started.span.setAttributes(attributes);
735
+ if (outcome.status === "error") {
736
+ started.span.setStatus({ code: SpanStatusCode.ERROR });
737
+ }
738
+ started.span.end();
739
+ }
740
+ this.emit("opencode.tool_result", attributes, outcome.status === "error" ? SeverityNumber.ERROR : SeverityNumber.INFO, sessionID ? this.session(sessionID).context : undefined);
741
+ }
742
+ // -------------------------------------------------------------- lifecycle
743
+ /**
744
+ * The context of the single in-flight chat, or `undefined` when zero or more
745
+ * than one is running. Ambiguity yields no trace context rather than a wrong
746
+ * parent — a missing link is recoverable, a fabricated one is not.
747
+ */
748
+ currentChatContext() {
749
+ // A pending chat (opened by `chat.params`) counts: that is precisely the
750
+ // window in which the provider request is actually made.
751
+ const live = [...this.pendingChats.values(), ...this.chats.values()];
752
+ if (live.length !== 1) {
753
+ return undefined;
754
+ }
755
+ const [chat] = live;
756
+ return chat?.span ? trace.setSpan(ROOT_CONTEXT, chat.span) : undefined;
757
+ }
758
+ /**
759
+ * Sizes of the in-memory bookkeeping. Exposed because these maps are the only
760
+ * unbounded thing the plugin holds — a long-running OpenCode server that
761
+ * never emitted `session.deleted` is exactly where a leak would show up, and
762
+ * "how big is it" should be answerable without a heap dump.
763
+ */
764
+ pendingStateSize() {
765
+ return {
766
+ sessions: this.sessions.size,
767
+ chats: this.chats.size,
768
+ pendingChats: this.pendingChats.size,
769
+ tools: this.tools.size,
770
+ finalizedMessages: this.finalizedMessages.size,
771
+ finishedTools: this.finishedTools.size,
772
+ permissions: this.permissions.size,
773
+ diffs: this.diffs.size
774
+ };
775
+ }
776
+ async shutdown() {
777
+ for (const [sessionID, state] of this.sessions) {
778
+ this.settleActiveTime(sessionID, state);
779
+ state.span?.end();
780
+ }
781
+ this.sessions.clear();
782
+ for (const chat of [...this.chats.values(), ...this.pendingChats.values()]) {
783
+ chat.span?.end();
784
+ }
785
+ this.chats.clear();
786
+ this.pendingChats.clear();
787
+ for (const tool of this.tools.values()) {
788
+ tool.span?.end();
789
+ }
790
+ this.tools.clear();
791
+ await this.deps.providers.shutdown();
792
+ }
793
+ }
794
+
795
+ //# sourceMappingURL=recorder.js.map