Files
2026-07-13 13:32:05 +08:00

546 lines
18 KiB
Python

import asyncio
import logging
import pytest
from typing import Any, List, Optional, Tuple
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_core.language_models.fake import FakeListLLM
from langchain_core.language_models.llms import LLM
from langchain_core.runnables import RunnableLambda
from langchain_core.callbacks.manager import (
AsyncCallbackManagerForLLMRun,
CallbackManagerForLLMRun,
)
from deepeval.integrations.langchain import CallbackHandler
from deepeval.tracing import observe, trace_manager
from deepeval.tracing.context import current_span_context, current_trace_context
class RaisingLLM(LLM):
"""Minimal LLM that always raises to trigger on_llm_error reliably."""
@property
def _llm_type(self) -> str:
return "raising-llm"
def _call(
self,
prompt: str,
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> str:
raise RuntimeError("boom")
async def _acall(
self,
prompt: str,
stop: Optional[List[str]] = None,
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> str:
raise RuntimeError("boom")
class RecordingCallbackHandler(CallbackHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.chain_runs: List[Tuple[str, Optional[str]]] = []
self.llm_runs: List[Tuple[str, Optional[str]]] = []
self.events: List[Tuple[str, str]] = [] # maps event name to run_id
# mapping of langchain run_id -> DeepEval span.parent_uuid so we can validate parentage
self.span_parents_start = {}
self.span_parents_end = {}
self.span_parents_error = {}
def _record_parent_if_present(self, run_id: str, target: dict):
span = trace_manager.get_span_by_uuid(run_id)
if span is not None:
target[run_id] = span.parent_uuid
def on_chain_start(
self, serialized, inputs, *, run_id, parent_run_id=None, **kwargs
):
rid = str(run_id)
self.chain_runs.append(
(rid, str(parent_run_id) if parent_run_id else None)
)
self.events.append(("chain_start", rid))
res = super().on_chain_start(
serialized,
inputs,
run_id=run_id,
parent_run_id=parent_run_id,
**kwargs,
)
self._record_parent_if_present(rid, self.span_parents_start)
return res
def on_chain_end(self, outputs, *, run_id, parent_run_id=None, **kwargs):
rid = str(run_id)
self.events.append(("chain_end", rid))
# Observe parent before super() exits/removes the span
self._record_parent_if_present(rid, self.span_parents_end)
res = super().on_chain_end(
outputs, run_id=run_id, parent_run_id=parent_run_id, **kwargs
)
if parent_run_id is None:
# After end, span should be removed from active store
assert trace_manager.get_span_by_uuid(rid) is None
return res
def on_chain_error(self, error, *, run_id, parent_run_id=None, **kwargs):
rid = str(run_id)
self.events.append(("chain_error", rid))
self._record_parent_if_present(rid, self.span_parents_error)
res = super().on_chain_error(
error, run_id=run_id, parent_run_id=parent_run_id, **kwargs
)
if parent_run_id is None:
assert trace_manager.get_span_by_uuid(rid) is None
return res
def on_llm_start(
self, serialized, prompts, *, run_id, parent_run_id=None, **kwargs
):
rid = str(run_id)
self.llm_runs.append(
(rid, str(parent_run_id) if parent_run_id else None)
)
self.events.append(("llm_start", rid))
res = super().on_llm_start(
serialized,
prompts,
run_id=run_id,
parent_run_id=parent_run_id,
**kwargs,
)
self._record_parent_if_present(rid, self.span_parents_start)
return res
def on_llm_end(self, response, *, run_id, parent_run_id=None, **kwargs):
rid = str(run_id)
self.events.append(("llm_end", rid))
self._record_parent_if_present(rid, self.span_parents_end)
res = super().on_llm_end(
response, run_id=run_id, parent_run_id=parent_run_id, **kwargs
)
assert trace_manager.get_span_by_uuid(rid) is None
return res
def on_llm_error(self, error, *, run_id, parent_run_id=None, **kwargs):
rid = str(run_id)
self.events.append(("llm_error", rid))
self._record_parent_if_present(rid, self.span_parents_error)
res = super().on_llm_error(
error, run_id=run_id, parent_run_id=parent_run_id, **kwargs
)
assert trace_manager.get_span_by_uuid(rid) is None
return res
class State(TypedDict, total=False):
prompt: str
output: str
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_langgraph_async_callback_does_not_print_span_mismatch(capsys):
"""LangGraph async execution should not break the DeepEval span context stack:
we should not print 'Current span in context does not match the span being exited'.
"""
llm = FakeListLLM(responses=["pong"])
async def node(state: State, config=None) -> dict:
out = await llm.ainvoke(state["prompt"], config=config)
return {"output": out}
builder = StateGraph(State)
builder.add_node("llm", node)
builder.add_edge(START, "llm")
builder.add_edge("llm", END)
graph = builder.compile()
callback = CallbackHandler(metric_collection="test_langgraph_async")
result = await graph.ainvoke(
{"prompt": "ping"},
config={"callbacks": [callback]},
)
assert result["output"] == "pong"
out = (
capsys.readouterr().out
) # captures everything printed to stdout so far
assert (
"Current span in context does not match the span being exited"
not in out
)
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_nested_async_calls_are_parented_correctly_by_ids(capsys):
"""A chain that calls an LLM should report parentage consistently:
LangChain passes parent_run_id=<chain run_id>, and DeepEval records span.parent_uuid=<chain run_id>.
"""
llm = FakeListLLM(responses=["pong"])
callback = RecordingCallbackHandler(
metric_collection="test_nested_async_ids"
)
async def outer(_input, config=None):
return await llm.ainvoke("ping", config=config)
result = await RunnableLambda(outer).ainvoke(
"unused",
config={"callbacks": [callback]},
)
assert result == "pong"
# Symptom guard (stack mismatch)
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
# assert that LangChain callback inputs report the expected parent_run_id relationship
assert callback.chain_runs
assert callback.llm_runs
outer_run_id, _ = callback.chain_runs[0]
llm_run_id, llm_parent = callback.llm_runs[0]
assert (
llm_parent == outer_run_id
), f"Expected LLM parent={outer_run_id}, got {llm_parent}"
# assert that DeepEval spans created in trace_manager have the expected parent_uuid relationship
assert (
outer_run_id in callback.span_parents_start
), "Expected to observe root span in trace_manager during on_chain_start"
assert (
llm_run_id in callback.span_parents_start
), "Expected to observe llm span in trace_manager during on_llm_start"
assert (
callback.span_parents_start[llm_run_id] == outer_run_id
), f"Expected llm span.parent_uuid={outer_run_id}, got {callback.span_parents_start[llm_run_id]}"
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_llm_error_path_tracks_correct_ids_and_cleans_up(capsys):
"""If the LLM raises, we should report the error without corrupting the span stack:
no span-mismatch print, an llm_error event is recorded, and the LLM span is removed.
"""
llm = RaisingLLM()
callback = RecordingCallbackHandler(
metric_collection="test_llm_error_cleanup"
)
async def outer(_input, config=None):
return await llm.ainvoke("ping", config=config)
with pytest.raises(RuntimeError, match="boom"):
await RunnableLambda(outer).ainvoke(
"unused", config={"callbacks": [callback]}
)
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
assert callback.llm_runs
llm_run_id, _ = callback.llm_runs[0]
# Span existed at start and was observed, and was cleaned on error.
assert llm_run_id in callback.span_parents_start
assert ("llm_error", llm_run_id) in callback.events
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_chain_error_path_cleans_up_and_no_mismatch(capsys):
"""If the outer chain raises, we should report the error without corrupting the span stack:
no span-mismatch print, a chain_error event is recorded, and the chain span is removed.
"""
callback = RecordingCallbackHandler(
metric_collection="test_chain_error_cleanup"
)
async def outer(_input, config=None):
raise RuntimeError("chain-boom")
with pytest.raises(RuntimeError, match="chain-boom"):
await RunnableLambda(outer).ainvoke(
"unused", config={"callbacks": [callback]}
)
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
assert callback.chain_runs
chain_run_id, _ = callback.chain_runs[0]
assert ("chain_error", chain_run_id) in callback.events
assert chain_run_id in callback.span_parents_start
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_parallel_llm_calls_under_same_parent_are_parented_correctly(
capsys,
):
"""Two concurrent LLM calls inside one chain should share the same parent:
LangChain passes parent_run_id=<chain run_id> for both, and DeepEval records span.parent_uuid=<chain run_id> for both.
"""
llm = FakeListLLM(responses=["pong", "pong"])
callback = RecordingCallbackHandler(
metric_collection="test_parallel_llm_calls"
)
async def outer(_input, config=None):
a, b = await asyncio.gather(
llm.ainvoke("ping1", config=config),
llm.ainvoke("ping2", config=config),
)
return a + b
result = await RunnableLambda(outer).ainvoke(
"unused",
config={"callbacks": [callback]},
)
assert result == "pongpong"
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
assert callback.chain_runs
outer_run_id, _ = callback.chain_runs[0]
assert (
len(callback.llm_runs) >= 2
), f"Expected >=2 llm runs, got {len(callback.llm_runs)}"
# Each llm call should be parented to the outer chain run
for llm_run_id, llm_parent in callback.llm_runs[:2]:
assert (
llm_parent == outer_run_id
), f"Expected LLM parent={outer_run_id}, got {llm_parent}"
assert llm_run_id in callback.span_parents_start
assert callback.span_parents_start[llm_run_id] == outer_run_id
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_chain_inside_chain_then_llm_is_parented_correctly(capsys):
"""LangChain reports structure as root -> nested -> llm, and DeepEval preserves that structure."""
llm = FakeListLLM(responses=["pong"])
callback = RecordingCallbackHandler(
metric_collection="test_chain_chain_llm"
)
async def inner(_input, config=None):
return await llm.ainvoke("ping", config=config)
inner_runnable = RunnableLambda(inner)
async def outer(_input, config=None):
# nested chain call
return await inner_runnable.ainvoke("unused-inner", config=config)
result = await RunnableLambda(outer).ainvoke(
"unused-outer",
config={"callbacks": [callback]},
)
assert result == "pong"
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
# Identify root chain (no parent) and nested chain (parent == root)
assert (
len(callback.chain_runs) >= 2
), f"Expected >=2 chain runs, got {len(callback.chain_runs)}"
root_chain_ids = [
run_id for run_id, parent in callback.chain_runs if parent is None
]
assert root_chain_ids, "Expected a root chain run (parent_run_id=None)"
root_chain_id = root_chain_ids[0]
nested_chain_ids = [
run_id
for run_id, parent in callback.chain_runs
if parent == root_chain_id
]
assert (
nested_chain_ids
), "Expected a nested chain run parented to the root chain"
nested_chain_id = nested_chain_ids[0]
assert callback.llm_runs, "Expected at least one llm run"
llm_run_id, llm_parent = callback.llm_runs[0]
# In this structure, the LLM call should be parented to the nested chain run.
assert (
llm_parent == nested_chain_id
), f"Expected LLM parent={nested_chain_id}, got {llm_parent}"
# DeepEval span parentage captured during starts should match as well
assert llm_run_id in callback.span_parents_start
assert callback.span_parents_start[llm_run_id] == nested_chain_id
assert callback.span_parents_start[nested_chain_id] == root_chain_id
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
async def test_nested_chain_chain_llm_end_order_and_parentage(capsys):
"""For nested chains, parentage should be root -> nested -> llm, and completion should be recorded:
the LLM and both chains should emit *_end events, and DeepEval should record span.parent_uuid consistent with that parentage.
"""
llm = FakeListLLM(responses=["pong"])
callback = RecordingCallbackHandler(
metric_collection="test_nested_end_order"
)
async def inner(_input, config=None):
return await llm.ainvoke("ping", config=config)
inner_runnable = RunnableLambda(inner)
async def outer(_input, config=None):
return await inner_runnable.ainvoke("unused-inner", config=config)
result = await RunnableLambda(outer).ainvoke(
"unused-outer", config={"callbacks": [callback]}
)
assert result == "pong"
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
# Parentage: root chain -> nested chain -> llm
root_chain_ids = [
rid for rid, parent in callback.chain_runs if parent is None
]
assert root_chain_ids
root_chain_id = root_chain_ids[0]
nested_chain_ids = [
rid for rid, parent in callback.chain_runs if parent == root_chain_id
]
assert nested_chain_ids
nested_chain_id = nested_chain_ids[0]
assert callback.llm_runs
llm_run_id, llm_parent = callback.llm_runs[0]
# DeepEval preserves LangChain parent_run_id hierarchy
assert callback.span_parents_start[nested_chain_id] == root_chain_id
assert callback.span_parents_start[llm_run_id] == nested_chain_id
# End events happened and cleanup assertions in handler already enforced span removal
assert ("llm_end", llm_run_id) in callback.events
assert ("chain_end", nested_chain_id) in callback.events
assert ("chain_end", root_chain_id) in callback.events
@pytest.mark.asyncio
@pytest.mark.filterwarnings(
"ignore:The 'config' parameter should be typed as 'RunnableConfig' or 'RunnableConfig \\| None'"
)
@pytest.mark.skip(
reason="Temporarily skipped: flaky on CI due to ContextVar leakage across asyncio task boundaries. Re-enable after tracing context cleanup is stabilized."
)
async def test_observe_wrapped_async_langgraph_callback_no_span_stack_mismatch(
capsys, caplog
):
"""
Repro for v.adynets:
- @observe works
- CallbackHandler works
- but @observe wrapping a CallbackHandler async run used to break with span mismatch and context token issues
This should only pass when callback context binding is callback safe regardless of execution context.
"""
caplog.set_level(logging.WARNING)
llm = FakeListLLM(responses=["pong"])
async def node(state: dict, config=None) -> dict:
out = await llm.ainvoke(state["prompt"], config=config)
return {"output": out}
builder = StateGraph(dict)
builder.add_node("llm", node)
builder.add_edge(START, "llm")
builder.add_edge("llm", END)
graph = builder.compile()
callback = CallbackHandler(metric_collection="test_observe_wraps_callback")
@observe(type="custom", name="observed_endpoint")
async def observed_run():
return await graph.ainvoke(
{"prompt": "ping"},
config={"callbacks": [callback]},
)
# Run it as a Task to mimic FastAPI scheduling / context boundaries
result = await asyncio.create_task(observed_run())
assert result["output"] == "pong"
out = capsys.readouterr().out
assert (
"Current span in context does not match the span being exited"
not in out
)
# Catch the other common failure mode you saw in logs earlier
assert "was created in a different Context" not in caplog.text
# Also ensure we don't leak contextvars after completion
assert current_span_context.get() is None
assert current_trace_context.get() is None