Skip to content

Commit 0a00b43

Browse files
Merge pull request #20 from DEVtheOPS/feat/otel-llm-spans
feat(tracing): add OpenTelemetry traces with gen_ai.* and tool spans
2 parents f578d23 + 65f1e70 commit 0a00b43

15 files changed

Lines changed: 1016 additions & 85 deletions

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@
4141
"@opentelemetry/api": "^1.9.0",
4242
"@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0",
4343
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.213.0",
44+
"@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0",
4445
"@opentelemetry/resources": "^2.6.0",
4546
"@opentelemetry/sdk-logs": "^0.213.0",
4647
"@opentelemetry/sdk-metrics": "^2.6.0",
48+
"@opentelemetry/sdk-trace-base": "^2.6.0",
4749
"@opentelemetry/semantic-conventions": "^1.40.0",
4850
"typescript": "^5.9.3"
4951
},

src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type PluginConfig = {
1010
otlpHeaders: string | undefined
1111
resourceAttributes: string | undefined
1212
disabledMetrics: Set<string>
13+
disabledTraces: Set<string>
1314
}
1415

1516
/** Parses a positive integer from an environment variable, returning `fallback` if absent or invalid. */
@@ -41,6 +42,13 @@ export function loadConfig(): PluginConfig {
4142
.filter(Boolean),
4243
)
4344

45+
const disabledTraces = new Set(
46+
(process.env["OPENCODE_DISABLE_TRACES"] ?? "")
47+
.split(",")
48+
.map(s => s.trim())
49+
.filter(Boolean),
50+
)
51+
4452
return {
4553
enabled: !!process.env["OPENCODE_ENABLE_TELEMETRY"],
4654
endpoint: process.env["OPENCODE_OTLP_ENDPOINT"] ?? "http://localhost:4317",
@@ -50,6 +58,7 @@ export function loadConfig(): PluginConfig {
5058
otlpHeaders,
5159
resourceAttributes,
5260
disabledMetrics,
61+
disabledTraces,
5362
}
5463
}
5564

src/handlers/message.ts

Lines changed: 182 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
2+
import { SpanStatusCode, SpanKind, context, trace } from "@opentelemetry/api"
23
import 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"
45
import type { HandlerContext } from "../types.ts"
56

67
type 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
*/
144171
export 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

Comments
 (0)