Skip to content

Commit cbf491d

Browse files
committed
feat(langchain): add callback patcher integration
Add a CallbackPatcher base primitive and use it to model LangChain as an SDK integration that installs a default Braintrust callback handler. Export the LangChain integration, wire it into auto_instrument(), and add focused setup and auto-instrument tests. Also document CallbackPatcher usage in the sdk-integrations skill so future integrations can use the same pattern for setup-only side effects.
1 parent ba0bc10 commit cbf491d

11 files changed

Lines changed: 267 additions & 18 deletions

File tree

.agents/skills/sdk-integrations/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,8 @@ Use `CompositeFunctionWrapperPatcher` when several closely related targets shoul
224224
- sync and async variants of the same method
225225
- the same logical surface patched across multiple modules
226226

227+
Use `CallbackPatcher` when the integration only needs a setup side effect after applicability succeeds, not in-place wrapping or class replacement. Good fits include registering a global callback handler, invoking a provider-owned configure hook, or installing default integration state. Gate it with `target_module` when the side effect depends on an optional module, and use `state_getter` when idempotence should be derived from integration-managed state instead of a marker on the imported root.
228+
227229
Set `target_module` when the patch target lives outside the module named by `import_names`, especially for optional or deep submodules. Failed `target_module` imports should make the patcher skip cleanly through `applies()`.
228230

229231
Use `superseded_by` for version-conditional mutual exclusion. Express fallback relationships declaratively instead of reproducing `hasattr` logic in custom `applies()` methods whenever possible.

py/src/braintrust/auto.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
ClaudeAgentSDKIntegration,
1616
DSPyIntegration,
1717
GoogleGenAIIntegration,
18+
LangChainIntegration,
1819
LiteLLMIntegration,
1920
OpenRouterIntegration,
2021
PydanticAIIntegration,
@@ -50,6 +51,7 @@ def auto_instrument(
5051
claude_agent_sdk: bool = True,
5152
dspy: bool = True,
5253
adk: bool = True,
54+
langchain: bool = True,
5355
) -> dict[str, bool]:
5456
"""
5557
Auto-instrument supported AI/ML libraries for Braintrust tracing.
@@ -72,6 +74,7 @@ def auto_instrument(
7274
claude_agent_sdk: Enable Claude Agent SDK instrumentation (default: True)
7375
dspy: Enable DSPy instrumentation (default: True)
7476
adk: Enable Google ADK instrumentation (default: True)
77+
langchain: Enable LangChain instrumentation (default: True)
7578
7679
Returns:
7780
Dict mapping integration name to whether it was successfully instrumented.
@@ -141,6 +144,8 @@ def auto_instrument(
141144
results["dspy"] = _instrument_integration(DSPyIntegration)
142145
if adk:
143146
results["adk"] = _instrument_integration(ADKIntegration)
147+
if langchain:
148+
results["langchain"] = _instrument_integration(LangChainIntegration)
144149

145150
return results
146151

py/src/braintrust/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .claude_agent_sdk import ClaudeAgentSDKIntegration
66
from .dspy import DSPyIntegration
77
from .google_genai import GoogleGenAIIntegration
8+
from .langchain import LangChainIntegration
89
from .litellm import LiteLLMIntegration
910
from .openrouter import OpenRouterIntegration
1011
from .pydantic_ai import PydanticAIIntegration
@@ -19,6 +20,7 @@
1920
"DSPyIntegration",
2021
"GoogleGenAIIntegration",
2122
"LiteLLMIntegration",
23+
"LangChainIntegration",
2224
"OpenRouterIntegration",
2325
"PydanticAIIntegration",
2426
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Test auto_instrument for LangChain."""
2+
3+
from pathlib import Path
4+
5+
from braintrust.auto import auto_instrument
6+
from braintrust.integrations.langchain import BraintrustCallbackHandler
7+
from braintrust.integrations.langchain.context import clear_global_handler, get_global_handler
8+
from braintrust.wrappers.test_utils import autoinstrument_test_context
9+
from langchain_core.callbacks import CallbackManager
10+
from langchain_core.prompts import ChatPromptTemplate
11+
from langchain_openai import ChatOpenAI
12+
13+
14+
_CASSETTES_DIR = Path(__file__).resolve().parent.parent / "langchain" / "cassettes"
15+
16+
# 1. Verify not patched initially.
17+
clear_global_handler()
18+
assert get_global_handler() is None
19+
manager = CallbackManager.configure()
20+
assert next((h for h in manager.handlers if isinstance(h, BraintrustCallbackHandler)), None) is None
21+
22+
# 2. Instrument.
23+
results = auto_instrument()
24+
assert results.get("langchain") == True
25+
handler = get_global_handler()
26+
assert isinstance(handler, BraintrustCallbackHandler)
27+
28+
manager = CallbackManager.configure()
29+
assert next((h for h in manager.handlers if isinstance(h, BraintrustCallbackHandler)), None) is handler
30+
31+
# 3. Idempotent.
32+
results2 = auto_instrument()
33+
assert results2.get("langchain") == True
34+
assert get_global_handler() is handler
35+
36+
# 4. Make an API call and verify spans.
37+
with autoinstrument_test_context("test_global_handler", cassettes_dir=_CASSETTES_DIR) as memory_logger:
38+
prompt = ChatPromptTemplate.from_template("What is 1 + {number}?")
39+
model = ChatOpenAI(
40+
model="gpt-4o-mini",
41+
temperature=1,
42+
top_p=1,
43+
frequency_penalty=0,
44+
presence_penalty=0,
45+
n=1,
46+
)
47+
chain = prompt.pipe(model)
48+
49+
message = chain.invoke({"number": "2"})
50+
assert message.content == "1 + 2 equals 3."
51+
52+
spans = memory_logger.pop()
53+
assert len(spans) > 0
54+
55+
print("SUCCESS")

