@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +237 -0
- package/dist/config.js.map +1 -0
- package/dist/deferred.d.ts +22 -0
- package/dist/deferred.js +29 -0
- package/dist/deferred.js.map +1 -0
- package/dist/export-logging.d.ts +28 -0
- package/dist/export-logging.js +74 -0
- package/dist/export-logging.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments.d.ts +29 -0
- package/dist/instruments.js +103 -0
- package/dist/instruments.js.map +1 -0
- package/dist/lib.d.ts +11 -0
- package/dist/lib.js +12 -0
- package/dist/lib.js.map +1 -0
- package/dist/logging.d.ts +16 -0
- package/dist/logging.js +69 -0
- package/dist/logging.js.map +1 -0
- package/dist/propagation.d.ts +22 -0
- package/dist/propagation.js +57 -0
- package/dist/propagation.js.map +1 -0
- package/dist/providers.d.ts +66 -0
- package/dist/providers.js +242 -0
- package/dist/providers.js.map +1 -0
- package/dist/recorder.d.ts +166 -0
- package/dist/recorder.js +795 -0
- package/dist/recorder.js.map +1 -0
- package/dist/token-source.d.ts +59 -0
- package/dist/token-source.js +125 -0
- package/dist/token-source.js.map +1 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/vcs.d.ts +63 -0
- package/dist/vcs.js +188 -0
- package/dist/vcs.js.map +1 -0
- package/package.json +79 -0
package/dist/recorder.js
ADDED
|
@@ -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
|