diff --git a/packages/bcode-laminar/VENDOR.md b/packages/bcode-laminar/VENDOR.md index 0945ed32e..2d7cde6e5 100644 --- a/packages/bcode-laminar/VENDOR.md +++ b/packages/bcode-laminar/VENDOR.md @@ -40,6 +40,7 @@ worth chasing. - OSS users routing bcode telemetry to any OTel collector (Honeycomb, Tempo, Jaeger) without a Laminar account. - V4 cloud relaying spans through a backend that holds the real Laminar ingest key — the agent runtime never needs `LMNR_PROJECT_API_KEY`. - Default (neither OTel env var set) is unchanged: gRPC to Laminar. +- **`injected_input` span for follow-ups that join a running turn.** Upstream returns early from `chat.message` when a turn span is already open, so a user message that arrives mid-run is never recorded: the turn span's `input` is serialized once at creation and cannot grow, leaving the LLM call's message array silently getting longer as the only evidence. `startChildSpan` (in `span.ts`) marks it as a zero-duration child of the live turn, carrying the message parts, positioned between the steps it landed between. ## Behavior preserved diff --git a/packages/bcode-laminar/src/plugin.ts b/packages/bcode-laminar/src/plugin.ts index 3c55e067a..63359ecb6 100644 --- a/packages/bcode-laminar/src/plugin.ts +++ b/packages/bcode-laminar/src/plugin.ts @@ -17,7 +17,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node" import { createSpanExporter } from "./exporter" import { OpenCodeLaminarSpanProcessor } from "./processor" -import { startTurnSpan } from "./span" +import { startChildSpan, startTurnSpan } from "./span" import { sessionCurrentTurnSpan, subagentSessionIds } from "./state" const DEFAULT_GRPC_PORT_LMNR = 8443 @@ -179,12 +179,38 @@ export const LaminarPlugin: Plugin = ({ client }) => { } }, "chat.message": async (input, output) => { - const { sessionID, agent, model, messageID, variant } = input + const { sessionID, agent, model, variant } = input + // Not `input.messageID`: that is the caller's optional override, absent + // whenever the prompt was posted without one (every v4 run — the worker + // POSTs /prompt_async with just parts and model), in which case opencode + // generates the id. `output.message.id` is the id the message was + // actually persisted under either way, so the span can be correlated + // with the transcript. + const messageID = output.message.id // Skip sub-agent prompts — their parent already has a turn span. const isSubagent = Object.values(subagentSessionIds).some((children) => children.has(sessionID), ) - if (isSubagent || sessionCurrentTurnSpan[sessionID]) return + if (isSubagent) return + + // A user message that arrives while a turn is in flight is absorbed by + // the run already in progress: the agent loop re-reads the session's + // history at the top of every step, so the message joins the turn + // instead of starting one. Record it as a point inside the turn — the + // turn span's `input` was serialized when the turn opened and cannot + // grow, so without this the injected message leaves no trace at all and + // the only evidence is the LLM call's message array silently getting + // longer. + const open = sessionCurrentTurnSpan[sessionID] + if (open) { + startChildSpan({ + name: "injected_input", + parent: open, + sessionId: sessionID, + input: { sessionID, messageID, message: output.message, parts: output.parts }, + }).end() + return + } const span = startTurnSpan({ name: "turn", diff --git a/packages/bcode-laminar/src/span.ts b/packages/bcode-laminar/src/span.ts index 9ea1ee999..95b76fcd6 100644 --- a/packages/bcode-laminar/src/span.ts +++ b/packages/bcode-laminar/src/span.ts @@ -2,8 +2,9 @@ // lmnr-ts/packages/lmnr/src/laminar.ts. We only need to start a "turn" span // per chat.message event with sessionId association, optional parent span // context (for callers driving opencode programmatically), and an input -// payload. No tracing-level, masked-input, global-context-stack, or -// active-span machinery — opencode owns its own trace lifecycle. +// payload, plus child spans marking points inside a turn. No tracing-level, +// masked-input, global-context-stack, or active-span machinery — opencode owns +// its own trace lifecycle. import { type Context, ROOT_CONTEXT, type Span, trace, TraceFlags } from "@opentelemetry/api" @@ -56,6 +57,26 @@ export const startTurnSpan = (opts: { return trace.getTracer(TURN_TRACER_NAME).startSpan(opts.name, { attributes }, ctx) } +// A span nested under one we already hold, for marking a point inside a turn. +// No parent-path attributes: the processor derives `lmnr.span.path` from the +// parent's recorded path, which is present because the parent is still open. +export const startChildSpan = (opts: { + name: string + parent: Span + sessionId: string + input?: unknown +}): Span => { + const attributes: Record = { + [SPAN_TYPE]: "DEFAULT", + [SESSION_ID]: opts.sessionId, + } + if (opts.input !== undefined) attributes[SPAN_INPUT] = JSON.stringify(opts.input) + + return trace + .getTracer(TURN_TRACER_NAME) + .startSpan(opts.name, { attributes }, trace.setSpan(ROOT_CONTEXT, opts.parent)) +} + type ParsedSpanContext = { traceId: string spanId: string