Skip to content

[NA] [SDK] fix: evict per-trace state in LangChain OpikTracer after root run finishes - #7517

Closed
AnastasisB wants to merge 2 commits into
comet-ml:mainfrom
AnastasisB:fix/langchain-tracer-per-trace-eviction
Closed

[NA] [SDK] fix: evict per-trace state in LangChain OpikTracer after root run finishes#7517
AnastasisB wants to merge 2 commits into
comet-ml:mainfrom
AnastasisB:fix/langchain-tracer-per-trace-eviction

Conversation

@AnastasisB

@AnastasisB AnastasisB commented Jul 18, 2026

Copy link
Copy Markdown

Details

Fixes an unbounded memory leak in the LangChain integration. OpikTracer only ever adds to its six per-instance bookkeeping structures (_span_data_map, _created_traces_data_map, _created_traces, _externally_created_traces_ids, _skipped_langgraph_root_run_ids, _langgraph_parent_span_ids), so a tracer built once and reused across requests (the documented pattern) retains every prompt/completion payload for the process lifetime. Full analysis, production numbers and an offline repro are in the linked issue.

The fix releases a trace's state right after its root run's final end/error callback: a finally hook in _process_end_span and _process_end_span_with_error for parentless runs, calling the new _evict_finished_trace_state. That is the earliest safe point: langchain calls _persist_run before that callback, and the end-span processing still needs the root run's entries to emit the final span. _persist_run is untouched.

  • Eviction walks the finished root run's own tree (BaseTracer attaches every child to its parent's child_runs), so it removes exactly that run's entries and never scans the shared maps. Other in-flight root runs on the same tracer, including ones attached to the same externally created (distributed) trace, are untouched. The external trace id is only discarded once no remaining span references it; a narrow lock shared by the id registration sites and that check-then-discard makes the two atomic, so a concurrent root run cannot have the id discarded from under it. Child span starts stay lock-free.
  • The hooks sit in finally, so the error path cannot leak either.
  • Evicting inside _persist_run was tried first: it passes the chain-based tests but silently drops root span emission for direct model invocations (measured 10 invocations -> 10 traces, 0 spans). A regression test now asserts spans reach the backend, so that placement fails the suite.
  • _created_traces is deliberately NOT evicted: created_traces() is documented to accumulate, existing tests and docs rely on it, and Trace objects are small handles without payloads. A long-lived tracer still accumulates one small handle per trace; whether to bound that is your design call, happy to help in a follow-up if you want it bounded.

Before/after with the repro from the issue, after 50 invocations:

opik 2.1.31:  _span_data_map= 100  _created_traces_data_map= 150  _created_traces=  50  _skipped_langgraph_root_run_ids=  50  _langgraph_parent_span_ids=  50  retained_payload_bytes=13123
this branch:  _span_data_map=   0  _created_traces_data_map=   0  _created_traces=  50  _skipped_langgraph_root_run_ids=   0  _langgraph_parent_span_ids=   0  retained_payload_bytes=0

Change checklist

  • User facing
  • Documentation update

Issues

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Fable 5
  • Scope: root-cause analysis, implementation draft and regression tests; commits authored and reviewed by the contributor
  • Human verification: LangChain integration test subset run locally, before/after repro run against PyPI 2.1.31 and this branch, pre-commit hooks on the changed files, emission counts measured under both eviction placements

Testing

Eight regression tests added to tests/library_integration/langchain/test_opik_tracer.py, following the existing fake_backend conventions: all payload-carrying state evicted after completion while traces still reach the backend with their spans and created_traces() accumulates, chained-run child state evicted, eviction on the error path, a concurrent in-flight trace untouched, a root run finishing on a shared external trace leaving the other in-flight root run's state intact, a deterministic registration/eviction race test (a second root run paused between registering the external id and saving its span while the first root run finishes; verified to fail when the lock is neutralized), the public get_current_span_data_for_run accessor returning None once the trace completes instead of stale data, an external trace id discarded.

Run locally (Python 3.10.12, langchain-core 1.4.9, offline):

  • pytest tests/library_integration/langchain/test_opik_tracer.py: 89 passed
  • wider langchain subset (test_langchain.py, test_langgraph.py, test_langchain_thread_id.py, test_message_converters.py): 32 passed, 2 failed; the same 2 fail identically on pristine main in this environment (no OPENAI_API_KEY, unrelated to this change)
  • pre-commit hooks on the changed files (ruff, ruff-format, mypy): pass
  • the issue's repro against this branch: all evictable counters stay at 0

