Skip to content

Commit 22a7286

Browse files
committed
fix(plugin): make a Laminar trace red iff the turn failed
Two defects made trace status uninformative for v4 runs. A tool that throws is normal agent flow - the model reads the `tool-error` result and adapts - but the AI SDK stamps ERROR on the `ai.toolCall` span and Laminar reds a whole trace if any of its spans is ERROR. One buggy `browser_execute` snippet therefore reported the entire run as a failure: 1,058 errored `browser_execute` spans in two days on prod. Demote those spans to UNSET, keeping the message on `bcode.tool.error` and the `exception` event intact. Conversely nothing ever marked a genuinely failed turn. The AI SDK ends `ai.streamText` / `ai.streamText.doStream` inside a transform `flush` that never runs when the consumer aborts, so a provider error arriving mid-stream drops those spans instead of marking them - the trace showed a clean, shorter run. Mark the turn span ERROR on `session.error`, which `SessionProcessor.halt` publishes just before the session goes idle. Aborts are excluded: stopping a run is not a failure.
1 parent 4db4b32 commit 22a7286

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

‎packages/bcode-laminar/src/plugin.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// module, not here.
1313

1414
import type { Plugin } from "@opencode-ai/plugin"
15+
import { SpanStatusCode } from "@opentelemetry/api"
1516
import { NodeSDK } from "@opentelemetry/sdk-node"
1617

1718
import { createSpanExporter } from "./exporter"
@@ -108,6 +109,27 @@ export const LaminarPlugin: Plugin = ({ client }) => {
108109
},
109110
event: async ({ event }) => {
110111
switch (event.type) {
112+
case "session.error": {
113+
// The turn span is the only place a failed run can be recorded.
114+
// Nothing upstream does it: the AI SDK ends `ai.streamText` and
115+
// `ai.streamText.doStream` inside a transform `flush` that never
116+
// runs when the consumer aborts, so a provider error arriving
117+
// mid-stream drops those spans entirely rather than marking them —
118+
// the trace then showed a clean, shorter run. `session.error` is
119+
// published just before the session goes idle (processor.ts `halt`),
120+
// so the span is still open here; `session.idle` ends it below.
121+
const sessionId = event.properties.sessionID
122+
const span = sessionId ? sessionCurrentTurnSpan[sessionId] : undefined
123+
const error = event.properties.error
124+
// An abort is the user stopping the run, not a failure.
125+
if (!span || !error || error.name === "MessageAbortedError") break
126+
const detail = "message" in error.data ? error.data.message : ""
127+
span.setStatus({
128+
code: SpanStatusCode.ERROR,
129+
message: detail ? `${error.name}: ${detail}` : error.name,
130+
})
131+
break
132+
}
111133
case "session.idle": {
112134
const sessionId = event.properties.sessionID
113135
const span = sessionCurrentTurnSpan[sessionId]

‎packages/bcode-laminar/src/processor.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
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, trace } from "@opentelemetry/api"
20+
import { type Context, type Span, SpanStatusCode, trace } from "@opentelemetry/api"
2121
import {
2222
BatchSpanProcessor,
2323
type ReadableSpan,
@@ -39,6 +39,7 @@ import { otelSpanIdToUUID, type StringUUID } from "./utils"
3939

4040
const SDK_VERSION = "bcode-laminar-0.1"
4141
const SPAWNING_TOOL_NAMES = ["task"]
42+
const TOOL_ERROR = "bcode.tool.error"
4243
type LogFn = (level: "debug" | "info" | "warn" | "error", message: string) => void
4344

4445
export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
@@ -153,6 +154,21 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
153154
}
154155

155156
onEnd(span: ReadableSpan): void {
157+
// A tool that throws is normal agent flow, not a failed run: the model
158+
// reads the `tool-error` result and adapts. The AI SDK still stamps ERROR
159+
// on the `ai.toolCall` span (`recordErrorOnSpan`), and Laminar reds a
160+
// whole trace if ANY of its spans is ERROR — so every run where the model
161+
// wrote one buggy `browser_execute` snippet was reported as a failure.
162+
// Demote to UNSET, keeping the message as an attribute and the recorded
163+
// `exception` event untouched. Only the `turn` span decides run outcome.
164+
//
165+
// Mutated in place because `setStatus`/`setAttribute` are no-ops once the
166+
// span has ended, which it has by the time onEnd runs.
167+
if (span.attributes["ai.toolCall.id"] && span.status.code === SpanStatusCode.ERROR) {
168+
Object.assign(span.attributes, { [TOOL_ERROR]: span.status.message ?? "" })
169+
Object.assign(span, { status: { code: SpanStatusCode.UNSET } })
170+
}
171+
156172
const spanId = span.spanContext().spanId
157173
this.spanIdLists.delete(spanId)
158174
this.spanIdToPath.delete(spanId)

0 commit comments

Comments
 (0)