Skip to content

Commit a226a0d

Browse files
authored
fix: #2489 lazily initialize tracing globals to avoid import-time fork hazards (#2499)
1 parent 1be977b commit a226a0d

File tree

4 files changed

+295
-24
lines changed

4 files changed

+295
-24
lines changed

src/agents/tracing/__init__.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import atexit
2-
31
from .config import TracingConfig
42
from .context import TraceCtxManager
53
from .create import (
@@ -19,8 +17,8 @@
1917
transcription_span,
2018
)
2119
from .processor_interface import TracingProcessor
22-
from .processors import default_exporter, default_processor
23-
from .provider import DefaultTraceProvider, TraceProvider
20+
from .processors import default_exporter
21+
from .provider import TraceProvider
2422
from .setup import get_trace_provider, set_trace_provider
2523
from .span_data import (
2624
AgentSpanData,
@@ -110,13 +108,3 @@ def set_tracing_export_api_key(api_key: str) -> None:
110108
Set the OpenAI API key for the backend exporter.
111109
"""
112110
default_exporter().set_api_key(api_key)
113-
114-
115-
set_trace_provider(DefaultTraceProvider())
116-
# Add the default processor, which exports traces and spans to the backend in batches. You can
117-
# change the default behavior by either:
118-
# 1. calling add_trace_processor(), which adds additional processors, or
119-
# 2. calling set_trace_processors(), which replaces the default processor.
120-
add_trace_processor(default_processor())
121-
122-
atexit.register(get_trace_provider().shutdown)

src/agents/tracing/processors.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,16 +354,47 @@ def _export_batches(self, force: bool = False):
354354
self._exporter.export(items_to_export)
355355

356356

357-
# Create a shared global instance:
358-
_global_exporter = BackendSpanExporter()
359-
_global_processor = BatchTraceProcessor(_global_exporter)
357+
# Lazily initialized defaults to avoid creating network clients or threading
358+
# primitives during module import (important for fork-based process models).
359+
_global_exporter: BackendSpanExporter | None = None
360+
_global_processor: BatchTraceProcessor | None = None
361+
_global_lock = threading.Lock()
360362

361363

362364
def default_exporter() -> BackendSpanExporter:
363365
"""The default exporter, which exports traces and spans to the backend in batches."""
364-
return _global_exporter
366+
global _global_exporter
367+
368+
exporter = _global_exporter
369+
if exporter is not None:
370+
return exporter
371+
372+
with _global_lock:
373+
exporter = _global_exporter
374+
if exporter is None:
375+
exporter = BackendSpanExporter()
376+
_global_exporter = exporter
377+
378+
return exporter
365379

366380

367381
def default_processor() -> BatchTraceProcessor:
368382
"""The default processor, which exports traces and spans to the backend in batches."""
369-
return _global_processor
383+
global _global_exporter
384+
global _global_processor
385+
386+
processor = _global_processor
387+
if processor is not None:
388+
return processor
389+
390+
with _global_lock:
391+
processor = _global_processor
392+
if processor is None:
393+
exporter = _global_exporter
394+
if exporter is None:
395+
exporter = BackendSpanExporter()
396+
_global_exporter = exporter
397+
processor = BatchTraceProcessor(exporter)
398+
_global_processor = processor
399+
400+
return processor

src/agents/tracing/setup.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,60 @@
11
from __future__ import annotations
22

3+
import atexit
4+
import threading
35
from typing import TYPE_CHECKING
46

57
if TYPE_CHECKING:
68
from .provider import TraceProvider
79

810
GLOBAL_TRACE_PROVIDER: TraceProvider | None = None
11+
_GLOBAL_TRACE_PROVIDER_LOCK = threading.Lock()
12+
_SHUTDOWN_HANDLER_REGISTERED = False
13+
14+
15+
def _shutdown_global_trace_provider() -> None:
16+
provider = GLOBAL_TRACE_PROVIDER
17+
if provider is not None:
18+
provider.shutdown()
919

1020

1121
def set_trace_provider(provider: TraceProvider) -> None:
1222
"""Set the global trace provider used by tracing utilities."""
1323
global GLOBAL_TRACE_PROVIDER
14-
GLOBAL_TRACE_PROVIDER = provider
24+
global _SHUTDOWN_HANDLER_REGISTERED
25+
26+
with _GLOBAL_TRACE_PROVIDER_LOCK:
27+
GLOBAL_TRACE_PROVIDER = provider
28+
if not _SHUTDOWN_HANDLER_REGISTERED:
29+
atexit.register(_shutdown_global_trace_provider)
30+
_SHUTDOWN_HANDLER_REGISTERED = True
1531

1632

1733
def get_trace_provider() -> TraceProvider:
18-
"""Get the global trace provider used by tracing utilities."""
19-
if GLOBAL_TRACE_PROVIDER is None:
20-
raise RuntimeError("Trace provider not set")
21-
return GLOBAL_TRACE_PROVIDER
34+
"""Get the global trace provider used by tracing utilities.
35+
36+
The default provider and processor are initialized lazily on first access so
37+
importing the SDK does not create network clients or threading primitives.
38+
"""
39+
global GLOBAL_TRACE_PROVIDER
40+
global _SHUTDOWN_HANDLER_REGISTERED
41+
42+
provider = GLOBAL_TRACE_PROVIDER
43+
if provider is not None:
44+
return provider
45+
46+
with _GLOBAL_TRACE_PROVIDER_LOCK:
47+
provider = GLOBAL_TRACE_PROVIDER
48+
if provider is None:
49+
from .processors import default_processor
50+
from .provider import DefaultTraceProvider
51+
52+
provider = DefaultTraceProvider()
53+
provider.register_processor(default_processor())
54+
GLOBAL_TRACE_PROVIDER = provider
55+
56+
if not _SHUTDOWN_HANDLER_REGISTERED:
57+
atexit.register(_shutdown_global_trace_provider)
58+
_SHUTDOWN_HANDLER_REGISTERED = True
59+
60+
return provider
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import subprocess
6+
import sys
7+
from pathlib import Path
8+
from typing import cast
9+
10+
REPO_ROOT = Path(__file__).resolve().parents[2]
11+
SRC_ROOT = REPO_ROOT / "src"
12+
13+
14+
def _run_python(script: str) -> dict[str, object]:
15+
env = os.environ.copy()
16+
pythonpath = env.get("PYTHONPATH")
17+
if pythonpath:
18+
env["PYTHONPATH"] = f"{SRC_ROOT}:{pythonpath}"
19+
else:
20+
env["PYTHONPATH"] = str(SRC_ROOT)
21+
22+
completed = subprocess.run(
23+
[sys.executable, "-c", script],
24+
cwd=REPO_ROOT,
25+
env=env,
26+
text=True,
27+
capture_output=True,
28+
check=True,
29+
)
30+
payload = json.loads(completed.stdout)
31+
if not isinstance(payload, dict):
32+
raise AssertionError("Subprocess payload must be a JSON object.")
33+
return cast(dict[str, object], payload)
34+
35+
36+
def test_import_agents_has_no_tracing_side_effects() -> None:
37+
payload = _run_python(
38+
"""
39+
import gc
40+
import json
41+
import httpx
42+
43+
clients_before = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client))
44+
import agents # noqa: F401
45+
from agents.tracing import processors as tracing_processors
46+
from agents.tracing import setup as tracing_setup
47+
clients_after = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client))
48+
49+
print(
50+
json.dumps(
51+
{
52+
"client_delta": clients_after - clients_before,
53+
"provider_initialized": tracing_setup.GLOBAL_TRACE_PROVIDER is not None,
54+
"exporter_initialized": tracing_processors._global_exporter is not None,
55+
"processor_initialized": tracing_processors._global_processor is not None,
56+
"shutdown_handler_registered": tracing_setup._SHUTDOWN_HANDLER_REGISTERED,
57+
}
58+
)
59+
)
60+
"""
61+
)
62+
63+
assert payload["client_delta"] == 0
64+
assert payload["provider_initialized"] is False
65+
assert payload["exporter_initialized"] is False
66+
assert payload["processor_initialized"] is False
67+
assert payload["shutdown_handler_registered"] is False
68+
69+
70+
def test_get_trace_provider_lazily_initializes_defaults() -> None:
71+
payload = _run_python(
72+
"""
73+
import json
74+
75+
from agents.tracing import setup as tracing_setup
76+
from agents.tracing import processors as tracing_processors
77+
78+
provider_before = tracing_setup.GLOBAL_TRACE_PROVIDER
79+
exporter_before = tracing_processors._global_exporter
80+
processor_before = tracing_processors._global_processor
81+
shutdown_before = tracing_setup._SHUTDOWN_HANDLER_REGISTERED
82+
83+
provider = tracing_setup.get_trace_provider()
84+
85+
provider_after = tracing_setup.GLOBAL_TRACE_PROVIDER
86+
exporter_after = tracing_processors._global_exporter
87+
processor_after = tracing_processors._global_processor
88+
shutdown_after = tracing_setup._SHUTDOWN_HANDLER_REGISTERED
89+
90+
print(
91+
json.dumps(
92+
{
93+
"provider_before": provider_before is not None,
94+
"exporter_before": exporter_before is not None,
95+
"processor_before": processor_before is not None,
96+
"shutdown_before": shutdown_before,
97+
"provider_after": provider_after is not None,
98+
"exporter_after": exporter_after is not None,
99+
"processor_after": processor_after is not None,
100+
"shutdown_after": shutdown_after,
101+
"provider_matches_global": provider_after is provider,
102+
}
103+
)
104+
)
105+
"""
106+
)
107+
108+
assert payload["provider_before"] is False
109+
assert payload["exporter_before"] is False
110+
assert payload["processor_before"] is False
111+
assert payload["shutdown_before"] is False
112+
113+
assert payload["provider_after"] is True
114+
assert payload["exporter_after"] is True
115+
assert payload["processor_after"] is True
116+
assert payload["shutdown_after"] is True
117+
assert payload["provider_matches_global"] is True
118+
119+
120+
def test_get_trace_provider_bootstraps_once() -> None:
121+
payload = _run_python(
122+
"""
123+
import json
124+
125+
from agents.tracing import processors as tracing_processors
126+
from agents.tracing import setup as tracing_setup
127+
128+
registrations = []
129+
130+
def fake_register(fn):
131+
registrations.append(fn)
132+
return fn
133+
134+
tracing_setup.atexit.register = fake_register
135+
tracing_setup.GLOBAL_TRACE_PROVIDER = None
136+
tracing_setup._SHUTDOWN_HANDLER_REGISTERED = False
137+
tracing_processors._global_exporter = None
138+
tracing_processors._global_processor = None
139+
140+
first = tracing_setup.get_trace_provider()
141+
second = tracing_setup.get_trace_provider()
142+
143+
print(
144+
json.dumps(
145+
{
146+
"same_provider": first is second,
147+
"shutdown_registration_count": sum(
148+
1
149+
for fn in registrations
150+
if getattr(fn, "__name__", "") == "_shutdown_global_trace_provider"
151+
),
152+
"provider_initialized": tracing_setup.GLOBAL_TRACE_PROVIDER is not None,
153+
"exporter_initialized": tracing_processors._global_exporter is not None,
154+
"processor_initialized": tracing_processors._global_processor is not None,
155+
}
156+
)
157+
)
158+
"""
159+
)
160+
161+
assert payload["same_provider"] is True
162+
assert payload["shutdown_registration_count"] == 1
163+
assert payload["provider_initialized"] is True
164+
assert payload["exporter_initialized"] is True
165+
assert payload["processor_initialized"] is True
166+
167+
168+
def test_set_trace_provider_skips_default_bootstrap() -> None:
169+
payload = _run_python(
170+
"""
171+
import json
172+
173+
from agents.tracing import processors as tracing_processors
174+
from agents.tracing import setup as tracing_setup
175+
from agents.tracing.provider import DefaultTraceProvider
176+
177+
registrations = []
178+
179+
def fake_register(fn):
180+
registrations.append(fn)
181+
return fn
182+
183+
tracing_setup.atexit.register = fake_register
184+
tracing_setup.GLOBAL_TRACE_PROVIDER = None
185+
tracing_setup._SHUTDOWN_HANDLER_REGISTERED = False
186+
tracing_processors._global_exporter = None
187+
tracing_processors._global_processor = None
188+
189+
custom_provider = DefaultTraceProvider()
190+
tracing_setup.set_trace_provider(custom_provider)
191+
retrieved_provider = tracing_setup.get_trace_provider()
192+
193+
print(
194+
json.dumps(
195+
{
196+
"custom_provider_returned": retrieved_provider is custom_provider,
197+
"shutdown_registration_count": sum(
198+
1
199+
for fn in registrations
200+
if getattr(fn, "__name__", "") == "_shutdown_global_trace_provider"
201+
),
202+
"exporter_initialized": tracing_processors._global_exporter is not None,
203+
"processor_initialized": tracing_processors._global_processor is not None,
204+
}
205+
)
206+
)
207+
"""
208+
)
209+
210+
assert payload["custom_provider_returned"] is True
211+
assert payload["shutdown_registration_count"] == 1
212+
assert payload["exporter_initialized"] is False
213+
assert payload["processor_initialized"] is False

0 commit comments

Comments
 (0)