py/src/braintrust/integrations/base.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,69 @@ def patch(cls, module: Any | None, version: str | None, *, target: Any | None =
6969
raise NotImplementedError
7070

7171

72+
class CallbackPatcher(BasePatcher):
73+
"""Base patcher for integration setup steps that only execute a callback.
74+
75+
Use this for integrations that do not need in-place wrapping or class
76+
replacement, but still need an idempotent setup action once a target module
77+
is available — for example, registering a global callback handler.
78+
79+
Set ``target_module`` when the callback should only run if a particular
80+
optional module can be imported. Provide ``state_getter`` when patch state
81+
should be derived from integration-owned state instead of a marker stored on
82+
the resolved root object.
83+
"""
84+
85+
target_module: ClassVar[str | None] = None
86+
callback: ClassVar[Any]
87+
state_getter: ClassVar[Any | None] = None
88+
89+
@classmethod
90+
def resolve_root(cls, module: Any | None, version: str | None, *, target: Any | None = None) -> Any | None:
91+
"""Return the object whose availability gates this callback patcher."""
92+
if target is not None:
93+
return target
94+
if cls.target_module is not None:
95+
try:
96+
return importlib.import_module(cls.target_module)
97+
except ImportError:
98+
return None
99+
return module
100+
101+
@classmethod
102+
def applies(cls, module: Any | None, version: str | None, *, target: Any | None = None) -> bool:
103+
"""Return whether the callback should run for the given module/version."""
104+
return (
105+
super().applies(module, version, target=target)
106+
and cls.resolve_root(module, version, target=target) is not None
107+
)
108+
109+
@classmethod
110+
def is_patched(cls, module: Any | None, version: str | None, *, target: Any | None = None) -> bool:
111+
"""Return whether this callback patcher has already been applied."""
112+
if cls.state_getter is not None:
113+
return bool(cls.state_getter())
114+
root = cls.resolve_root(module, version, target=target)
115+
return bool(root is not None and cls.has_patch_marker(root))
116+
117+
@classmethod
118+
def patch(cls, module: Any | None, version: str | None, *, target: Any | None = None) -> bool:
119+
"""Execute the callback and mark the root as patched when needed."""
120+
root = cls.resolve_root(module, version, target=target)
121+
if root is None or not cls.applies(module, version, target=target):
122+
return False
123+
124+
result = cls.callback()
125+
if result is False:
126+
return False
127+
128+
if cls.state_getter is not None:
129+
return cls.is_patched(module, version, target=target)
130+
131+
cls.mark_patched(root)
132+
return True
133+
134+
72135
class ClassScanPatcher(BasePatcher):
73136
"""Base patcher for rescanning and patching discovered class hierarchies."""
74137

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
1-
from braintrust.integrations.langchain.callbacks import BraintrustCallbackHandler
2-
from braintrust.integrations.langchain.context import set_global_handler
1+
"""Braintrust integration for LangChain."""
32

3+
from braintrust.logger import NOOP_SPAN, current_span, init_logger
44

5-
__all__ = ["BraintrustCallbackHandler", "set_global_handler"]
5+
from .integration import LangChainIntegration
6+
7+
8+
try:
9+
from .callbacks import BraintrustCallbackHandler
10+
from .context import set_global_handler
11+
except ImportError as exc: # pragma: no cover - optional dependency not installed
12+
_IMPORT_ERROR = exc
13+
14+
class BraintrustCallbackHandler: # type: ignore[no-redef]
15+
def __init__(self, *args, **kwargs):
16+
raise ImportError("langchain-core is required for braintrust.integrations.langchain") from _IMPORT_ERROR
17+
18+
def set_global_handler(handler): # type: ignore[no-redef]
19+
raise ImportError("langchain-core is required for braintrust.integrations.langchain") from _IMPORT_ERROR
20+
21+
22+
__all__ = [
23+
"BraintrustCallbackHandler",
24+
"LangChainIntegration",
25+
"set_global_handler",
26+
"setup_langchain",
27+
]
28+
29+
30+
def setup_langchain(
31+
api_key: str | None = None,
32+
project_id: str | None = None,
33+
project_name: str | None = None,
34+
) -> bool:
35+
"""Setup Braintrust integration with LangChain."""
36+
if current_span() == NOOP_SPAN:
37+
init_logger(project=project_name, api_key=api_key, project_id=project_id)
38+
39+
return LangChainIntegration.setup()

py/src/braintrust/integrations/langchain/callbacks.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
)
1212
from uuid import UUID
1313

