Skip to content

Commit aaf57d4

Browse files
authored
Merge pull request #146 from browser-use/alex/laminar-injected-input
feat(plugin): trace follow-ups that join a running turn
2 parents 7ca085c + f7c7348 commit aaf57d4

3 files changed

Lines changed: 53 additions & 5 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: 29 additions & 3 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
@@ -179,12 +179,38 @@ export const LaminarPlugin: Plugin = ({ client }) => {
179179
}
180180
},
181181
"chat.message": async (input, output) => {
182-
const { sessionID, agent, model, messageID, variant } = input
182+
const { sessionID, agent, model, variant } = input
183+
// Not `input.messageID`: that is the caller's optional override, absent
184+
// whenever the prompt was posted without one (every v4 run — the worker
185+
// POSTs /prompt_async with just parts and model), in which case opencode
186+
// generates the id. `output.message.id` is the id the message was
187+
// actually persisted under either way, so the span can be correlated
188+
// with the transcript.
189+
const messageID = output.message.id
183190
// Skip sub-agent prompts — their parent already has a turn span.
184191
const isSubagent = Object.values(subagentSessionIds).some((children) =>
185192
children.has(sessionID),
186193
)
187-
if (isSubagent || sessionCurrentTurnSpan[sessionID]) return
194+
if (isSubagent) return
195+
196+
// A user message that arrives while a turn is in flight is absorbed by
197+
// the run already in progress: the agent loop re-reads the session's
198+
// history at the top of every step, so the message joins the turn
199+
// instead of starting one. Record it as a point inside the turn — the
200+
// turn span's `input` was serialized when the turn opened and cannot
201+
// grow, so without this the injected message leaves no trace at all and
202+
// the only evidence is the LLM call's message array silently getting
203+
// longer.
204+
const open = sessionCurrentTurnSpan[sessionID]
205+
if (open) {
206+
startChildSpan({
207+
name: "injected_input",
208+
parent: open,
209+
sessionId: sessionID,
210+
input: { sessionID, messageID, message: output.message, parts: output.parts },
211+
}).end()
212+
return
213+
}
188214

189215
const span = startTurnSpan({
190216
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)