Skip to content

Commit 7da14b1

Browse files
ibolmoAbhiPrasad
andauthored
fix: Make ADK and pydantic integration wrappers thread safe (#23)
Co-authored-by: Abhijeet Prasad <abhijeet@braintrustdata.com>
1 parent f60a2d3 commit 7da14b1

5 files changed

Lines changed: 520 additions & 91 deletions

File tree

py/src/braintrust/wrappers/adk/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextvars
12
import inspect
23
import logging
34
import time
@@ -55,6 +56,15 @@ def setup_adk(
5556
runners.Runner = wrap_runner(runners.Runner)
5657
base_llm_flow.BaseLlmFlow = wrap_flow(base_llm_flow.BaseLlmFlow)
5758

59+
try:
60+
from google.adk.platform import thread as adk_thread
61+
62+
adk_thread.create_thread = _wrap_create_thread(adk_thread.create_thread)
63+
runners.create_thread = _wrap_create_thread(runners.create_thread)
64+
logger.debug("ADK thread bridge patching successful")
65+
except Exception as e:
66+
logger.warning(f"Failed to patch ADK thread bridge: {e}")
67+
5868
# Try to patch McpTool if available (MCP is optional)
5969
try:
6070
from google.adk.tools.mcp_tool import mcp_tool
@@ -75,6 +85,22 @@ def setup_adk(
7585
return False
7686

7787

88+
def _wrap_create_thread(create_thread):
89+
if _is_patched(create_thread):
90+
return create_thread
91+
92+
def _wrapped_create_thread(target: Any, *args: Any, **kwargs: Any):
93+
ctx = contextvars.copy_context()
94+
95+
def _run_in_context(*target_args: Any, **target_kwargs: Any):
96+
return ctx.run(target, *target_args, **target_kwargs)
97+
98+
return create_thread(_run_in_context, *args, **kwargs)
99+
100+
_wrapped_create_thread._braintrust_patched = True
101+
return _wrapped_create_thread
102+
103+
78104
def wrap_agent(Agent: Any) -> Any:
79105
if _is_patched(Agent):
80106
return Agent

py/src/braintrust/wrappers/adk/test_adk.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from braintrust.bt_json import bt_safe_deep_copy
88
from braintrust.logger import Attachment
99
from braintrust.test_helpers import init_test_logger
10-
from braintrust.wrappers.adk import setup_adk
10+
from braintrust.wrappers.adk import _wrap_create_thread, setup_adk
1111
from google.adk import Agent
1212

1313
ADK_VERSION = tuple(int(x) for x in pkg_version("google-adk").split(".")[:3])
@@ -50,6 +50,84 @@ def memory_logger():
5050
yield bgl
5151

5252

53+
def test_adk_thread_context_propagation(memory_logger):
54+
"""Runner.run should preserve Braintrust context across its thread bridge."""
55+
import asyncio
56+
57+
from braintrust import current_span, start_span
58+
from google.adk.agents import LlmAgent
59+
from google.adk.models.base_llm import BaseLlm
60+
from google.adk.models.llm_request import LlmRequest
61+
from google.adk.models.llm_response import LlmResponse
62+
from google.adk.models.registry import LLMRegistry
63+
from google.adk.runners import Runner
64+
from google.adk.sessions import InMemorySessionService
65+
from google.genai import types
66+
67+
assert not memory_logger.pop()
68+
69+
parent_seen = []
70+
71+
class TestLlm(BaseLlm):
72+
@classmethod
73+
def supported_models(cls) -> list[str]:
74+
return [r"test-llm-context-prop"]
75+
76+
async def generate_content_async(self, llm_request: LlmRequest, stream: bool = False):
77+
parent_seen.append(current_span())
78+
yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text="ok")]))
79+
80+
LLMRegistry.register(TestLlm)
81+
82+
agent = LlmAgent(
83+
name="echo_agent",
84+
model="test-llm-context-prop",
85+
instruction="Respond with ok.",
86+
)
87+
session_service = InMemorySessionService()
88+
app_name = "thread_bridge_app"
89+
user_id = "test-user"
90+
session_id = "test-session-thread"
91+
asyncio.run(
92+
session_service.create_session(
93+
app_name=app_name,
94+
user_id=user_id,
95+
session_id=session_id,
96+
)
97+
)
98+
runner = Runner(agent=agent, app_name=app_name, session_service=session_service)
99+
user_msg = types.Content(role="user", parts=[types.Part(text="hello")])
100+
101+
with start_span(name="adk_thread_parent") as parent_span:
102+
events = list(runner.run(user_id=user_id, session_id=session_id, new_message=user_msg))
103+
104+
assert events
105+
assert parent_seen
106+
thread_root = getattr(parent_seen[0], "root_span_id", None)
107+
assert thread_root is not None
108+
assert thread_root == parent_span.root_span_id
109+
110+
111+
def test_wrap_create_thread_exception_does_not_double_invoke_target():
112+
"""Regression test: target exceptions must not cause a second invocation."""
113+
call_count = 0
114+
115+
def create_thread(target, *args, **kwargs):
116+
return target(*args, **kwargs)
117+
118+
wrapped_create_thread = _wrap_create_thread(create_thread)
119+
120+
def target():
121+
nonlocal call_count
122+
call_count += 1
123+
raise RuntimeError("boom")
124+
125+
with pytest.raises(RuntimeError, match="boom"):
126+
wrapped_create_thread(target)
127+
128+
assert call_count == 1
129+
130+
53131
@pytest.mark.vcr
54132
@pytest.mark.asyncio
55133
async def test_adk_braintrust_integration(memory_logger):
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
interactions:
2+
- request:
3+
body: '{"messages":[{"role":"user","content":"Hello"}],"model":"gpt-4o-mini","stream":true,"stream_options":{"include_usage":true}}'
4+
headers:
5+
Accept:
6+
- application/json
7+
Accept-Encoding:
8+
- gzip, deflate
9+
Connection:
10+
- keep-alive
11+
Content-Length:
12+
- '136'
13+
Content-Type:
14+
- application/json
15+
Cookie:
16+
- __cf_bm=SnTjRxRlQXUGj3jo4FeKDaKGiTqzcsdhS_LPeF2hwmg-1766265191-1.0.1.1-aN3W.WEa6IjC4znMp0fTYaZOZeW.3xU8ex4fRGFibZ0fuR86Piz9hDDAEvh8Z9ewQ9WHoKwdnsDvPE5KIpoZY4xurXV7VS4TcwfukWZofIk;
17+
_cfuvid=tXOZ7vGE2DBF6L6fDg_veKtSaUVC4UPotJDezWYoYXI-1766265191281-0.0.1.1-604800000
18+
Host:
19+
- api.openai.com
20+
User-Agent:
21+
- pydantic-ai/1.37.0
22+
X-Stainless-Arch:
23+
- arm64
24+
X-Stainless-Async:
25+
- async:asyncio
26+
X-Stainless-Lang:
27+
- python
28+
X-Stainless-OS:
29+
- MacOS
30+
X-Stainless-Package-Version:
31+
- 2.14.0
32+
X-Stainless-Runtime:
33+
- CPython
34+
X-Stainless-Runtime-Version:
35+
- 3.13.3
36+
x-stainless-read-timeout:
37+
- '600'
38+
x-stainless-retry-count:
39+
- '0'
40+
method: POST
41+
uri: https://api.openai.com/v1/chat/completions
42+
response:
43+
body:
44+
string: 'data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"n4wIfDywj"}
45+
46+
47+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":"1"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"M7nBs8tJ4w"}
48+
49+
50+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"3AKnil79q5"}
51+
52+
53+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":"
54+
"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"tj0YRKsPzd"}
55+
56+
57+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"3G9IJGTD7B"}
58+
59+
60+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ury0XoZd21"}
61+
62+
63+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":"
64+
"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"mJX6hpQnaM"}
65+
66+
67+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":"3"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"qhlqTCCpyt"}
68+
69+
70+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2JL0YHl8QJ"}
71+
72+
73+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"DgeyW"}
74+
75+
76+
data: {"id":"chatcmpl-CoyYZrJy9JYFY664IA2WgzVOsQSmj","object":"chat.completion.chunk","created":1766265215,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_ee69c2ef48","choices":[],"usage":{"prompt_tokens":14,"completion_tokens":8,"total_tokens":22,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"pdj6Hh5uLUK"}
77+
78+
79+
data: [DONE]
80+
81+
82+
'
83+
headers:
84+
CF-RAY:
85+
- 9b1224f9cc5cdbe5-LAX
86+
Connection:
87+
- keep-alive
88+
Content-Type:
89+
- text/event-stream; charset=utf-8
90+
Date:
91+
- Sat, 20 Dec 2025 21:13:35 GMT
92+
Server:
93+
- cloudflare
94+
Strict-Transport-Security:
95+
- max-age=31536000; includeSubDomains; preload
96+
Transfer-Encoding:
97+
- chunked
98+
X-Content-Type-Options:
99+
- nosniff
100+
access-control-expose-headers:
101+
- X-Request-ID
102+
alt-svc:
103+
- h3=":443"; ma=86400
104+
cf-cache-status:
105+
- DYNAMIC
106+
openai-organization:
107+
- braintrust-data
108+
openai-processing-ms:
109+
- '159'
110+
openai-project:
111+
- proj_vsCSXafhhByzWOThMrJcZiw9
112+
openai-version:
113+
- '2020-10-01'
114+
x-envoy-upstream-service-time:
115+
- '171'
116+
x-openai-proxy-wasm:
117+
- v0.1
118+
x-ratelimit-limit-requests:
119+
- '30000'
120+
x-ratelimit-limit-tokens:
121+
- '150000000'
122+
x-ratelimit-remaining-requests:
123+
- '29999'
124+
x-ratelimit-remaining-tokens:
125+
- '149999992'
126+
x-ratelimit-reset-requests:
127+
- 2ms
128+
x-ratelimit-reset-tokens:
129+
- 0s
130+
x-request-id:
131+
- req_fac92d6c619a4d7e94154752dd20d282
132+
status:
133+
code: 200
134+
message: OK
135+
version: 1

py/src/braintrust/wrappers/pydantic_ai.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import contextvars
23
import logging
34
import sys
45
import time
@@ -50,6 +51,16 @@ def setup_pydantic_ai(
5051

5152
wrap_model_classes()
5253

54+
# Patch StreamedResponseSync to propagate context to background threads
55+
try:
56+
if hasattr(direct_module, "StreamedResponseSync"):
57+
wrap_function_wrapper(
58+
direct_module.StreamedResponseSync, "_start_producer", _create_start_producer_wrapper()
59+
)
60+
logger.debug("Pydantic AI StreamedResponseSync context propagation patching successful")
61+
except Exception as e:
62+
logger.warning(f"Failed to patch StreamedResponseSync context propagation: {e}")
63+
5364
return True
5465
except ImportError:
5566
# Not installed - this is expected when using auto_instrument()
@@ -1230,6 +1241,30 @@ def _extract_response_metrics(
12301241
return metrics if metrics else None
12311242

12321243

1244+
def _create_start_producer_wrapper():
1245+
"""Create wrapper for StreamedResponseSync._start_producer to propagate context.
1246+
1247+
StreamedResponseSync._start_producer creates a background thread that doesn't
1248+
inherit contextvars. This wrapper ensures Braintrust context flows to that thread
1249+
so nested instrumentation (like wrap_openai) creates properly parented spans.
1250+
"""
1251+
1252+
def wrapper(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> None:
1253+
ctx = contextvars.copy_context()
1254+
original_async_producer = instance._async_producer
1255+
1256+
def _context_wrapped_async_producer() -> None:
1257+
ctx.run(original_async_producer)
1258+
1259+
instance._async_producer = _context_wrapped_async_producer
1260+
try:
1261+
return wrapped(*args, **kwargs)
1262+
finally:
1263+
instance._async_producer = original_async_producer
1264+
1265+
return wrapper
1266+
1267+
12331268
def _is_patched(obj: Any) -> bool:
12341269
"""Check if object is already patched.
12351270

0 commit comments

Comments
 (0)