Skip to content

[OPIK-7425] [SDK] fix: LangChain OpikTracer no longer leaks per-run state - #7566

Merged
alexkuzmik merged 6 commits into
mainfrom
aliaksandrk/OPIK-7425-langchain-tracer-memory-leak
Jul 22, 2026
Merged

[OPIK-7425] [SDK] fix: LangChain OpikTracer no longer leaks per-run state#7566
alexkuzmik merged 6 commits into
mainfrom
aliaksandrk/OPIK-7425-langchain-tracer-memory-leak

Conversation

@alexkuzmik

Copy link
Copy Markdown
Collaborator

Details

opik.integrations.langchain.OpikTracer only ever added to its per-run bookkeeping and never evicted anything, so a tracer built once and reused on every invocation — the documented pattern — grew unbounded for the process lifetime, pinning full prompt/completion payloads (and slowing per-run map scans as the process aged). This makes a long-lived tracer's memory stay flat across invocations.

  • Extracted the per-run bookkeeping into a dedicated RunStateStore (run id → span/trace data, skipped-LangGraph roots; trace ownership is derived from the trace-data map instead of a redundant parallel set).
  • The tracer releases a finished root run's whole subtree in the root run's end-span handler — the callback LangChain fires after _persist_run — so _finalize_trace still sees every child span and the root span's own output is recorded before its state is dropped.
  • Removed the write-only _langgraph_parent_span_ids map (dead accumulation); created_traces() is intentionally left accumulating since it is public API.
  • Follow-up tidy with no behavior change: dropped the stale "local maps" indirection, and centralized the repeated "emit start events" gate and the end-of-span cleanup.

Change checklist

  • User facing
  • Documentation update

Issues

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 4.8
  • Scope: Full implementation (fix, refactor, and tests)
  • Human verification: Code review + full test suite run locally

Testing

  • Added a dedicated RunStateStore unit suite that exercises the store through its public API only (save/get, ownership, child-trace linking with the stream-restart fallback, subtree release, and a weakref check that a released SpanData is garbage-collected).
  • Added end-to-end memory checks (real chain + @track-nested chain) asserting the live SpanData count does not grow across repeated invocations. Confirmed these fail when eviction is disabled, so they genuinely guard the regression.
  • Reproduced the original report offline (fake backend client + FakeListLLM): retained payload bytes stay flat at 0 across 50 invocations, versus growing linearly before the fix.
  • Commands run (from sdks/python):
    • pytest tests/library_integration/langchain/{test_run_state,test_opik_tracer,test_langchain,test_langgraph,test_langchain_openai}.py → 117 passed, 2 skipped (skips are unrelated optional provider packages not installed locally).
    • ruff check, ruff format --check, and mypy on the changed files — all clean.

Documentation

N/A — no user-facing documentation changes; the fix restores the documented "build once, reuse on every invocation" behavior.

alexkuzmik and others added 3 commits July 22, 2026 13:12
…tate

OpikTracer only ever added to its per-run bookkeeping and never removed
anything, so a long-lived tracer reused across invocations - the
documented "build once, pass on every invoke" pattern - grew unbounded
for the process lifetime, pinning full prompt/completion payloads and
slowing the per-run map scans.

Extract that bookkeeping into a dedicated RunStateStore (run id -> span/
trace data, skipped-langgraph roots, trace ownership derived from the
trace data map). The tracer releases a root run's whole subtree once it
fully ends - in the root run's end-span handler, which LangChain calls
after _persist_run, so _finalize_trace still sees every child span and
the root span's own output is recorded before its state is dropped.

Also drop the write-only _langgraph_parent_span_ids map. created_traces()
is left accumulating - it is public API.

Tested via a dedicated RunStateStore unit suite (public API only) and
end-to-end memory checks asserting live SpanData does not grow across
repeated invocations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up cleanups, no behavior change:

- Drop _save_span_trace_data_to_local_maps and its "local/external maps"
  docstrings/comments - there are no local maps since the state moved to
  RunStateStore. Call the store directly instead.
- Extract the repeated "emit start trace/span if configured" gate into
  _emit_start_trace / _emit_start_span (+ _should_log_start_events).
- Extract the identical end-of-span cleanup shared by _process_end_span
  and _process_end_span_with_error into _release_ended_span_state.
- Simplify _skip_tracking to a single return.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK labels Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🐍 mypy — python sdk Static type check 1.31s
🐍 fix end of files — python sdk Ensure files end in a newline 0.03s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.03s
🐍 ruff-format — python sdk Format Python code (ruff) 0.01s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.01s
Total (5 ran) 1.39s
⏭️ 36 skipped (no matching files changed)
Hook Description Result
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

Comment thread sdks/python/src/opik/integrations/langchain/opik_tracer.py
Comment thread sdks/python/src/opik/integrations/langchain/opik_tracer.py
Comment thread sdks/python/src/opik/integrations/langchain/opik_tracer.py
Comment thread sdks/python/src/opik/integrations/langchain/run_state.py
- Restore test_opik_tracer__attach_span_to_parent_span__stream_restart_root
  (accidentally dropped in the run-state extraction); it now sets up and
  asserts through RunStateStore's public API instead of raw private maps.
- Replace the gc.collect()/weakref memory checks with deterministic
  assertions on RunStateStore.is_empty() - eviction is a dict pop, so it
  must not depend on garbage collection timing.
- Document in _persist_run why finalization is gated on trace ownership
  (LangChain calls _persist_run only for root runs; external traces are
  left for their real owner to finalize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread sdks/python/tests/library_integration/langchain/test_opik_tracer.py Outdated
Comment thread sdks/python/tests/library_integration/langchain/test_opik_tracer.py Outdated
Comment thread sdks/python/tests/library_integration/langchain/test_opik_tracer.py
Comment thread sdks/python/tests/library_integration/langchain/test_opik_tracer.py Outdated
Comment thread sdks/python/tests/library_integration/langchain/test_opik_tracer.py Outdated
@alexkuzmik
alexkuzmik marked this pull request as ready for review July 22, 2026 13:54
@alexkuzmik
alexkuzmik requested a review from a team as a code owner July 22, 2026 13:54
Address review feedback: drop private tracer._run_state access from the
integration tests and assert observable behavior instead.

- Rework the repeated-invocation and nested-@track tests to assert full
  fake_backend.trace_trees contents (structure, per-run inputs, outputs)
  and created_traces(), so a regression that drops root output or child
  spans is caught - not just that state was cleared.
- Remove the white-box stream-restart test that seeded tracer._run_state
  and called the private _attach_span_to_parent_span; the trace_id
  fallback it guarded is covered on the public store API by
  test_run_state__link_child_run_falls_back_to_trace_id_lookup.

The deterministic per-run release guarantee remains covered by the
RunStateStore unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@petrotiurin petrotiurin left a comment

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.

Nice work!

@alexkuzmik
alexkuzmik merged commit dad2d50 into main Jul 22, 2026
212 of 219 checks passed
@alexkuzmik
alexkuzmik deleted the aliaksandrk/OPIK-7425-langchain-tracer-memory-leak branch July 22, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

baz: pending Python SDK python Pull requests that update Python code 🔴 size/XL 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