fix(tracing): isolate trace context per async execution context (OPEN-12420, OPEN-12421, OPEN-12426) - #224
Open
viniciusdsmello wants to merge 1 commit into
Conversation
Fixes three defects in the tracer and the development-mode runner.
1. Concurrent rows corrupted development-mode traces (OPEN-12420)
CLIHandler processes dataset rows concurrently via Promise.all, but the
tracer held trace state in module-level globals, so every row after the
first nested into the first row's trace instead of rooting its own. All
rows shared one identical steps blob, only one trace uploaded for the
whole dataset, and per-row latency/cost/token columns were null for every
row but the first. Python is unaffected because run_batch_from_df
iterates sequentially, which keeps the same globals safe.
Trace and step-stack state now live in an AsyncLocalStorage, with every
accessor routed through a single ctx() helper. Callers that never
establish a context share one mutable defaultContext that behaves exactly
like the globals it replaced, so existing integrations and their
getCurrentTrace()-after-root assertions are unaffected. CLIHandler wraps
each row in the new exported runInTraceContext().
The wrapper takes a callback because enterWith() is not a substitute: the
synchronous prelude of an async function runs in its caller's context, so
enterWith inside dataset.map(async ...) writes into the shared parent and
leaks the store to every sibling.
Alongside it:
- endStep removed the top of the stack rather than the step that ended,
so a user's own Promise.all over parallel tool calls inside one row
could evict an unrelated step. It now removes by identity.
- runFromCLI was fire-and-forget, returning undefined instead of its
promise chain, so nothing could await completion and the process could
exit before writeOutput ran.
- endStep resolved the async store at invocation time, so a step ended
from a different context (a .then() outside the wrapper, or a framework
callback) read a null trace and threw "Cannot read properties of null
(reading 'steps')". The context is now captured at step creation.
2. inputVariableNames never reached config.json (OPEN-12421)
postProcessTrace returns { traceData, inputVariableNames } as siblings,
but CLIHandler unwrapped to .traceData and then read .inputVariableNames
off that, so it was always undefined.
3. Cost and tokens read only the root step (OPEN-12426)
postProcessTrace read both fields off the root step alone, cast to
ChatCompletionStep. Any agent- or chain-rooted trace carries them on
nested LLM steps, so the unchecked cast silently yielded undefined. In
development mode CLIHandler only declares a column when some row has a
numeric value, so an agent-shaped model produced a config.json with no
costColumnName or numOfTokenColumnName at all, and a test thresholding on
either had nothing to evaluate. Monitoring streams the same traceData, so
live agent traces published null cost and tokens.
Both are now totalled across the root and all descendants. A trace
already rooted on a chat-completion step reports exactly what it did
before, pinned by its own regression test.
Note this changes monitoring output: live agent traces will start
reporting real cost/token totals where they previously published nulls.
Verified with three new suites (9 tests), each watched failing first.
Full suite against main: 483 -> 492 passing, 68 -> 59 failing, with the
suite-by-suite diff identical apart from the new suites flipping to pass.
Remaining failures are pre-existing api-resources cases needing a mock
server. yarn build, tsc --noEmit, eslint and prettier all clean.
Fixes OPEN-12420
Fixes OPEN-12421
Fixes OPEN-12426
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CvG3PYxCe6fbf8v7iyMRs
viniciusdsmello
force-pushed
the
vini/open-12420-openlayer-ts-clihandler-concurrent-row-processing-corrupts
branch
from
September 1, 2026 17:16
0017d92 to
e0f09ca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes OPEN-12420, OPEN-12421 and OPEN-12426.
1. Concurrent rows corrupt development-mode traces (OPEN-12420)
The development-mode runner (
CLIHandler) processes dataset rows concurrently viaPromise.all, but the tracer held trace state in module-level globals. Every row after the first nested into the first row's trace instead of rooting its own.Measured on a 3-row dataset with one traced step per row and staggered awaits:
All three rows share one identical
stepsblob (A → B → C nested), only one trace uploads for the whole dataset, andconfig.jsondeclareslatencyColumnNamewhile 2 of 3 rows carrylatency: null.Python is unaffected:
OpenlayerModel.run_batch_from_dfiterates sequentially, which keeps the same globals safe.Fix. Trace + step-stack state moved into an
AsyncLocalStorage, with every accessor routed through a singlectx()helper. Callers that never establish a context share one mutabledefaultContextthat behaves exactly like the globals it replaced — so existing integrations and theirgetCurrentTrace()-after-root assertions are untouched.CLIHandlerwraps each row in the new exportedrunInTraceContext().enterWith()is not a substitute, which is why the wrapper takes a callback. The synchronous prelude of an async function runs in its caller's context, soenterWithinsidedataset.map(async ...)writes into the shared parent and leaks to every sibling:Also fixed alongside it:
endSteppopped the top of the stack rather than the step that ended. Now removes by identity, so a user's ownPromise.allover parallel tool calls inside one row cannot evict an unrelated step.runFromCLIwas fire-and-forget — it returnedundefinedinstead of its promise chain, so nothing could await completion and the process could exit beforewriteOutputran.endStepresolved the store at invocation time, so a step ended from a different async context (a.then()outside the wrapper, or a framework callback) read a null trace and threwCannot read properties of null (reading 'steps'). The context is now captured at step creation.2.
inputVariableNamesnever written to config.json (OPEN-12421)postProcessTracereturns{ traceData, inputVariableNames }as siblings, butCLIHandlerunwrapped to.traceDataand then read.inputVariableNamesoff that — alwaysundefined, so it never reachedconfig.json.3. Cost and tokens read only the root step (OPEN-12426)
postProcessTracereadcostandtokensoff the root step alone, cast toChatCompletionStep. Any agent- or chain-rooted trace carries those on nested LLM steps, so the unchecked cast silently yieldedundefined.Silent in both planes: development mode only declares a column when some row has a numeric value, so an agent-shaped model produced a
config.jsonwith nocostColumnNameornumOfTokenColumnNameat all — a test thresholding on either had nothing to evaluate. Monitoring streams the sametraceData, so live agent traces published null cost/tokens.Now totalled across the root and all descendants. A trace already rooted on a chat-completion step reports exactly what it did before, pinned by its own test.
End-to-end verification
A 3-row batch, each row an agent root wrapping one chat-completion step (
tokens: 42, cost: 0.00031).Before — one shared trace, one upload, latency on row 1 only, no cost/token columns:
After — three distinct root step IDs, three uploads, per-row latency, and the cost/token columns present:
Row inputs (
userQuery,groundTruth),output,otherFields, and the nestedAgent → OpenAI Chat Completionstep tree all serialize correctly.Verification
tests/cli-concurrency.test.ts,tests/tracer-context.test.ts,tests/tracer-cost-aggregation.test.ts(9 tests). Each test was watched failing first: the concurrency test failed with row-B nested in row-A nested in row-C; the aggregation tests failedExpected: 42 / Received: undefined; the detached-endSteptest failed with the predictedTypeError: Cannot read properties of null (reading 'steps').mainon fresh deps: 483 → 492 passing, 68 → 59 failing. Suite-by-suite diff is identical apart from the three new suites flipping to PASS. Remaining failures are pre-existingtests/api-resources/**cases needing a mock server (APIConnectionError), confirmed failing identically onmain.yarn buildpasses, including therequire("openlayer")/import("openlayer")smoke tests;runInTraceContextand the aggregation are both present indist.tsc --noEmit,eslint,prettier --checkclean.Scope / known gaps
Isolation is automatic for development mode and opt-in for monitoring. An app tracing parallel requests still shares
defaultContextunless it wraps each unit of work inrunInTraceContext(). Making that automatic is OPEN-12423, deliberately left out — investigation on this branch found it only partly achievable SDK-side:tracedTool/tracedAgenttraceOpenAInon-streamingtraceOpenAIstreamingtracedOutputGenerator, in the consumer's contexttracedQuery(Claude Agent SDK)async function*;run()around generator creation is a no-oplangchainCallbackrunId-keyed mapsTwo further items left alone, noted on OPEN-12420:
runFromCLI's.catch(console.error)still swallows write failures, anddefaultContextnever clears its trace (same as the previous globals — the preservedendStepNOTE depends on it).openlayer-pythonhas the same root-only cost/token logic (defaulting to0rather than omitting), so it under-reports too — worth a parity follow-up.🤖 Generated with Claude Code
https://claude.ai/code/session_015CvG3PYxCe6fbf8v7iyMRs