@softprobe/opencode-plugin 0.1.0
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/README.md +70 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +70 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +235 -0
- package/dist/softprobe.d.ts +119 -0
- package/dist/softprobe.js +707 -0
- package/dist/tool-kind.d.ts +3 -0
- package/dist/tool-kind.js +27 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.js +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { SoftprobeClient, recordToolCalls, } from "@softprobe/tracing";
|
|
2
|
+
import { inferToolKind } from "./tool-kind.js";
|
|
3
|
+
function asJson(value) {
|
|
4
|
+
if (value === null ||
|
|
5
|
+
typeof value === "string" ||
|
|
6
|
+
typeof value === "number" ||
|
|
7
|
+
typeof value === "boolean") {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(value) || (value && typeof value === "object")) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
function formatUserParts(parts) {
|
|
16
|
+
const formatted = parts.map((part) => {
|
|
17
|
+
if (part.type === "text") {
|
|
18
|
+
return { type: part.type, text: part.text ?? "" };
|
|
19
|
+
}
|
|
20
|
+
if (part.type === "file") {
|
|
21
|
+
const out = { type: part.type };
|
|
22
|
+
if (part.filename !== undefined)
|
|
23
|
+
out.filename = part.filename;
|
|
24
|
+
if (part.url !== undefined)
|
|
25
|
+
out.url = part.url;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
if (part.type === "agent") {
|
|
29
|
+
const out = { type: part.type };
|
|
30
|
+
if (part.name !== undefined)
|
|
31
|
+
out.name = part.name;
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
if (part.type === "subtask") {
|
|
35
|
+
const out = { type: part.type };
|
|
36
|
+
if (part.prompt !== undefined)
|
|
37
|
+
out.prompt = part.prompt;
|
|
38
|
+
if (part.agent !== undefined)
|
|
39
|
+
out.agent = part.agent;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
if (part.type === "tool") {
|
|
43
|
+
const out = { type: part.type };
|
|
44
|
+
if (part.tool !== undefined)
|
|
45
|
+
out.tool = part.tool;
|
|
46
|
+
if (part.state?.title !== undefined)
|
|
47
|
+
out.title = part.state.title;
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
return { type: part.type };
|
|
51
|
+
});
|
|
52
|
+
return { role: "user", parts: formatted };
|
|
53
|
+
}
|
|
54
|
+
function getSessionErrorMessage(error) {
|
|
55
|
+
if (typeof error.message === "string" && error.message)
|
|
56
|
+
return error.message;
|
|
57
|
+
if (typeof error.data?.message === "string" && error.data.message) {
|
|
58
|
+
return error.data.message;
|
|
59
|
+
}
|
|
60
|
+
return error.name;
|
|
61
|
+
}
|
|
62
|
+
function getCompletedReasoningTimestamp(part) {
|
|
63
|
+
if (typeof part.time?.completed === "number")
|
|
64
|
+
return part.time.completed;
|
|
65
|
+
if (typeof part.time?.end === "number")
|
|
66
|
+
return part.time.end;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Maps OpenCode session hooks to Softprobe Part A observations.
|
|
71
|
+
* Construct with an injected SoftprobeClient (tests) or via {@link createSoftprobeSessionTracer}.
|
|
72
|
+
*/
|
|
73
|
+
export class SoftprobeSessionTracer {
|
|
74
|
+
client;
|
|
75
|
+
userId;
|
|
76
|
+
abortedSessions = new Set();
|
|
77
|
+
tracedMessageIds = new Set();
|
|
78
|
+
tracedGenerationIds = new Set();
|
|
79
|
+
tracedEventIds = new Set();
|
|
80
|
+
tracedReasoningIds = new Set();
|
|
81
|
+
tracedToolCallIds = new Set();
|
|
82
|
+
pendingReasoningByMessageId = new Map();
|
|
83
|
+
assistantParts = new Map();
|
|
84
|
+
generationByMessageId = new Map();
|
|
85
|
+
turnByMessageId = new Map();
|
|
86
|
+
latestTurnBySession = new Map();
|
|
87
|
+
activeTools = new Map();
|
|
88
|
+
activeGenerations = new Map();
|
|
89
|
+
generationParents = new Map();
|
|
90
|
+
constructor(client, options = {}) {
|
|
91
|
+
this.client = client;
|
|
92
|
+
this.userId = options.userId;
|
|
93
|
+
}
|
|
94
|
+
clearTraceState() {
|
|
95
|
+
this.assistantParts.clear();
|
|
96
|
+
this.abortedSessions.clear();
|
|
97
|
+
this.tracedEventIds.clear();
|
|
98
|
+
this.tracedReasoningIds.clear();
|
|
99
|
+
this.tracedToolCallIds.clear();
|
|
100
|
+
this.pendingReasoningByMessageId.clear();
|
|
101
|
+
this.generationByMessageId.clear();
|
|
102
|
+
this.generationParents.clear();
|
|
103
|
+
this.turnByMessageId.clear();
|
|
104
|
+
this.latestTurnBySession.clear();
|
|
105
|
+
}
|
|
106
|
+
endActiveToolObservations(sessionID, error) {
|
|
107
|
+
for (const [callID, active] of this.activeTools) {
|
|
108
|
+
if (sessionID && active.sessionID !== sessionID)
|
|
109
|
+
continue;
|
|
110
|
+
if (error && error.name !== "MessageAbortedError") {
|
|
111
|
+
active.observation.recordException(new Error(getSessionErrorMessage(error)));
|
|
112
|
+
active.observation.end({
|
|
113
|
+
attributes: { "sp.tool.status": "error" },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (error?.name === "MessageAbortedError") {
|
|
117
|
+
active.observation.end({
|
|
118
|
+
attributes: { "sp.tool.status": "cancelled" },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
active.observation.end();
|
|
123
|
+
}
|
|
124
|
+
this.activeTools.delete(callID);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
endActiveGenerationSteps(sessionID, error) {
|
|
128
|
+
for (const [activeSessionID, step] of this.activeGenerations) {
|
|
129
|
+
if (sessionID && activeSessionID !== sessionID)
|
|
130
|
+
continue;
|
|
131
|
+
if (error && error.name !== "MessageAbortedError") {
|
|
132
|
+
step.observation.recordException(new Error(getSessionErrorMessage(error)));
|
|
133
|
+
step.observation.end();
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
step.observation.end();
|
|
137
|
+
}
|
|
138
|
+
this.activeGenerations.delete(activeSessionID);
|
|
139
|
+
this.generationParents.delete(activeSessionID);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
endActiveTurnObservations() {
|
|
143
|
+
for (const turn of new Set(this.latestTurnBySession.values())) {
|
|
144
|
+
turn.observation.end();
|
|
145
|
+
}
|
|
146
|
+
this.turnByMessageId.clear();
|
|
147
|
+
this.latestTurnBySession.clear();
|
|
148
|
+
}
|
|
149
|
+
finalizeSessionTracing() {
|
|
150
|
+
this.endActiveToolObservations();
|
|
151
|
+
this.endActiveGenerationSteps();
|
|
152
|
+
this.endActiveTurnObservations();
|
|
153
|
+
this.clearTraceState();
|
|
154
|
+
}
|
|
155
|
+
async forceFlush() {
|
|
156
|
+
await this.client.forceFlush();
|
|
157
|
+
}
|
|
158
|
+
async shutdown() {
|
|
159
|
+
this.finalizeSessionTracing();
|
|
160
|
+
await this.client.shutdown();
|
|
161
|
+
}
|
|
162
|
+
getTurn(sessionID, messageID) {
|
|
163
|
+
return ((messageID ? this.turnByMessageId.get(messageID) : undefined) ??
|
|
164
|
+
this.latestTurnBySession.get(sessionID));
|
|
165
|
+
}
|
|
166
|
+
generationParent(sessionID) {
|
|
167
|
+
return (this.activeGenerations.get(sessionID)?.observation ??
|
|
168
|
+
this.generationParents.get(sessionID));
|
|
169
|
+
}
|
|
170
|
+
sessionAttrs(sessionID) {
|
|
171
|
+
return {
|
|
172
|
+
sessionId: sessionID,
|
|
173
|
+
...(this.userId ? { userId: this.userId } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
traceUserMessage(input) {
|
|
177
|
+
if (input.messageID && this.tracedMessageIds.has(input.messageID)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.abortedSessions.delete(input.sessionID);
|
|
181
|
+
const formattedInput = formatUserParts(input.parts);
|
|
182
|
+
if (input.messageID) {
|
|
183
|
+
this.tracedMessageIds.add(input.messageID);
|
|
184
|
+
}
|
|
185
|
+
const previous = this.latestTurnBySession.get(input.sessionID);
|
|
186
|
+
if (previous) {
|
|
187
|
+
previous.observation.end();
|
|
188
|
+
this.latestTurnBySession.delete(input.sessionID);
|
|
189
|
+
}
|
|
190
|
+
this.generationParents.delete(input.sessionID);
|
|
191
|
+
const turn = this.client.startAgent({
|
|
192
|
+
name: "opencode.turn",
|
|
193
|
+
...this.sessionAttrs(input.sessionID),
|
|
194
|
+
input: formattedInput,
|
|
195
|
+
metadata: {
|
|
196
|
+
messageID: input.messageID ?? null,
|
|
197
|
+
agent: input.agent ?? null,
|
|
198
|
+
providerID: input.model?.providerID ?? null,
|
|
199
|
+
modelID: input.model?.modelID ?? null,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
const observation = {
|
|
203
|
+
observation: turn,
|
|
204
|
+
sessionID: input.sessionID,
|
|
205
|
+
messageID: input.messageID,
|
|
206
|
+
};
|
|
207
|
+
if (input.messageID) {
|
|
208
|
+
this.turnByMessageId.set(input.messageID, observation);
|
|
209
|
+
}
|
|
210
|
+
this.latestTurnBySession.set(input.sessionID, observation);
|
|
211
|
+
const userEvent = this.client.startEvent({
|
|
212
|
+
name: "opencode.message.user",
|
|
213
|
+
parent: turn,
|
|
214
|
+
...this.sessionAttrs(input.sessionID),
|
|
215
|
+
input: formattedInput,
|
|
216
|
+
metadata: {
|
|
217
|
+
messageID: input.messageID ?? null,
|
|
218
|
+
agent: input.agent ?? null,
|
|
219
|
+
providerID: input.model?.providerID ?? null,
|
|
220
|
+
modelID: input.model?.modelID ?? null,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
userEvent.end();
|
|
224
|
+
}
|
|
225
|
+
startActiveGenerationStep(input) {
|
|
226
|
+
const existing = this.activeGenerations.get(input.sessionID);
|
|
227
|
+
if (existing && !existing.model) {
|
|
228
|
+
existing.observation.update({
|
|
229
|
+
model: input.model.id,
|
|
230
|
+
provider: input.model.providerID,
|
|
231
|
+
});
|
|
232
|
+
existing.observation.setAttributes({
|
|
233
|
+
"sp.metadata.agent": input.agent,
|
|
234
|
+
"sp.metadata.providerID": input.model.providerID,
|
|
235
|
+
...(input.model.variant
|
|
236
|
+
? { "sp.metadata.variant": input.model.variant }
|
|
237
|
+
: {}),
|
|
238
|
+
...(input.snapshot ? { "sp.metadata.snapshot": input.snapshot } : {}),
|
|
239
|
+
});
|
|
240
|
+
this.activeGenerations.set(input.sessionID, {
|
|
241
|
+
...existing,
|
|
242
|
+
agent: input.agent,
|
|
243
|
+
model: input.model,
|
|
244
|
+
started: input.started,
|
|
245
|
+
snapshot: input.snapshot,
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (existing) {
|
|
250
|
+
existing.observation.end({ endTime: input.started });
|
|
251
|
+
this.activeGenerations.delete(input.sessionID);
|
|
252
|
+
}
|
|
253
|
+
const turn = this.getTurn(input.sessionID);
|
|
254
|
+
if (!turn)
|
|
255
|
+
return;
|
|
256
|
+
const generation = this.client.startGeneration({
|
|
257
|
+
name: "opencode.generation",
|
|
258
|
+
parent: turn.observation,
|
|
259
|
+
...this.sessionAttrs(input.sessionID),
|
|
260
|
+
startTime: input.started,
|
|
261
|
+
model: input.model.id,
|
|
262
|
+
provider: input.model.providerID,
|
|
263
|
+
operationName: "chat",
|
|
264
|
+
metadata: {
|
|
265
|
+
agent: input.agent,
|
|
266
|
+
providerID: input.model.providerID,
|
|
267
|
+
variant: input.model.variant ?? null,
|
|
268
|
+
snapshot: input.snapshot ?? null,
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
this.activeGenerations.set(input.sessionID, {
|
|
272
|
+
observation: generation,
|
|
273
|
+
sessionID: input.sessionID,
|
|
274
|
+
agent: input.agent,
|
|
275
|
+
model: input.model,
|
|
276
|
+
started: input.started,
|
|
277
|
+
snapshot: input.snapshot,
|
|
278
|
+
});
|
|
279
|
+
this.generationParents.set(input.sessionID, generation);
|
|
280
|
+
}
|
|
281
|
+
rememberAssistantPart(part) {
|
|
282
|
+
if (!part.id || !part.messageID)
|
|
283
|
+
return;
|
|
284
|
+
const parts = this.assistantParts.get(part.messageID) ?? new Map();
|
|
285
|
+
parts.set(part.id, part);
|
|
286
|
+
this.assistantParts.set(part.messageID, parts);
|
|
287
|
+
}
|
|
288
|
+
getAssistantText(messageID) {
|
|
289
|
+
return Array.from(this.assistantParts.get(messageID)?.values() ?? [])
|
|
290
|
+
.filter((part) => part.type === "text" && Boolean(part.text))
|
|
291
|
+
.map((part) => part.text ?? "")
|
|
292
|
+
.join("");
|
|
293
|
+
}
|
|
294
|
+
getAssistantToolCalls(messageID) {
|
|
295
|
+
return Array.from(this.assistantParts.get(messageID)?.values() ?? [])
|
|
296
|
+
.filter((part) => part.type === "tool" && part.tool)
|
|
297
|
+
.map((part, index) => ({
|
|
298
|
+
id: part.id,
|
|
299
|
+
name: part.tool,
|
|
300
|
+
arguments: {},
|
|
301
|
+
index,
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
flushPendingReasoning(messageID, parent) {
|
|
305
|
+
const pending = this.pendingReasoningByMessageId.get(messageID);
|
|
306
|
+
this.pendingReasoningByMessageId.delete(messageID);
|
|
307
|
+
if (!pending)
|
|
308
|
+
return;
|
|
309
|
+
for (const part of pending.values()) {
|
|
310
|
+
const completed = getCompletedReasoningTimestamp(part);
|
|
311
|
+
if (completed === undefined || !part.text?.trim())
|
|
312
|
+
continue;
|
|
313
|
+
this.traceReasoning({
|
|
314
|
+
reasoningID: part.id ?? `part-${completed}`,
|
|
315
|
+
sessionID: part.sessionID ?? "",
|
|
316
|
+
timestamp: completed,
|
|
317
|
+
text: part.text,
|
|
318
|
+
messageID: part.messageID,
|
|
319
|
+
source: "message.part.updated",
|
|
320
|
+
parent,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
traceGeneration(input) {
|
|
325
|
+
if (this.abortedSessions.has(input.sessionID))
|
|
326
|
+
return;
|
|
327
|
+
if (this.tracedGenerationIds.has(input.messageID))
|
|
328
|
+
return;
|
|
329
|
+
this.tracedGenerationIds.add(input.messageID);
|
|
330
|
+
const text = this.getAssistantText(input.messageID);
|
|
331
|
+
const output = text ? { text } : undefined;
|
|
332
|
+
const turn = this.getTurn(input.sessionID, input.parentID);
|
|
333
|
+
const toolCalls = this.getAssistantToolCalls(input.messageID);
|
|
334
|
+
if (input.mode !== "compaction" && turn && output) {
|
|
335
|
+
turn.observation.update({ output });
|
|
336
|
+
}
|
|
337
|
+
const totalTokens = input.tokens.total ??
|
|
338
|
+
input.tokens.input + input.tokens.output + input.tokens.reasoning;
|
|
339
|
+
const step = this.activeGenerations.get(input.sessionID);
|
|
340
|
+
if (step) {
|
|
341
|
+
step.observation.update({
|
|
342
|
+
model: input.modelID,
|
|
343
|
+
provider: input.providerID,
|
|
344
|
+
usage: {
|
|
345
|
+
inputTokens: input.tokens.input,
|
|
346
|
+
outputTokens: input.tokens.output,
|
|
347
|
+
totalTokens,
|
|
348
|
+
},
|
|
349
|
+
cost: { total: input.cost },
|
|
350
|
+
finishReasons: input.finish ? [input.finish] : undefined,
|
|
351
|
+
output,
|
|
352
|
+
completionEvent: output,
|
|
353
|
+
});
|
|
354
|
+
step.observation.setAttributes({
|
|
355
|
+
"sp.metadata.messageID": input.messageID,
|
|
356
|
+
"sp.metadata.parentID": input.parentID,
|
|
357
|
+
"sp.metadata.providerID": input.providerID,
|
|
358
|
+
"sp.metadata.mode": input.mode,
|
|
359
|
+
"sp.metadata.reasoningTokens": input.tokens.reasoning,
|
|
360
|
+
"sp.metadata.cacheRead": input.tokens.cache.read,
|
|
361
|
+
"sp.metadata.cacheWrite": input.tokens.cache.write,
|
|
362
|
+
...(input.agent ? { "sp.metadata.agent": input.agent } : {}),
|
|
363
|
+
...(input.finish ? { "sp.metadata.finish": input.finish } : {}),
|
|
364
|
+
...(step.model?.variant
|
|
365
|
+
? { "sp.metadata.variant": step.model.variant }
|
|
366
|
+
: {}),
|
|
367
|
+
...(step.snapshot ? { "sp.metadata.snapshot": step.snapshot } : {}),
|
|
368
|
+
});
|
|
369
|
+
if (toolCalls.length > 0) {
|
|
370
|
+
recordToolCalls(step.observation, toolCalls);
|
|
371
|
+
}
|
|
372
|
+
this.generationByMessageId.set(input.messageID, step.observation);
|
|
373
|
+
this.flushPendingReasoning(input.messageID, step.observation);
|
|
374
|
+
step.observation.end({ endTime: input.completed });
|
|
375
|
+
this.activeGenerations.delete(input.sessionID);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!turn)
|
|
379
|
+
return;
|
|
380
|
+
const generation = this.client.startGeneration({
|
|
381
|
+
name: "opencode.generation",
|
|
382
|
+
parent: turn.observation,
|
|
383
|
+
...this.sessionAttrs(input.sessionID),
|
|
384
|
+
startTime: input.created,
|
|
385
|
+
model: input.modelID,
|
|
386
|
+
provider: input.providerID,
|
|
387
|
+
operationName: "chat",
|
|
388
|
+
usage: {
|
|
389
|
+
inputTokens: input.tokens.input,
|
|
390
|
+
outputTokens: input.tokens.output,
|
|
391
|
+
totalTokens,
|
|
392
|
+
},
|
|
393
|
+
cost: { total: input.cost },
|
|
394
|
+
output,
|
|
395
|
+
completionEvent: output,
|
|
396
|
+
metadata: {
|
|
397
|
+
messageID: input.messageID,
|
|
398
|
+
parentID: input.parentID,
|
|
399
|
+
agent: input.agent ?? null,
|
|
400
|
+
providerID: input.providerID,
|
|
401
|
+
mode: input.mode,
|
|
402
|
+
finish: input.finish ?? null,
|
|
403
|
+
reasoningTokens: input.tokens.reasoning,
|
|
404
|
+
cacheRead: input.tokens.cache.read,
|
|
405
|
+
cacheWrite: input.tokens.cache.write,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
if (input.finish) {
|
|
409
|
+
generation.update({ finishReasons: [input.finish] });
|
|
410
|
+
}
|
|
411
|
+
if (toolCalls.length > 0) {
|
|
412
|
+
recordToolCalls(generation, toolCalls);
|
|
413
|
+
}
|
|
414
|
+
this.generationParents.set(input.sessionID, generation);
|
|
415
|
+
this.generationByMessageId.set(input.messageID, generation);
|
|
416
|
+
this.flushPendingReasoning(input.messageID, generation);
|
|
417
|
+
generation.end({ endTime: input.completed });
|
|
418
|
+
}
|
|
419
|
+
traceFailedGenerationStep(input) {
|
|
420
|
+
if (this.tracedGenerationIds.has(input.id))
|
|
421
|
+
return;
|
|
422
|
+
this.tracedGenerationIds.add(input.id);
|
|
423
|
+
const step = this.activeGenerations.get(input.sessionID);
|
|
424
|
+
if (step) {
|
|
425
|
+
step.observation.recordException(new Error(input.error.message));
|
|
426
|
+
if (step.agent) {
|
|
427
|
+
step.observation.setAttributes({ "sp.metadata.agent": step.agent });
|
|
428
|
+
}
|
|
429
|
+
step.observation.end({
|
|
430
|
+
endTime: input.completed,
|
|
431
|
+
output: { error: input.error },
|
|
432
|
+
});
|
|
433
|
+
this.activeGenerations.delete(input.sessionID);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const turn = this.getTurn(input.sessionID);
|
|
437
|
+
if (!turn)
|
|
438
|
+
return;
|
|
439
|
+
const generation = this.client.startGeneration({
|
|
440
|
+
name: "opencode.generation.failed",
|
|
441
|
+
parent: turn.observation,
|
|
442
|
+
...this.sessionAttrs(input.sessionID),
|
|
443
|
+
startTime: input.completed,
|
|
444
|
+
output: { error: input.error },
|
|
445
|
+
});
|
|
446
|
+
generation.recordException(new Error(input.error.message));
|
|
447
|
+
this.generationParents.set(input.sessionID, generation);
|
|
448
|
+
generation.end({ endTime: input.completed });
|
|
449
|
+
}
|
|
450
|
+
traceEvent(input) {
|
|
451
|
+
if (this.tracedEventIds.has(input.id))
|
|
452
|
+
return;
|
|
453
|
+
this.tracedEventIds.add(input.id);
|
|
454
|
+
const parent = input.parent ??
|
|
455
|
+
this.generationParent(input.sessionID) ??
|
|
456
|
+
this.getTurn(input.sessionID)?.observation;
|
|
457
|
+
const event = this.client.startEvent({
|
|
458
|
+
name: input.name,
|
|
459
|
+
...(parent ? { parent } : {}),
|
|
460
|
+
...this.sessionAttrs(input.sessionID),
|
|
461
|
+
startTime: input.timestamp,
|
|
462
|
+
input: input.input === undefined ? undefined : asJson(input.input),
|
|
463
|
+
output: input.output === undefined ? undefined : asJson(input.output),
|
|
464
|
+
metadata: input.metadata,
|
|
465
|
+
});
|
|
466
|
+
event.end({ endTime: input.timestamp });
|
|
467
|
+
}
|
|
468
|
+
traceReasoning(input) {
|
|
469
|
+
if (!input.text.trim())
|
|
470
|
+
return;
|
|
471
|
+
const key = `${input.sessionID}:${input.reasoningID}`;
|
|
472
|
+
if (this.tracedReasoningIds.has(key))
|
|
473
|
+
return;
|
|
474
|
+
this.tracedReasoningIds.add(key);
|
|
475
|
+
const parent = input.parent ??
|
|
476
|
+
(input.messageID
|
|
477
|
+
? this.generationByMessageId.get(input.messageID)
|
|
478
|
+
: undefined) ??
|
|
479
|
+
this.generationParent(input.sessionID);
|
|
480
|
+
this.traceEvent({
|
|
481
|
+
id: `reasoning:${key}`,
|
|
482
|
+
sessionID: input.sessionID,
|
|
483
|
+
name: "opencode.generation.reasoning",
|
|
484
|
+
timestamp: input.timestamp,
|
|
485
|
+
output: { text: input.text },
|
|
486
|
+
metadata: {
|
|
487
|
+
reasoningID: input.reasoningID,
|
|
488
|
+
messageID: input.messageID ?? null,
|
|
489
|
+
source: input.source,
|
|
490
|
+
},
|
|
491
|
+
parent,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
traceReasoningPart(part) {
|
|
495
|
+
const completed = getCompletedReasoningTimestamp(part);
|
|
496
|
+
if (part.type !== "reasoning" || completed === undefined || !part.text) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (!part.id || !part.sessionID || !part.messageID)
|
|
500
|
+
return;
|
|
501
|
+
const generation = this.generationByMessageId.get(part.messageID) ??
|
|
502
|
+
this.generationParent(part.sessionID);
|
|
503
|
+
if (!generation) {
|
|
504
|
+
const pending = this.pendingReasoningByMessageId.get(part.messageID) ??
|
|
505
|
+
new Map();
|
|
506
|
+
pending.set(part.id, part);
|
|
507
|
+
this.pendingReasoningByMessageId.set(part.messageID, pending);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.traceReasoning({
|
|
511
|
+
reasoningID: part.id,
|
|
512
|
+
sessionID: part.sessionID,
|
|
513
|
+
timestamp: completed,
|
|
514
|
+
text: part.text,
|
|
515
|
+
messageID: part.messageID,
|
|
516
|
+
source: "message.part.updated",
|
|
517
|
+
parent: generation,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/** Fallback when tools arrive before session.next.step.started. */
|
|
521
|
+
ensureGenerationParent(sessionID) {
|
|
522
|
+
if (this.activeGenerations.has(sessionID) ||
|
|
523
|
+
this.generationParents.has(sessionID)) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const turn = this.getTurn(sessionID);
|
|
527
|
+
if (!turn)
|
|
528
|
+
return;
|
|
529
|
+
const generation = this.client.startGeneration({
|
|
530
|
+
name: "opencode.generation",
|
|
531
|
+
parent: turn.observation,
|
|
532
|
+
...this.sessionAttrs(sessionID),
|
|
533
|
+
operationName: "chat",
|
|
534
|
+
});
|
|
535
|
+
this.activeGenerations.set(sessionID, {
|
|
536
|
+
observation: generation,
|
|
537
|
+
sessionID,
|
|
538
|
+
});
|
|
539
|
+
this.generationParents.set(sessionID, generation);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Prefer an active/synthetic generation under the agent turn
|
|
543
|
+
* (`agent → generation → tool`). Creates a generation when the turn exists
|
|
544
|
+
* but session.next.step.started never fired.
|
|
545
|
+
*/
|
|
546
|
+
toolParent(sessionID) {
|
|
547
|
+
this.ensureGenerationParent(sessionID);
|
|
548
|
+
return this.generationParent(sessionID);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Capture tool observations from message.part.updated when tool.execute.*
|
|
552
|
+
* hooks are missed (or spans from those hooks were dropped on export).
|
|
553
|
+
*/
|
|
554
|
+
traceToolPart(part) {
|
|
555
|
+
if (part.type !== "tool" || !part.tool || !part.sessionID)
|
|
556
|
+
return;
|
|
557
|
+
const callID = part.callID ?? part.id;
|
|
558
|
+
if (!callID)
|
|
559
|
+
return;
|
|
560
|
+
const status = part.state?.status;
|
|
561
|
+
if (status === "running" || status === "pending") {
|
|
562
|
+
if (this.activeTools.has(callID))
|
|
563
|
+
return;
|
|
564
|
+
this.traceToolStart({
|
|
565
|
+
sessionID: part.sessionID,
|
|
566
|
+
callID,
|
|
567
|
+
tool: part.tool,
|
|
568
|
+
args: part.state?.input,
|
|
569
|
+
started: part.state?.time?.start,
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (status !== "completed" && status !== "error")
|
|
574
|
+
return;
|
|
575
|
+
this.traceToolEnd({
|
|
576
|
+
sessionID: part.sessionID,
|
|
577
|
+
callID,
|
|
578
|
+
tool: part.tool,
|
|
579
|
+
args: part.state?.input,
|
|
580
|
+
title: part.state?.title ?? part.tool,
|
|
581
|
+
output: status === "error"
|
|
582
|
+
? (part.state?.error ?? "error")
|
|
583
|
+
: (part.state?.output ?? ""),
|
|
584
|
+
completed: part.state?.time?.end,
|
|
585
|
+
started: part.state?.time?.start,
|
|
586
|
+
status: status === "error" ? "error" : "ok",
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
traceToolStart(input) {
|
|
590
|
+
if (this.tracedToolCallIds.has(input.callID))
|
|
591
|
+
return;
|
|
592
|
+
const existing = this.activeTools.get(input.callID);
|
|
593
|
+
if (existing) {
|
|
594
|
+
// Same call already open (e.g. message.part.updated running before
|
|
595
|
+
// tool.execute.before). Keep the span; refresh args if we got better ones.
|
|
596
|
+
if (input.args !== undefined) {
|
|
597
|
+
existing.observation.update({ input: asJson(input.args) });
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const parent = this.toolParent(input.sessionID);
|
|
602
|
+
if (!parent)
|
|
603
|
+
return;
|
|
604
|
+
const started = input.started ??
|
|
605
|
+
this.activeGenerations.get(input.sessionID)?.started ??
|
|
606
|
+
Date.now();
|
|
607
|
+
const tool = this.client.startTool({
|
|
608
|
+
name: input.tool,
|
|
609
|
+
parent,
|
|
610
|
+
...this.sessionAttrs(input.sessionID),
|
|
611
|
+
startTime: started,
|
|
612
|
+
toolName: input.tool,
|
|
613
|
+
toolCallId: input.callID,
|
|
614
|
+
kind: inferToolKind(input.tool),
|
|
615
|
+
status: "ok",
|
|
616
|
+
input: asJson(input.args),
|
|
617
|
+
metadata: {
|
|
618
|
+
callID: input.callID,
|
|
619
|
+
tool: input.tool,
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
this.activeTools.set(input.callID, {
|
|
623
|
+
observation: tool,
|
|
624
|
+
sessionID: input.sessionID,
|
|
625
|
+
tool: input.tool,
|
|
626
|
+
started,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
traceToolEnd(input) {
|
|
630
|
+
if (this.tracedToolCallIds.has(input.callID)) {
|
|
631
|
+
this.activeTools.delete(input.callID);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (!this.activeTools.has(input.callID)) {
|
|
635
|
+
this.traceToolStart({
|
|
636
|
+
sessionID: input.sessionID,
|
|
637
|
+
callID: input.callID,
|
|
638
|
+
tool: input.tool,
|
|
639
|
+
args: input.args,
|
|
640
|
+
started: input.started,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const active = this.activeTools.get(input.callID);
|
|
644
|
+
if (!active)
|
|
645
|
+
return;
|
|
646
|
+
const toolStatus = input.status ?? "ok";
|
|
647
|
+
active.observation.setAttributes({
|
|
648
|
+
"sp.metadata.callID": input.callID,
|
|
649
|
+
"sp.metadata.tool": input.tool,
|
|
650
|
+
"sp.tool.status": toolStatus,
|
|
651
|
+
});
|
|
652
|
+
active.observation.end({
|
|
653
|
+
output: { title: input.title, output: input.output },
|
|
654
|
+
endTime: input.completed ?? Date.now(),
|
|
655
|
+
...(toolStatus === "error"
|
|
656
|
+
? { statusMessage: input.output || "tool error" }
|
|
657
|
+
: {}),
|
|
658
|
+
});
|
|
659
|
+
this.activeTools.delete(input.callID);
|
|
660
|
+
this.tracedToolCallIds.add(input.callID);
|
|
661
|
+
}
|
|
662
|
+
traceSessionError(input) {
|
|
663
|
+
this.endActiveToolObservations(input.sessionID, input.error);
|
|
664
|
+
this.endActiveGenerationSteps(input.sessionID, input.error);
|
|
665
|
+
if (input.error?.name === "MessageAbortedError") {
|
|
666
|
+
this.abortedSessions.add(input.sessionID);
|
|
667
|
+
}
|
|
668
|
+
const turn = this.getTurn(input.sessionID);
|
|
669
|
+
if (!turn) {
|
|
670
|
+
this.generationParents.delete(input.sessionID);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (input.error) {
|
|
674
|
+
turn.observation.update({
|
|
675
|
+
output: { error: asJson(input.error) },
|
|
676
|
+
});
|
|
677
|
+
if (input.error.name !== "MessageAbortedError") {
|
|
678
|
+
turn.observation.recordException(new Error(getSessionErrorMessage(input.error)));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
turn.observation.end();
|
|
682
|
+
if (turn.messageID)
|
|
683
|
+
this.turnByMessageId.delete(turn.messageID);
|
|
684
|
+
this.latestTurnBySession.delete(input.sessionID);
|
|
685
|
+
this.generationParents.delete(input.sessionID);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
export function createSoftprobeSessionTracer(options) {
|
|
689
|
+
const client = new SoftprobeClient({
|
|
690
|
+
...options,
|
|
691
|
+
serviceName: options.serviceName ?? "opencode",
|
|
692
|
+
// OpenCode already owns experimental OTEL; keep Softprobe spans on a
|
|
693
|
+
// private provider so we never replace or disable the host tracer.
|
|
694
|
+
registerProvider: options.registerProvider ?? false,
|
|
695
|
+
disableGlobalTracerOnShutdown: options.disableGlobalTracerOnShutdown ?? false,
|
|
696
|
+
// Batch + retry: one HTTP POST per span end to thelake (Cloudflare) was
|
|
697
|
+
// timing out and dropping generations/tools while smaller events landed.
|
|
698
|
+
useSimpleProcessor: options.useSimpleProcessor ?? false,
|
|
699
|
+
timeoutMs: options.timeoutMs ?? 30_000,
|
|
700
|
+
headers: {
|
|
701
|
+
// Cloudflare edge has blocked empty/bot-like UAs with 403 on some paths.
|
|
702
|
+
"User-Agent": "SoftprobeOpenCodePlugin/0.1",
|
|
703
|
+
...(options.headers ?? {}),
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
return new SoftprobeSessionTracer(client, { userId: options.userId });
|
|
707
|
+
}
|