Skip to content

Commit 7b457ad

Browse files
committed
feat(plugin): trace follow-ups that join a running turn
A user message sent while the agent is working is absorbed by the run already in flight — the agent loop re-reads the session's history at the top of every step, so the message joins the current turn instead of starting a new one. Laminar had no record of it: `chat.message` returns early when a turn span is already open, and the turn span's `input` is serialized once at creation and cannot grow. The only evidence a follow-up ever arrived was the LLM call's message array getting longer between one step and the next. Mark it with a zero-duration `injected_input` span nested in the live turn, carrying the message parts. It lands between the steps it arrived between, so the trace shows where in the conversation the follow-up was picked up, and injections become queryable by span name.
1 parent 7ca085c commit 7b457ad

3 files changed

Lines changed: 45 additions & 4 deletions

File tree

packages/bcode-laminar/VENDOR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ worth chasing.
4040
- OSS users routing bcode telemetry to any OTel collector (Honeycomb, Tempo, Jaeger) without a Laminar account.
4141
- V4 cloud relaying spans through a backend that holds the real Laminar ingest key — the agent runtime never needs `LMNR_PROJECT_API_KEY`.
4242
- Default (neither OTel env var set) is unchanged: gRPC to Laminar.
43+
- **`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.
4344

4445
## Behavior preserved
4546

packages/bcode-laminar/src/plugin.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node"
1717

1818
import { createSpanExporter } from "./exporter"
1919
import { OpenCodeLaminarSpanProcessor } from "./processor"
20-
import { startTurnSpan } from "./span"
20+
import { startChildSpan, startTurnSpan } from "./span"
2121
import { sessionCurrentTurnSpan, subagentSessionIds } from "./state"
2222

2323
const DEFAULT_GRPC_PORT_LMNR = 8443
@@ -184,7 +184,26 @@ export const LaminarPlugin: Plugin = ({ client }) => {
184184
const isSubagent = Object.values(subagentSessionIds).some((children) =>
185185
children.has(sessionID),
186186
)
187-
if (isSubagent || sessionCurrentTurnSpan[sessionID]) return
187+
if (isSubagent) return
188+
189+
// A user message that arrives while a turn is in flight is absorbed by
190+
// the run already in progress: the agent loop re-reads the session's
191+
// history at the top of every step, so the message joins the turn
192+
// instead of starting one. Record it as a point inside the turn — the
193+
// turn span's `input` was serialized when the turn opened and cannot
194+
// grow, so without this the injected message leaves no trace at all and
195+
// the only evidence is the LLM call's message array silently getting
196+
// longer.
197+
const open = sessionCurrentTurnSpan[sessionID]
198+
if (open) {
199+
startChildSpan({
200+
name: "injected_input",
201+
parent: open,
202+
sessionId: sessionID,
203+
input: { sessionID, messageID, message: output.message, parts: output.parts },
204+
}).end()
205+
return
206+
}
188207

189208
const span = startTurnSpan({
190209
name: "turn",

packages/bcode-laminar/src/span.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// lmnr-ts/packages/lmnr/src/laminar.ts. We only need to start a "turn" span
33
// per chat.message event with sessionId association, optional parent span
44
// context (for callers driving opencode programmatically), and an input
5-
// payload. No tracing-level, masked-input, global-context-stack, or
6-
// active-span machinery — opencode owns its own trace lifecycle.
5+
// payload, plus child spans marking points inside a turn. No tracing-level,
6+
// masked-input, global-context-stack, or active-span machinery — opencode owns
7+
// its own trace lifecycle.
78

89
import { type Context, ROOT_CONTEXT, type Span, trace, TraceFlags } from "@opentelemetry/api"
910

@@ -56,6 +57,26 @@ export const startTurnSpan = (opts: {
5657
return trace.getTracer(TURN_TRACER_NAME).startSpan(opts.name, { attributes }, ctx)
5758
}
5859

60+
// A span nested under one we already hold, for marking a point inside a turn.
61+
// No parent-path attributes: the processor derives `lmnr.span.path` from the
62+
// parent's recorded path, which is present because the parent is still open.
63+
export const startChildSpan = (opts: {
64+
name: string
65+
parent: Span
66+
sessionId: string
67+
input?: unknown
68+
}): Span => {
69+
const attributes: Record<string, any> = {
70+
[SPAN_TYPE]: "DEFAULT",
71+
[SESSION_ID]: opts.sessionId,
72+
}
73+
if (opts.input !== undefined) attributes[SPAN_INPUT] = JSON.stringify(opts.input)
74+
75+
return trace
76+
.getTracer(TURN_TRACER_NAME)
77+
.startSpan(opts.name, { attributes }, trace.setSpan(ROOT_CONTEXT, opts.parent))
78+
}
79+
5980
type ParsedSpanContext = {
6081
traceId: string
6182
spanId: string

0 commit comments

Comments
 (0)