11import { SeverityNumber } from "@opentelemetry/api-logs"
2+ import { SpanStatusCode , SpanKind , context , trace } from "@opentelemetry/api"
23import type { AssistantMessage , EventMessageUpdated , EventMessagePartUpdated , ToolPart } from "@opencode-ai/sdk"
3- import { errorSummary , setBoundedMap , accumulateSessionTotals , isMetricEnabled } from "../util.ts"
4+ import { errorSummary , setBoundedMap , accumulateSessionTotals , isMetricEnabled , isTraceEnabled } from "../util.ts"
45import type { HandlerContext } from "../types.ts"
56
67type SubtaskPart = {
@@ -13,8 +14,8 @@ type SubtaskPart = {
1314}
1415
1516/**
16- * Handles a completed assistant message: increments token and cost counters and emits
17- * either an `api_request` or `api_error` log event depending on whether the message errored .
17+ * Handles a completed assistant message: increments token and cost counters, emits
18+ * either an `api_request` or `api_error` log event, and ends the LLM span for this message .
1819 * The `agent` attribute is sourced from the session totals, which are populated by the
1920 * `chat.message` hook when the user prompt is received.
2021 */
@@ -75,6 +76,28 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
7576 cost_usd : assistant . cost ,
7677 } )
7778
79+ const msgKey = `${ sessionID } :${ assistant . id } `
80+ const msgSpan = ctx . messageSpans . get ( msgKey )
81+ if ( msgSpan ) {
82+ msgSpan . setAttributes ( {
83+ "gen_ai.usage.input_tokens" : assistant . tokens . input ,
84+ "gen_ai.usage.output_tokens" : assistant . tokens . output ,
85+ "gen_ai.usage.reasoning_tokens" : assistant . tokens . reasoning ,
86+ "gen_ai.usage.cache_read_tokens" : assistant . tokens . cache . read ,
87+ "gen_ai.usage.cache_creation_tokens" : assistant . tokens . cache . write ,
88+ "gen_ai.response.finish_reason" : assistant . error ? "error" : "stop" ,
89+ cost_usd : assistant . cost ,
90+ duration_ms : duration ,
91+ } )
92+ if ( assistant . error ) {
93+ msgSpan . setStatus ( { code : SpanStatusCode . ERROR , message : errorSummary ( assistant . error ) } )
94+ } else {
95+ msgSpan . setStatus ( { code : SpanStatusCode . OK } )
96+ }
97+ msgSpan . end ( assistant . time . completed )
98+ ctx . messageSpans . delete ( msgKey )
99+ }
100+
78101 if ( assistant . error ) {
79102 ctx . logger . emit ( {
80103 severityNumber : SeverityNumber . ERROR ,
@@ -137,9 +160,13 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
137160
138161/**
139162 * Tracks tool execution time between `running` and `completed`/`error` part updates,
140- * records a `tool.duration` histogram measurement, and emits a `tool_result` log event.
141- * Also handles `subtask` parts, incrementing the sub-agent invocation counter and emitting
142- * a `subtask_invoked` log event.
163+ * records a `tool.duration` histogram measurement, manages the tool child span, and emits
164+ * a `tool_result` log event. Also handles `subtask` parts, incrementing the sub-agent
165+ * invocation counter and emitting a `subtask_invoked` log event.
166+ *
167+ * For tool spans: on `running` a child span of the current session span is started and stored
168+ * in `pendingToolSpans`. On `completed`/`error` the span is ended with appropriate status.
169+ * If no `running` event was seen (out-of-order), a best-effort span is started and immediately ended.
143170 */
144171export function handleMessagePartUpdated ( e : EventMessagePartUpdated , ctx : HandlerContext ) {
145172 const part = e . properties . part
@@ -175,70 +202,165 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
175202 } )
176203 }
177204
178- if ( part . type !== "tool" ) return
205+ if ( part . type === "tool" ) {
206+ const toolPart = part as ToolPart
207+ const key = `${ toolPart . sessionID } :${ toolPart . callID } `
179208
180- const toolPart = part as ToolPart
181- const key = `${ toolPart . sessionID } :${ toolPart . callID } `
209+ if ( toolPart . state . status === "running" ) {
210+ const toolSpan = isTraceEnabled ( "tool" , ctx )
211+ ? ( ( ) => {
212+ const sessionSpan = ctx . sessionSpans . get ( toolPart . sessionID )
213+ const parentCtx = sessionSpan
214+ ? trace . setSpan ( context . active ( ) , sessionSpan )
215+ : context . active ( )
216+ return ctx . tracer . startSpan (
217+ `${ ctx . tracePrefix } tool.${ toolPart . tool } ` ,
218+ {
219+ startTime : toolPart . state . time . start ,
220+ kind : SpanKind . INTERNAL ,
221+ attributes : {
222+ "session.id" : toolPart . sessionID ,
223+ "tool.name" : toolPart . tool ,
224+ ...ctx . commonAttrs ,
225+ } ,
226+ } ,
227+ parentCtx ,
228+ )
229+ } ) ( )
230+ : undefined
231+ setBoundedMap ( ctx . pendingToolSpans , key , {
232+ tool : toolPart . tool ,
233+ sessionID : toolPart . sessionID ,
234+ startMs : toolPart . state . time . start ,
235+ span : toolSpan ,
236+ } )
237+ ctx . log ( "debug" , "otel: tool span started" , { sessionID : toolPart . sessionID , tool : toolPart . tool , key } )
238+ return
239+ }
182240
183- if ( toolPart . state . status === "running ") {
184- setBoundedMap ( ctx . pendingToolSpans , key , {
185- tool : toolPart . tool ,
186- sessionID : toolPart . sessionID ,
187- startMs : toolPart . state . time . start ,
188- } )
189- ctx . log ( "debug" , "otel: tool span started" , { sessionID : toolPart . sessionID , tool : toolPart . tool , key } )
190- return
191- }
241+ if ( toolPart . state . status !== "completed" && toolPart . state . status !== "error ") return
242+
243+ const pending = ctx . pendingToolSpans . get ( key )
244+ ctx . pendingToolSpans . delete ( key )
245+ const start = pending ?. startMs ?? toolPart . state . time . start
246+ const end = toolPart . state . time . end
247+ if ( end === undefined ) return
248+ const duration_ms = end - start
249+ const success = toolPart . state . status === "completed"
192250
193- if ( toolPart . state . status !== "completed" && toolPart . state . status !== "error" ) return
251+ if ( isMetricEnabled ( "tool.duration" , ctx ) ) {
252+ ctx . instruments . toolDurationHistogram . record ( duration_ms , {
253+ ...ctx . commonAttrs ,
254+ "session.id" : toolPart . sessionID ,
255+ tool_name : toolPart . tool ,
256+ success,
257+ } )
258+ }
194259
195- const span = ctx . pendingToolSpans . get ( key )
196- ctx . pendingToolSpans . delete ( key )
197- const start = span ?. startMs ?? toolPart . state . time . start
198- const end = toolPart . state . time . end
199- if ( end === undefined ) return
200- const duration_ms = end - start
201- const success = toolPart . state . status === "completed"
260+ if ( isTraceEnabled ( "tool" , ctx ) ) {
261+ const toolSpan = pending ?. span ?? ( ( ) => {
262+ const sessionSpan = ctx . sessionSpans . get ( toolPart . sessionID )
263+ const parentCtx = sessionSpan
264+ ? trace . setSpan ( context . active ( ) , sessionSpan )
265+ : context . active ( )
266+ return ctx . tracer . startSpan (
267+ `${ ctx . tracePrefix } tool.${ toolPart . tool } ` ,
268+ {
269+ startTime : start ,
270+ kind : SpanKind . INTERNAL ,
271+ attributes : {
272+ "session.id" : toolPart . sessionID ,
273+ "tool.name" : toolPart . tool ,
274+ ...ctx . commonAttrs ,
275+ } ,
276+ } ,
277+ parentCtx ,
278+ )
279+ } ) ( )
280+ toolSpan . setAttribute ( "tool.success" , success )
281+ if ( success ) {
282+ const output = ( toolPart . state as { output : string } ) . output
283+ toolSpan . setAttribute ( "tool.result_size_bytes" , Buffer . byteLength ( output , "utf8" ) )
284+ toolSpan . setStatus ( { code : SpanStatusCode . OK } )
285+ } else {
286+ const err = ( toolPart . state as { error : string } ) . error
287+ toolSpan . setAttribute ( "tool.error" , err )
288+ toolSpan . setStatus ( { code : SpanStatusCode . ERROR , message : err } )
289+ }
290+ toolSpan . end ( end )
291+ }
202292
203- if ( isMetricEnabled ( "tool.duration" , ctx ) ) {
204- ctx . instruments . toolDurationHistogram . record ( duration_ms , {
205- ...ctx . commonAttrs ,
206- "session.id" : toolPart . sessionID ,
293+ const sizeAttr = success
294+ ? { tool_result_size_bytes : Buffer . byteLength ( ( toolPart . state as { output : string } ) . output , "utf8" ) }
295+ : { error : ( toolPart . state as { error : string } ) . error }
296+
297+ ctx . logger . emit ( {
298+ severityNumber : success ? SeverityNumber . INFO : SeverityNumber . ERROR ,
299+ severityText : success ? "INFO" : "ERROR" ,
300+ timestamp : start ,
301+ observedTimestamp : Date . now ( ) ,
302+ body : "tool_result" ,
303+ attributes : {
304+ "event.name" : "tool_result" ,
305+ "session.id" : toolPart . sessionID ,
306+ tool_name : toolPart . tool ,
307+ success,
308+ duration_ms,
309+ ...sizeAttr ,
310+ ...ctx . commonAttrs ,
311+ } ,
312+ } )
313+ ctx . log ( "debug" , "otel: tool.duration histogram recorded" , {
314+ sessionID : toolPart . sessionID ,
207315 tool_name : toolPart . tool ,
316+ duration_ms,
208317 success,
209318 } )
210- }
211-
212- const sizeAttr = success
213- ? { tool_result_size_bytes : Buffer . byteLength ( ( toolPart . state as { output : string } ) . output , "utf8" ) }
214- : { error : ( toolPart . state as { error : string } ) . error }
215-
216- ctx . logger . emit ( {
217- severityNumber : success ? SeverityNumber . INFO : SeverityNumber . ERROR ,
218- severityText : success ? "INFO" : "ERROR" ,
219- timestamp : start ,
220- observedTimestamp : Date . now ( ) ,
221- body : "tool_result" ,
222- attributes : {
223- "event.name" : "tool_result" ,
224- "session.id" : toolPart . sessionID ,
319+ return ctx . log ( success ? "info" : "error" , "otel: tool_result" , {
320+ sessionID : toolPart . sessionID ,
225321 tool_name : toolPart . tool ,
226322 success,
227323 duration_ms,
228- ...sizeAttr ,
229- ...ctx . commonAttrs ,
324+ } )
325+ }
326+ }
327+
328+ /**
329+ * Starts an LLM span for an assistant message when it first appears in `message.updated`.
330+ * The span is parented to the session span and carries `gen_ai.*` semantic attributes for
331+ * the model and provider. It is ended in `handleMessageUpdated` once the message completes.
332+ *
333+ * Only called for assistant messages that have not yet completed (`time.completed` absent).
334+ */
335+ export function startMessageSpan (
336+ sessionID : string ,
337+ messageID : string ,
338+ modelID : string ,
339+ providerID : string ,
340+ startTime : number ,
341+ ctx : HandlerContext ,
342+ ) {
343+ if ( ! isTraceEnabled ( "llm" , ctx ) ) return
344+ const msgKey = `${ sessionID } :${ messageID } `
345+ if ( ctx . messageSpans . has ( msgKey ) ) return
346+ const sessionSpan = ctx . sessionSpans . get ( sessionID )
347+ const parentCtx = sessionSpan
348+ ? trace . setSpan ( context . active ( ) , sessionSpan )
349+ : context . active ( )
350+
351+ const msgSpan = ctx . tracer . startSpan (
352+ "gen_ai.chat" ,
353+ {
354+ startTime,
355+ kind : SpanKind . CLIENT ,
356+ attributes : {
357+ "gen_ai.system" : providerID ,
358+ "gen_ai.request.model" : modelID ,
359+ "session.id" : sessionID ,
360+ ...ctx . commonAttrs ,
361+ } ,
230362 } ,
231- } )
232- ctx . log ( "debug" , "otel: tool.duration histogram recorded" , {
233- sessionID : toolPart . sessionID ,
234- tool_name : toolPart . tool ,
235- duration_ms,
236- success,
237- } )
238- return ctx . log ( success ? "info" : "error" , "otel: tool_result" , {
239- sessionID : toolPart . sessionID ,
240- tool_name : toolPart . tool ,
241- success,
242- duration_ms,
243- } )
363+ parentCtx ,
364+ )
365+ setBoundedMap ( ctx . messageSpans , msgKey , msgSpan )
244366}
0 commit comments