Documentation

No public API or parameter changes, created_traces() behavior unchanged. One observable difference: get_current_span_data_for_run returns None once the run's trace completed, where it previously kept returning the retained span data; that is the eviction working and is pinned by a regression test.

@AnastasisB
AnastasisB requested a review from a team as a code owner July 18, 2026 15:27
@github-actions github-actions Bot added python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK 🟡 size/M labels Jul 18, 2026
@AnastasisB
AnastasisB force-pushed the fix/langchain-tracer-per-trace-eviction branch from 458669b to 8fcfe43 Compare July 18, 2026 15:30
Comment on lines +583 to +589
def _bookkeeping_sizes(tracer: OpikTracer) -> Dict[str, int]:
return {
"span_data_map": len(tracer._span_data_map),
"created_traces_data_map": len(tracer._created_traces_data_map),
"externally_created_traces_ids": len(tracer._externally_created_traces_ids),
"skipped_langgraph_root_run_ids": len(tracer._skipped_langgraph_root_run_ids),
"langgraph_parent_span_ids": len(tracer._langgraph_parent_span_ids),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_bookkeeping_sizes() asserts on five private tracer containers, so a harmless storage refactor breaks the tests even when flush(), created_traces(), and get_current_span_data_for_run() still behave correctly — should we replace _EMPTY_BOOKKEEPING/_bookkeeping_sizes with assertions on fake_backend.trace_trees, tracer.created_traces(), and get_current_span_data_for_run(), as .agents/skills/python-sdk/testing.md suggests?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/langchain/test_opik_tracer.py around lines
571-589, remove the `_bookkeeping_sizes()` helper and `_EMPTY_BOOKKEEPING` constant
entirely — they assert against private tracer containers (`_span_data_map`,
`_created_traces_data_map`, `_externally_created_traces_ids`,
`_skipped_langgraph_root_run_ids`, `_langgraph_parent_span_ids`) and freeze the internal
bookkeeping layout. Refactor all tests that use them to validate the same behaviors
(completion, chained runs, error paths, concurrency, externally-created trace ID
cleanup) using only public signals per `.agents/skills/python-sdk/testing.md`: -
`fake_backend.trace_trees` contents to confirm spans were emitted -
`tracer.created_traces()` length to confirm trace registration -
`get_current_span_data_for_run()` to assert completed runs have no current span data
after `tracer.flush()`, while in-flight runs remain queryable

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4a77e36

Comment on lines +655 to +657
assert {
name: size for name, size in _bookkeeping_sizes(tracer).items() if size > 0
} == in_flight_entries

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-run cleanup not verified

This assertion only checks the map sizes before and after the second trace, so it can miss a regression that evicts the original in_flight_run_id and leaves different same-sized state behind — should we assert that in_flight_run_id is still present in _span_data_map / _created_traces_data_map after the second trace completes?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/langchain/test_opik_tracer.py around lines
635-658, in the
`test_opik_tracer__concurrent_in_flight_trace__untouched_by_other_trace_finishing`
logic, replace the final assertion that only compares bookkeeping map sizes (lines
655-657) with assertions that the specific `in_flight_run_id` is still present in the
tracer’s internal state after the second trace finishes. Refactor the test to capture
the pre-flush keys (or explicitly assert membership) for `_span_data_map` and
`_created_traces_data_map` tied to `in_flight_run_id`, and then after `tracer.flush()`
assert those keys are unchanged/present while unrelated trace state was evicted. Keep
the existing size check only as a secondary assertion if useful.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4a77e36

Comment on lines +371 to +382
run_ids = {
run_id
for run_id, span_data in self._span_data_map.items()
if span_data.trace_id == trace_id
}
# Child runs map to the same TraceData object as the root run.
run_ids.update(
run_id
for run_id, trace_data in self._created_traces_data_map.items()
if trace_data.id == trace_id
)
for run_id in run_ids:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_evict_finished_trace_state() evicts all entries for a trace_id when the first root run finishes, so a second root run sharing the same external trace_id loses its _span_data_map entries before _process_end_span fires and its final span never emits — should we scope eviction to the finishing root run's own descendants (and only drop shared trace state when a refcount reaches zero), and guard the whole eviction pass with a threading.RLock to prevent concurrent callbacks from racing on the shared dicts?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/langchain/opik_tracer.py around lines 371-387,
refactor `_evict_finished_trace_state()` to address two related issues: 1. **Correctness
(shared trace_id):** Currently, eviction collects every entry in `_span_data_map` where
`span_data.trace_id == trace_id` and removes them all, which is unsafe when multiple
root runs share the same externally supplied `opik_trace_id`. Refactor so eviction is
scoped to the finishing root run and its own descendants only. For shared/external trace
IDs (i.e., when `_resolve_trace_id()` returns a trace not owned by this root), introduce
a reference count per `trace_id` and only evict shared state (including
`_externally_created_traces_ids`) when the refcount reaches zero. This ensures later
root runs' `_process_end_span` callbacks still find their entries in `_span_data_map`.
2. **Thread safety:** The current code iterates `_span_data_map.items()` and
`_created_traces_data_map.items()` while mutating those same dicts with no
synchronization, which can raise `RuntimeError: dictionary changed size during
iteration` under concurrent callbacks. Introduce a tracer-instance `threading.RLock` and
acquire it for the entire eviction sequence — from `_resolve_trace_id()` through
computing run IDs and popping entries from both maps. Apply the same lock to any other
logic in this file that scans or mutates these bookkeeping maps (e.g.,
`_finalize_trace`, end-span handlers).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4a77e36

Comment on lines +500 to +505
with self._external_trace_registration_lock:
self._create_root_trace_and_span_impl(
run_id=run_id,
run_dict=run_dict,
allow_duplicating_root_span=allow_duplicating_root_span,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_create_root_trace_and_span() and _attach_span_to_local_or_distributed_trace() hold self._external_trace_registration_lock for their entire _..._impl(...) execution, so one slow backend call (__internal_api__trace__/__internal_api__span__, _opik_context_storage.*) blocks all concurrent traces on the shared tracer — according to the PR description this tracer is reused across requests, so should we narrow the lock to just the _externally_created_traces_ids / _span_data_map mutations that need atomicity with _evict_finished_trace_state's check-then-discard?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/src/opik/integrations/langchain/opik_tracer.py, both
`_create_root_trace_and_span()` (lines ~487-506) and
`_attach_span_to_local_or_distributed_trace()` (lines ~619-634) wrap their entire impl
in `self._external_trace_registration_lock`, serializing unrelated traces and creating a
throughput bottleneck. Refactor by narrowing the lock scope in both methods: 1. Move all
"compute/setup" work (context storage updates via `self._opik_context_storage.*`,
backend calls via `__internal_api__trace__`/`__internal_api__span__`,
`_track_root_run(...)`) outside the lock. 2. Acquire
`self._external_trace_registration_lock` only for the minimal critical section that
mutates or checks shared bookkeeping: adding to `_externally_created_traces_ids` and
writing `_span_data_map`/trace-id associations that `_evict_finished_trace_state`
checks. 3. In `_create_root_trace_and_span_impl`, add to
`_externally_created_traces_ids` only after `_save_span_trace_data_to_local_maps(...)`
has populated the maps for that trace/span, then wrap just that set-add in the lock. 4.
Preserve `_evict_finished_trace_state()`'s lock-held check-then-discard atomicity —
only reduce contention by moving non-bookkeeping operations outside the lock.

Comment on lines +708 to +719
def test_opik_tracer__get_current_span_data_for_run__cleared_once_trace_completes(
fake_backend,
):
tracer = OpikTracer()
root_run_id, child_run_id = uuid4(), uuid4()
tracer.on_chain_start({"name": "root"}, {"input": "x"}, run_id=root_run_id)
tracer.on_chain_start(
{"name": "child"},
{"input": "y"},
run_id=child_run_id,
parent_run_id=root_run_id,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed backend emission regression

get_current_span_data_for_run() only checks that the trace is cleared after completion, so _process_end_span() could stop emitting the final root/child spans to fake_backend and this test would still pass — should we assert the expected fake_backend and __internal_api__span__ calls too?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/langchain/test_opik_tracer.py around lines
708-730, in
`test_opik_tracer__get_current_span_data_for_run__cleared_once_trace_completes`,
strengthen the assertions beyond `get_current_span_data_for_run(...) is None` by also
verifying that the end processing emitted the final spans. After `tracer.flush()` (and
before/after the eviction assertions), assert that `fake_backend` recorded span
submissions for the child and root runs (and that the calls include the expected
identifiers/relationships such as `run_id` and the correct parent span linkage), or
alternatively assert that `__internal_api__span__` was called with the expected
parameters for those runs. Refactor the test to capture backend call history (whatever
the fake_backend exposes) and add precise expectations so this would fail if
`_process_end_span()` stops emitting the final spans.

Comment on lines +733 to +744
def test_opik_tracer__shared_external_trace__finishing_root_run_leaves_other_in_flight(
fake_backend,
):
external_trace_data = trace_module.TraceData(name="external-trace")
context_storage.set_trace_data(external_trace_data)
tracer = OpikTracer()
first_run_id, second_run_id = uuid4(), uuid4()
try:
tracer.on_chain_start({"name": "first"}, {"input": "x"}, run_id=first_run_id)
tracer.on_chain_start({"name": "second"}, {"input": "y"}, run_id=second_run_id)
tracer.on_chain_end({"output": "x"}, run_id=first_run_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup tested without output check

These external-trace tests only check _externally_created_traces_ids and the span-data maps, so a change could clear the bookkeeping while suppressing the final span call and still pass — should we also assert the backend span emission/count the PR description calls out?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/langchain/test_opik_tracer.py around lines 733-759
in `test_opik_tracer__shared_external_trace__finishing_root_run_leaves_other_in_flight`
(and similarly check the race test just below), the assertions only validate internal
bookkeeping (`_externally_created_traces_ids` and `get_current_span_data_for_run`) and
do not verify the user-visible outcome that both root runs still emit backend spans.
Refactor the test to also assert the fake backend recorded span/emission for both
run_ids (or the correct span count/name) before/after each `flush`, using the
capabilities of the `fake_backend` fixture. Make the test fail if span emission is
suppressed even when bookkeeping eviction behaves correctly, while keeping the existing
in-flight vs finished membership checks.

Comment on lines +800 to +810
second_thread = threading.Thread(target=start_second_root)
second_thread.start()
assert registration_entered.wait(timeout=5)

# finish the first root while the second is mid-registration; with the
# lock this blocks until the registration completes, so release it
# shortly after
threading.Timer(0.3, resume_registration.set).start()
tracer.on_chain_end({"output": "x"}, run_id=first_run_id)
second_thread.join(timeout=5)
assert not second_thread.is_alive()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

second_thread can stay blocked in paused_save() if registration_entered.wait(timeout=5) times out before threading.Timer(0.3) fires resume_registration.set(), leaving a live non-daemon thread that can hang the suite — should we move the resume_registration.set() and second_thread.join() into a finally block, and replace the timer with an event-driven handoff so the test doesn't depend on wall-clock scheduling?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
sdks/python/tests/library_integration/langchain/test_opik_tracer.py around lines 800-810
inside
`test_opik_tracer__external_id_registration_races_eviction__id_not_discarded_mid_registration`:
1. Remove the `threading.Timer(0.3)` and its implicit 300ms delay entirely. 2. Refactor
to use additional `threading.Event`(s) to deterministically coordinate the handoff —
e.g., run `tracer.on_chain_end` for the first run in a separate thread and signal an
event when eviction/persist for the first run reaches the point where it would block on
the registration lock; once that event is observed, set `resume_registration`
immediately, then join the first thread. 3. Move the release of `resume_registration`
and the `second_thread.join(timeout=...)` into a `finally` block so they execute on
every exit path (including timeouts and assertion failures), ensuring `second_thread` is
always unblocked before `_save_span_trace_data_to_local_maps` is restored. 4. Keep any
timeout only as a safety guard, not as a timing mechanism.

@petrotiurin

Copy link
Copy Markdown
Contributor

Thank you for working on this @AnastasisB ! Looks like a parallel fix has already been merged into main that fixes this problem so I'm going to close this PR as a duplicate.
Nevertheless, really appreciate the hard work you've put in this change! If something was not resolved in the latest main, feel free to open an issue and we'll look into it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Python SDK python Pull requests that update Python code 🟠 size/L tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpikTracer (LangChain integration) never evicts per-trace state, unbounded memory growth

2 participants