From 22a72868d3dfc0ba9e1ba24ba17f9675d94c3b0d Mon Sep 17 00:00:00 2001 From: Alexander Yue Date: Mon, 3 Aug 2026 19:32:29 -0700 Subject: [PATCH] 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. --- packages/bcode-laminar/src/plugin.ts | 22 ++++++++++++++++++++++ packages/bcode-laminar/src/processor.ts | 18 +++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/bcode-laminar/src/plugin.ts b/packages/bcode-laminar/src/plugin.ts index 0d2e262121..3c55e067a1 100644 --- a/packages/bcode-laminar/src/plugin.ts +++ b/packages/bcode-laminar/src/plugin.ts @@ -12,6 +12,7 @@ // module, not here. import type { Plugin } from "@opencode-ai/plugin" +import { SpanStatusCode } from "@opentelemetry/api" import { NodeSDK } from "@opentelemetry/sdk-node" import { createSpanExporter } from "./exporter" @@ -108,6 +109,27 @@ export const LaminarPlugin: Plugin = ({ client }) => { }, event: async ({ event }) => { switch (event.type) { + case "session.error": { + // The turn span is the only place a failed run can be recorded. + // Nothing upstream does it: the AI SDK ends `ai.streamText` and + // `ai.streamText.doStream` inside a transform `flush` that never + // runs when the consumer aborts, so a provider error arriving + // mid-stream drops those spans entirely rather than marking them — + // the trace then showed a clean, shorter run. `session.error` is + // published just before the session goes idle (processor.ts `halt`), + // so the span is still open here; `session.idle` ends it below. + const sessionId = event.properties.sessionID + const span = sessionId ? sessionCurrentTurnSpan[sessionId] : undefined + const error = event.properties.error + // An abort is the user stopping the run, not a failure. + if (!span || !error || error.name === "MessageAbortedError") break + const detail = "message" in error.data ? error.data.message : "" + span.setStatus({ + code: SpanStatusCode.ERROR, + message: detail ? `${error.name}: ${detail}` : error.name, + }) + break + } case "session.idle": { const sessionId = event.properties.sessionID const span = sessionCurrentTurnSpan[sessionId] diff --git a/packages/bcode-laminar/src/processor.ts b/packages/bcode-laminar/src/processor.ts index 2d6bdb1559..f958d9ef52 100644 --- a/packages/bcode-laminar/src/processor.ts +++ b/packages/bcode-laminar/src/processor.ts @@ -17,7 +17,7 @@ // - `pino` logger — opencode plugins log via `client.app.log`; the plugin passes // in a logger callback. -import { type Context, type Span, trace } from "@opentelemetry/api" +import { type Context, type Span, SpanStatusCode, trace } from "@opentelemetry/api" import { BatchSpanProcessor, type ReadableSpan, @@ -39,6 +39,7 @@ import { otelSpanIdToUUID, type StringUUID } from "./utils" const SDK_VERSION = "bcode-laminar-0.1" const SPAWNING_TOOL_NAMES = ["task"] +const TOOL_ERROR = "bcode.tool.error" type LogFn = (level: "debug" | "info" | "warn" | "error", message: string) => void export class OpenCodeLaminarSpanProcessor implements SpanProcessor { @@ -153,6 +154,21 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor { } onEnd(span: ReadableSpan): void { + // A tool that throws is normal agent flow, not a failed run: the model + // reads the `tool-error` result and adapts. The AI SDK still stamps ERROR + // on the `ai.toolCall` span (`recordErrorOnSpan`), and Laminar reds a + // whole trace if ANY of its spans is ERROR — so every run where the model + // wrote one buggy `browser_execute` snippet was reported as a failure. + // Demote to UNSET, keeping the message as an attribute and the recorded + // `exception` event untouched. Only the `turn` span decides run outcome. + // + // Mutated in place because `setStatus`/`setAttribute` are no-ops once the + // span has ended, which it has by the time onEnd runs. + if (span.attributes["ai.toolCall.id"] && span.status.code === SpanStatusCode.ERROR) { + Object.assign(span.attributes, { [TOOL_ERROR]: span.status.message ?? "" }) + Object.assign(span, { status: { code: SpanStatusCode.UNSET } }) + } + const spanId = span.spanContext().spanId this.spanIdLists.delete(spanId) this.spanIdToPath.delete(spanId)