Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bcode-laminar/VENDOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 29 additions & 3 deletions packages/bcode-laminar/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}).end()
return
}

const span = startTurnSpan({
name: "turn",
Expand Down
25 changes: 23 additions & 2 deletions packages/bcode-laminar/src/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<string, any> = {
[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
Expand Down
Loading