@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.
@@ -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
+ }