1717// - `pino` logger — opencode plugins log via `client.app.log`; the plugin passes
1818// in a logger callback.
1919
20- import { type Context , type Span , SpanStatusCode , trace } from "@opentelemetry/api"
20+ import { type Context , type Span , SpanStatusCode , TraceFlags , trace } from "@opentelemetry/api"
21+ import { ExportResultCode } from "@opentelemetry/core"
2122import {
2223 BatchSpanProcessor ,
2324 type ReadableSpan ,
@@ -34,6 +35,7 @@ import {
3435 SPAN_SDK_VERSION ,
3536} from "./attributes"
3637import { getParentSpanId , makeSpanOtelV2Compatible , type OTelSpanCompat } from "./compat"
38+ import { boundedSpan , encodedBytes , QUEUE_BYTES , QUEUE_RECORDS } from "./budget"
3739import { sessionCurrentTurnSpan } from "./state"
3840import { otelSpanIdToUUID , type StringUUID } from "./utils"
3941
@@ -44,16 +46,55 @@ type LogFn = (level: "debug" | "info" | "warn" | "error", message: string) => vo
4446
4547export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
4648 private inner : BatchSpanProcessor
49+ private pendingBytes = 0
50+ private pendingRecords = 0
51+ private sizes = new WeakMap < ReadableSpan , number > ( )
4752 private readonly spanIdToPath = new Map < string , string [ ] > ( )
4853 private readonly spanIdLists = new Map < string , StringUUID [ ] > ( )
4954 private readonly spawningSpanIdToToolUseId : Record < string , string > = { }
5055 private readonly log : LogFn
5156
5257 constructor ( options : { exporter : SpanExporter ; log ?: LogFn } ) {
53- this . inner = new BatchSpanProcessor ( options . exporter , {
54- maxExportBatchSize : 512 ,
55- exportTimeoutMillis : 30000 ,
56- } )
58+ this . inner = new BatchSpanProcessor (
59+ {
60+ export : ( spans , callback ) => {
61+ let completed = false
62+ const finish : typeof callback = ( result ) => {
63+ if ( completed ) return
64+ completed = true
65+ clearTimeout ( timer )
66+ for ( const span of spans ) {
67+ this . pendingBytes -= this . sizes . get ( span ) ?? 0
68+ this . pendingRecords --
69+ this . sizes . delete ( span )
70+ }
71+ callback ( result )
72+ }
73+ // SDK timeout does not invoke the exporter callback; release our budget too.
74+ const timer = setTimeout (
75+ ( ) =>
76+ finish ( {
77+ code : ExportResultCode . FAILED ,
78+ error : new Error ( "Diagnostic export timed out" ) ,
79+ } ) ,
80+ 10000 ,
81+ )
82+ try {
83+ options . exporter . export ( spans , finish )
84+ } catch {
85+ finish ( { code : ExportResultCode . FAILED , error : new Error ( "Diagnostic export failed" ) } )
86+ }
87+ } ,
88+ shutdown : ( ) => options . exporter . shutdown ( ) ,
89+ forceFlush : ( ) => options . exporter . forceFlush ?.( ) ?? Promise . resolve ( ) ,
90+ } ,
91+ {
92+ // Sixteen 64 KiB records fit in 1 MiB, including each record's OTLP envelope.
93+ maxExportBatchSize : 16 ,
94+ maxQueueSize : QUEUE_RECORDS ,
95+ exportTimeoutMillis : 10000 ,
96+ } ,
97+ )
5798 this . log = options . log ?? ( ( ) => { } )
5899 }
59100
@@ -71,9 +112,7 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
71112 // 1. Re-parent AI-SDK spans onto the live "turn" span for this opencode
72113 // session, so each turn becomes its own Laminar trace instead of a
73114 // forest of orphan traces.
74- const sessionId = span . attributes ?. [ "ai.telemetry.metadata.sessionId" ] as
75- | string
76- | undefined
115+ const sessionId = span . attributes ?. [ "ai.telemetry.metadata.sessionId" ] as string | undefined
77116 let ctx = parentContext
78117 if ( sessionId && typeof sessionId === "string" ) {
79118 const parentSpanContext = sessionCurrentTurnSpan [ sessionId ] ?. spanContext ( )
@@ -96,9 +135,7 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
96135 const toolCallNameAttr = span . attributes ?. [ "ai.toolCall.name" ] as string | undefined
97136 if (
98137 SPAWNING_TOOL_NAMES . includes ( span . name ) ||
99- ( span . name === "ai.toolCall" &&
100- toolCallNameAttr &&
101- SPAWNING_TOOL_NAMES . includes ( toolCallNameAttr ) )
138+ ( span . name === "ai.toolCall" && toolCallNameAttr && SPAWNING_TOOL_NAMES . includes ( toolCallNameAttr ) )
102139 ) {
103140 this . spawningSpanIdToToolUseId [ otelSpanIdToUUID ( span . spanContext ( ) . spanId ) ] = toolCallId
104141 }
@@ -107,16 +144,12 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
107144 // 3. Stamp Laminar's path attributes. The UI nests by these, NOT by
108145 // OTel parentSpanId — must run for every span.
109146 const parentPathFromAttribute = span . attributes ?. [ PARENT_SPAN_PATH ] as string [ ] | undefined
110- const parentIdsPathFromAttribute = span . attributes ?. [ PARENT_SPAN_IDS_PATH ] as
111- | StringUUID [ ]
112- | undefined
147+ const parentIdsPathFromAttribute = span . attributes ?. [ PARENT_SPAN_IDS_PATH ] as StringUUID [ ] | undefined
113148 const parentSpanId = getParentSpanId ( span )
114149 const parentSpanPath =
115- parentPathFromAttribute ??
116- ( parentSpanId !== undefined ? this . spanIdToPath . get ( parentSpanId ) : undefined )
150+ parentPathFromAttribute ?? ( parentSpanId !== undefined ? this . spanIdToPath . get ( parentSpanId ) : undefined )
117151 const parentSpanIdsPath =
118- parentIdsPathFromAttribute ??
119- ( parentSpanId !== undefined ? this . spanIdLists . get ( parentSpanId ) : [ ] )
152+ parentIdsPathFromAttribute ?? ( parentSpanId !== undefined ? this . spanIdLists . get ( parentSpanId ) : [ ] )
120153
121154 const spanId = span . spanContext ( ) . spanId
122155 const spanPath = parentSpanPath ? [ ...parentSpanPath , span . name ] : [ span . name ]
@@ -143,8 +176,7 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
143176 if ( spawningToolCallSpanId ) {
144177 span . setAttributes ( {
145178 "lmnr.spawning_subagent.span_id" : spawningToolCallSpanId ,
146- "lmnr.spawning_subagent.tool_use_id" :
147- this . spawningSpanIdToToolUseId [ spawningToolCallSpanId ] ! ,
179+ "lmnr.spawning_subagent.tool_use_id" : this . spawningSpanIdToToolUseId [ spawningToolCallSpanId ] ! ,
148180 } )
149181 }
150182 }
@@ -176,6 +208,17 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
176208 // entries that the descendant scan iterates), not by the raw hex span id.
177209 delete this . spawningSpanIdToToolUseId [ otelSpanIdToUUID ( spanId ) ]
178210 makeSpanOtelV2Compatible ( span )
179- this . inner . onEnd ( span )
211+ if ( ! ( span . spanContext ( ) . traceFlags & TraceFlags . SAMPLED ) ) return
212+ const bounded = boundedSpan ( span )
213+ const size = bounded ? encodedBytes ( [ bounded ] ) : 0
214+ if ( ! bounded || this . pendingBytes + size > QUEUE_BYTES || this . pendingRecords >= QUEUE_RECORDS ) {
215+ this . log ( "warn" , "Diagnostic span dropped: telemetry memory budget" )
216+ return
217+ }
218+ makeSpanOtelV2Compatible ( bounded )
219+ this . sizes . set ( bounded , size )
220+ this . pendingBytes += size
221+ this . pendingRecords ++
222+ this . inner . onEnd ( bounded )
180223 }
181224}
0 commit comments