Baike.dev
All toolsAI codingTrendingOpen sourceNewsSubmit
Log in
Back to tool/Back to issues
#7516·opik

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

Author: AnastasisBCreated Jul 18, 2026Updated Sep 17, 2026
LabelsJC

What component(s) are affected?

  • Opik Python SDK
  • Opik Typescript SDK
  • Opik Agent Optimizer SDK
  • Opik UI
  • Opik Server
  • Documentation

Opik version

  • Opik version: 2.1.31 (also present on main as of 2026-07-16, ca22d81ac215d965172286f4f0afe942cbb818c1)
  • Python: 3.10.12
  • langchain-core: 1.4.9

Describe the problem

opik.integrations.langchain.OpikTracer only ever adds to its per-run bookkeeping and never removes anything:

  • _span_data_map (run_id -> SpanData, includes full prompt/completion/tool payloads)
  • _created_traces_data_map (run_id -> TraceData)
  • _created_traces (list of Trace objects)
  • _externally_created_traces_ids (set of trace ids)
  • _skipped_langgraph_root_run_ids (set of run ids)
  • _langgraph_parent_span_ids (run_id -> parent span id)

Entries go in on run start (_create_root_trace_and_span, _attach_span_to_parent_span, _save_span_trace_data_to_local_maps) and nothing ever takes them out. There is no eviction in _persist_run, flush() or any close path. The only pop calls in opik_tracer.py are on the context storage stack, not on these maps.

Expected: a tracer built once and passed in config={"callbacks": [tracer]} on every invocation (the documented pattern) releases a trace's state when the trace finishes, so memory stays flat under a steady request rate.

Actual: every trace's state stays pinned for the process lifetime, including the full prompt/completion payloads referenced by the retained SpanData objects. So growth is data-sized, not just entry-counted.

On top of the memory, _is_opik_trace_created_by_this_tracer and _is_opik_span_created_by_this_tracer scan these maps on run start, so CPU per request also creeps up as the process ages.

We hit this in a production LangChain service with one long-lived tracer per flow. After 50 requests a single tracer held 300 _span_data_map entries, 350 _created_traces_data_map entries, 50 trace objects and ~311 KiB of prompt/completion payload bytes. The 25-request checkpoint had exactly half of everything, so growth is strictly linear and unbounded. That was measured on 1.10.32, but the bookkeeping code is unchanged through 2.1.31 and current main.

Reproduction steps and code snippets

Fully offline repro, no keys and no network: the Opik client is a no-op fake and the LLM is FakeListLLM. Run with opik==2.1.31, langchain-core==1.4.9:

python
"""Repro: opik.integrations.langchain.OpikTracer never evicts per-trace state."""

from types import SimpleNamespace


class _FakeConfig:
    log_start_trace_span = False


class _FakeOpikClient:
    """No-op stand-in for opik.Opik: absorbs trace/span emission calls."""

    config = _FakeConfig()

    def __internal_api__trace__(self, **kwargs):
        return SimpleNamespace(id=kwargs.get("id"))

    def __internal_api__span__(self, **kwargs):
        return None

    def flush(self):
        pass


# Inject the fake BEFORE anything constructs a tracer.
from opik.api_objects import opik_client

opik_client.set_global_client(_FakeOpikClient())

import opik  # noqa: E402
from langchain_core.language_models.fake import FakeListLLM  # noqa: E402
from langchain_core.prompts import PromptTemplate  # noqa: E402
from opik.integrations.langchain import OpikTracer  # noqa: E402

print(f"opik {opik.__version__}")

tracer = OpikTracer()  # constructed ONCE, the documented long-lived usage
prompt = PromptTemplate(input_variables=["n"], template="Say something about {n}.")
chain = prompt | FakeListLLM(responses=["ok"])


def report(n_calls: int) -> None:
    payload = sum(
        len(str(sd.input)) + len(str(sd.output))
        for sd in tracer._span_data_map.values()
    )
    print(
        f"after {n_calls:3d} invocations: "
        f"_span_data_map={len(tracer._span_data_map):4d}  "
        f"_created_traces_data_map={len(tracer._created_traces_data_map):4d}  "
        f"_created_traces={len(tracer._created_traces):4d}  "
        f"_skipped_langgraph_root_run_ids={len(tracer._skipped_langgraph_root_run_ids):4d}  "
        f"_langgraph_parent_span_ids={len(tracer._langgraph_parent_span_ids):4d}  "
        f"retained_payload_bytes={payload}"
    )


for n in range(1, 51):
    chain.invoke({"n": str(n)}, config={"callbacks": [tracer]})
    if n in (10, 25, 50):
        report(n)

Actual output:

opik 2.1.31
after  10 invocations: _span_data_map=  20  _created_traces_data_map=  30  _created_traces=  10  _skipped_langgraph_root_run_ids=  10  _langgraph_parent_span_ids=  10  retained_payload_bytes=2603
after  25 invocations: _span_data_map=  50  _created_traces_data_map=  75  _created_traces=  25  _skipped_langgraph_root_run_ids=  25  _langgraph_parent_span_ids=  25  retained_payload_bytes=6548
after  50 invocations: _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

Everything grows linearly with the invocation count and never shrinks.

Error logs or stack trace

No error is raised. The process just grows until the host/container hits memory pressure (OOM in long-running deployments).

Healthcheck results

Not applicable: the repro is fully offline (fake backend client, fake LLM), no Opik deployment involved.

Source: comet-ml/opik

View original on GitHubView discussion on GitHub