Visitar URL original
🐛 Bug Report: Langchain instrumentation adds empty create_agent graph nodes for every middleware hook · Issue #4628 · traceloop/openllmetry · GitHub
Skip to content

🐛 Bug Report: Langchain instrumentation adds empty create_agent graph nodes for every middleware hook #4628

Open

Description

@scampbell-h1

Which component is this bug for?

Langchain Instrumentation

📜 Description

  • _wrap_middleware_hooks wraps the AgentMiddleware base hooks on the class with wrap_function_wrapper (__init__.py#L298-L322). A wrapt FunctionWrapper returns a new bound wrapper on every attribute access, so AgentMiddleware.before_model is AgentMiddleware.before_model becomes False.
  • create_agent uses that identity to decide which middleware overrides a hook (factory.py#L1166-L1185). After instrumenting, every middleware looks like it overrides all four hooks, so the graph gains an empty before_model / after_model node per middleware per model turn. Each node counts as a step against recursion_limit.
  • In our agents with four wrap_model_call middlewares, a model turn went from about 2 steps to about 10, and runs hit GraphRecursionError around 5x earlier than without tracing.
  • Why we're raising this here rather than with LangChain:
    • there might be an improvement to LangChain's check that could harden their logic but I think the tracing is meant to observe the agent without changing how it runs, and fix(langchain): Use instance-level wrapping for AgentMiddleware hooks #4000 suggests it can be fixed entirely inside the instrumentation.
    • openllmetry's span wrapper also seems to fire only when the base (no-op) hook runs. A middleware that really overrides before_model replaces the base method, so the wrapper never runs and I don't think real hook overrides get an execute_task span today. Could you confirm whether that matches your understanding?

👟 Reproduction steps

  1. Save the script below as repro.py.
  2. Run it in a clean environment:
    uv run --no-project --with "opentelemetry-instrumentation-langchain==0.62.4" --with "langchain>=1.3" --with opentelemetry-sdk python repro.py
    
from importlib.metadata import version
import sys

from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, wrap_model_call
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage


@wrap_model_call
def passthrough(request, handler):
    return handler(request)


def build_nodes():
    model = FakeMessagesListChatModel(responses=[AIMessage(content="done")])
    agent = create_agent(model, tools=[], middleware=[passthrough])
    return sorted(agent.get_graph().nodes)


print("python", sys.version.split()[0])
for package in ("langchain", "langgraph", "opentelemetry-instrumentation-langchain"):
    print(package, version(package))

print("before instrument:", build_nodes())
print("identity before:", AgentMiddleware.before_model is AgentMiddleware.before_model)

from opentelemetry.instrumentation.langchain import LangchainInstrumentor

LangchainInstrumentor().instrument()

print("after instrument: ", build_nodes())
print("identity after:", AgentMiddleware.before_model is AgentMiddleware.before_model)

👍 Expected behavior

Instrumenting doesn't change the agent graph. A middleware that only implements wrap_model_call adds no nodes:

after instrument:  ['__end__', '__start__', 'model']
identity after: True

👎 Actual Behavior with Screenshots

python 3.14.0
langchain 1.4.6
langgraph 1.2.15
opentelemetry-instrumentation-langchain 0.62.4
before instrument: ['__end__', '__start__', 'model']
identity before: True
after instrument:  ['__end__', '__start__', 'model', 'passthrough.after_agent', 'passthrough.after_model', 'passthrough.before_agent', 'passthrough.before_model']
identity after: False

🤖 Python Version

3.14.0. Also seen on 3.12.

📃 Provide any additional context for the Bug.

👀 Have you spent some time to check if this bug has been raised before?

  • I checked and didn't find similar issue

Activity

  1. DEVJHAWAR11 commented on Oct 16, 2026

    @DEVJHAWAR11

    Hi, I can reproduce this with the script above. The base hooks get wrapped, so AgentMiddleware.before_model is AgentMiddleware.before_model turns False and create_agent adds a node per middleware. I'd like to fix it. #4000 looks like the same idea but has been conflicting since April. Would you prefer I rebase and finish that one, or open a fresh PR with a regression test (graph nodes unchanged after instrument(), and the hooks still traced when a middleware really overrides them)? Happy to go either way.

  2. scampbell-h1 commented on Oct 16, 2026

    @scampbell-h1
    Author

    One more effect of the same wrapping, which a colleague spotted: it can stop a middleware's hook from running at all.

    A middleware hook has a sync and an async version. If a middleware writes only one of them, LangChain normally either runs that one or raises an error. With instrumentation on, LangChain thinks both were written, so it runs the wrapped empty base method in place of the missing one. The hook is skipped silently.

    Middleware defines Agent called with No tracing 0.62.4 instrumented
    only before_model ainvoke hook runs hook silently skipped
    only abefore_model invoke raises TypeError hook silently skipped

    Reproduction steps

    1. Save the script below as repro_dispatch.py.
    2. Run it in a clean environment:
      uv run --no-project --with "opentelemetry-instrumentation-langchain==0.62.4" --with "langchain>=1.3" --with opentelemetry-sdk python repro_dispatch.py
      
    import asyncio
    
    from langchain.agents import create_agent
    from langchain.agents.middleware import AgentMiddleware
    from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
    from langchain_core.messages import AIMessage
    
    
    class SyncOnly(AgentMiddleware):
        calls = 0
    
        def before_model(self, state, runtime):
            SyncOnly.calls += 1
    
    
    class AsyncOnly(AgentMiddleware):
        async def abefore_model(self, state, runtime):
            return None
    
    
    def agent(middleware):
        model = FakeMessagesListChatModel(responses=[AIMessage(content="done")])
        return create_agent(model, tools=[], middleware=[middleware])
    
    
    def dispatch():
        request = {"messages": [{"role": "user", "content": "hi"}]}
        SyncOnly.calls = 0
        asyncio.run(agent(SyncOnly()).ainvoke(request))
        try:
            agent(AsyncOnly()).invoke(request)
            async_only = "ran, no error"
        except Exception as error:
            async_only = f"raised {type(error).__name__}"
        return f"sync-only under ainvoke: called {SyncOnly.calls}x | async-only under invoke: {async_only}"
    
    
    print("before instrument:", dispatch())
    
    from opentelemetry.instrumentation.langchain import LangchainInstrumentor
    
    LangchainInstrumentor().instrument()
    print("after instrument: ", dispatch())

    Expected

    Instrumenting doesn't change which hooks run:

    before instrument: sync-only under ainvoke: called 1x | async-only under invoke: raised TypeError
    after instrument:  sync-only under ainvoke: called 1x | async-only under invoke: raised TypeError
    

    Actual (0.62.4, langchain 1.4.6, Python 3.14.0)

    before instrument: sync-only under ainvoke: called 1x | async-only under invoke: raised TypeError
    after instrument:  sync-only under ainvoke: called 0x | async-only under invoke: ran, no error
    
  3. DEVJHAWAR11 commented on Oct 16, 2026

    @DEVJHAWAR11

    I opened a PR with a fix: #4629. The base hooks are now plain functions via functools.wraps, so the identity check, the runtime signature and the sync/async dispatch all match the un-instrumented behaviour. Thanks @scampbell-h1 for the dispatch repro, it's in the tests. This should replace #4000, which fails with the runtime error described above.

  4. sdelmas commented on Oct 18, 2026

    @sdelmas

    I can confirm the skipped hook from the comment above with a standalone repro (scripted chat model, no API key). Versions: langchain 1.3.14, langchain-core 1.4.9, langgraph 1.2.12, opentelemetry-sdk 1.45.1.

    In the skipped runs an execute_task <Middleware>.abefore_model (or .before_model) span still appears. It comes from the wrapped base no-op, which runs in place of the user's hook.

    With #4629 installed, every instrumented case matches the uninstrumented one. One side effect: these middlewares no longer emit any execute_task hook span, because no base hook runs. That might be worth a changelog note.

    Repro: create_agent with one middleware and a fake chat model that answers immediately. Each case runs in a fresh process, once with LangchainInstrumentor().instrument() and once without, and counts hook calls against model calls.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions