Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 21 additions & 2 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 @@ -184,7 +184,26 @@ export const LaminarPlugin: Plugin = ({ client }) => {
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