14-
import braintrust
15-
from braintrust import NOOP_SPAN, Logger, Span, SpanAttributes, SpanTypeAttribute, current_span, init_logger
14+
from braintrust.generated_types import SpanAttributes
15+
from braintrust.logger import NOOP_SPAN, Logger, Span, current_span, init_logger, start_span
16+
from braintrust.span_types import SpanTypeAttribute
1617
from braintrust.version import VERSION as sdk_version
1718
from langchain_core.agents import AgentAction, AgentFinish
1819
from langchain_core.callbacks.base import BaseCallbackHandler
@@ -93,8 +94,6 @@ def _start_span(
9394
parent_span = current_parent
9495
elif self.logger is not None:
9596
parent_span = self.logger
96-
else:
97-
parent_span = braintrust
9897

9998
if event is None:
10099
event = {}
@@ -116,15 +115,26 @@ def _start_span(
116115
},
117116
}
118117

119-
span = parent_span.start_span(
120-
name=name,
121-
type=type,
122-
span_attributes=span_attributes,
123-
start_time=start_time,
124-
set_current=set_current,
125-
parent=parent,
126-
**event,
127-
)
118+
if parent_span is None:
119+
span = start_span(
120+
name=name,
121+
type=type,
122+
span_attributes=span_attributes,
123+
start_time=start_time,
124+
set_current=set_current,
125+
parent=parent,
126+
**event,
127+
)
128+
else:
129+
span = parent_span.start_span(
130+
name=name,
131+
type=type,
132+
span_attributes=span_attributes,
133+
start_time=start_time,
134+
set_current=set_current,
135+
parent=parent,
136+
**event,
137+
)
128138

129139
if self.logger != NOOP_SPAN and span == NOOP_SPAN:
130140
_logger.warning(

py/src/braintrust/integrations/langchain/context.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from langchain_core.tracers.context import register_configure_hook
55

66

7-
__all__ = ["set_global_handler", "clear_global_handler"]
7+
__all__ = ["set_global_handler", "clear_global_handler", "get_global_handler"]
88

99

1010
braintrust_callback_handler_var: ContextVar[BraintrustCallbackHandler | None] = ContextVar(
@@ -16,6 +16,10 @@ def set_global_handler(handler: BraintrustCallbackHandler):
1616
braintrust_callback_handler_var.set(handler)
1717

1818

19+
def get_global_handler() -> BraintrustCallbackHandler | None:
20+
return braintrust_callback_handler_var.get()
21+
22+
1923
def clear_global_handler():
2024
braintrust_callback_handler_var.set(None)
2125

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""LangChain integration definition."""
2+
3+
from braintrust.integrations.base import BaseIntegration
4+
5+
from .patchers import GlobalHandlerPatcher
6+
7+
8+
class LangChainIntegration(BaseIntegration):
9+
"""Braintrust instrumentation for LangChain via a global callback handler."""
10+
11+
name = "langchain"
12+
import_names = ("langchain_core",)
13+
patchers = (GlobalHandlerPatcher,)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""LangChain patchers."""
2+
3+
from braintrust.integrations.base import CallbackPatcher
4+
5+
6+
try:
7+
from .callbacks import BraintrustCallbackHandler
8+
from .context import get_global_handler, set_global_handler
9+
except ImportError: # pragma: no cover - optional dependency not installed
10+
BraintrustCallbackHandler = None
11+
get_global_handler = None
12+
set_global_handler = None
13+
14+
15+
def _has_global_handler() -> bool:
16+
if BraintrustCallbackHandler is None or get_global_handler is None:
17+
return False
18+
return isinstance(get_global_handler(), BraintrustCallbackHandler)
19+
20+
21+
def _set_default_global_handler() -> None:
22+
if BraintrustCallbackHandler is None or get_global_handler is None or set_global_handler is None:
23+
raise ImportError("langchain_core is not installed")
24+
25+
if isinstance(get_global_handler(), BraintrustCallbackHandler):
26+
return
27+
28+
set_global_handler(BraintrustCallbackHandler())
29+
30+
31+
class GlobalHandlerPatcher(CallbackPatcher):
32+
"""Install the default Braintrust callback handler into LangChain's configure hook."""
33+
34+
name = "langchain.global_handler"
35+
target_module = "langchain_core.tracers.context"
36+
callback = _set_default_global_handler
37+
state_getter = _has_global_handler

0 commit comments

Comments
 (0)