From efa9bb2afe735fb7d897af5524f3890465b14e2d Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 17 Feb 2026 09:55:00 +0900 Subject: [PATCH] fix: lazily initialize tracing globals to avoid import-time fork hazards --- src/agents/tracing/__init__.py | 16 +- src/agents/tracing/processors.py | 41 ++++- src/agents/tracing/setup.py | 49 ++++- tests/tracing/test_import_side_effects.py | 213 ++++++++++++++++++++++ 4 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 tests/tracing/test_import_side_effects.py diff --git a/src/agents/tracing/__init__.py b/src/agents/tracing/__init__.py index 71204da04e..9f5e4f7568 100644 --- a/src/agents/tracing/__init__.py +++ b/src/agents/tracing/__init__.py @@ -1,5 +1,3 @@ -import atexit - from .config import TracingConfig from .context import TraceCtxManager from .create import ( @@ -19,8 +17,8 @@ transcription_span, ) from .processor_interface import TracingProcessor -from .processors import default_exporter, default_processor -from .provider import DefaultTraceProvider, TraceProvider +from .processors import default_exporter +from .provider import TraceProvider from .setup import get_trace_provider, set_trace_provider from .span_data import ( AgentSpanData, @@ -110,13 +108,3 @@ def set_tracing_export_api_key(api_key: str) -> None: Set the OpenAI API key for the backend exporter. """ default_exporter().set_api_key(api_key) - - -set_trace_provider(DefaultTraceProvider()) -# Add the default processor, which exports traces and spans to the backend in batches. You can -# change the default behavior by either: -# 1. calling add_trace_processor(), which adds additional processors, or -# 2. calling set_trace_processors(), which replaces the default processor. -add_trace_processor(default_processor()) - -atexit.register(get_trace_provider().shutdown) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 0b0bffa5ba..46d38174d0 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -354,16 +354,47 @@ def _export_batches(self, force: bool = False): self._exporter.export(items_to_export) -# Create a shared global instance: -_global_exporter = BackendSpanExporter() -_global_processor = BatchTraceProcessor(_global_exporter) +# Lazily initialized defaults to avoid creating network clients or threading +# primitives during module import (important for fork-based process models). +_global_exporter: BackendSpanExporter | None = None +_global_processor: BatchTraceProcessor | None = None +_global_lock = threading.Lock() def default_exporter() -> BackendSpanExporter: """The default exporter, which exports traces and spans to the backend in batches.""" - return _global_exporter + global _global_exporter + + exporter = _global_exporter + if exporter is not None: + return exporter + + with _global_lock: + exporter = _global_exporter + if exporter is None: + exporter = BackendSpanExporter() + _global_exporter = exporter + + return exporter def default_processor() -> BatchTraceProcessor: """The default processor, which exports traces and spans to the backend in batches.""" - return _global_processor + global _global_exporter + global _global_processor + + processor = _global_processor + if processor is not None: + return processor + + with _global_lock: + processor = _global_processor + if processor is None: + exporter = _global_exporter + if exporter is None: + exporter = BackendSpanExporter() + _global_exporter = exporter + processor = BatchTraceProcessor(exporter) + _global_processor = processor + + return processor diff --git a/src/agents/tracing/setup.py b/src/agents/tracing/setup.py index 3a56b728f1..1fb9a1582c 100644 --- a/src/agents/tracing/setup.py +++ b/src/agents/tracing/setup.py @@ -1,21 +1,60 @@ from __future__ import annotations +import atexit +import threading from typing import TYPE_CHECKING if TYPE_CHECKING: from .provider import TraceProvider GLOBAL_TRACE_PROVIDER: TraceProvider | None = None +_GLOBAL_TRACE_PROVIDER_LOCK = threading.Lock() +_SHUTDOWN_HANDLER_REGISTERED = False + + +def _shutdown_global_trace_provider() -> None: + provider = GLOBAL_TRACE_PROVIDER + if provider is not None: + provider.shutdown() def set_trace_provider(provider: TraceProvider) -> None: """Set the global trace provider used by tracing utilities.""" global GLOBAL_TRACE_PROVIDER - GLOBAL_TRACE_PROVIDER = provider + global _SHUTDOWN_HANDLER_REGISTERED + + with _GLOBAL_TRACE_PROVIDER_LOCK: + GLOBAL_TRACE_PROVIDER = provider + if not _SHUTDOWN_HANDLER_REGISTERED: + atexit.register(_shutdown_global_trace_provider) + _SHUTDOWN_HANDLER_REGISTERED = True def get_trace_provider() -> TraceProvider: - """Get the global trace provider used by tracing utilities.""" - if GLOBAL_TRACE_PROVIDER is None: - raise RuntimeError("Trace provider not set") - return GLOBAL_TRACE_PROVIDER + """Get the global trace provider used by tracing utilities. + + The default provider and processor are initialized lazily on first access so + importing the SDK does not create network clients or threading primitives. + """ + global GLOBAL_TRACE_PROVIDER + global _SHUTDOWN_HANDLER_REGISTERED + + provider = GLOBAL_TRACE_PROVIDER + if provider is not None: + return provider + + with _GLOBAL_TRACE_PROVIDER_LOCK: + provider = GLOBAL_TRACE_PROVIDER + if provider is None: + from .processors import default_processor + from .provider import DefaultTraceProvider + + provider = DefaultTraceProvider() + provider.register_processor(default_processor()) + GLOBAL_TRACE_PROVIDER = provider + + if not _SHUTDOWN_HANDLER_REGISTERED: + atexit.register(_shutdown_global_trace_provider) + _SHUTDOWN_HANDLER_REGISTERED = True + + return provider diff --git a/tests/tracing/test_import_side_effects.py b/tests/tracing/test_import_side_effects.py new file mode 100644 index 0000000000..2ee2a8c002 --- /dev/null +++ b/tests/tracing/test_import_side_effects.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import cast + +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = REPO_ROOT / "src" + + +def _run_python(script: str) -> dict[str, object]: + env = os.environ.copy() + pythonpath = env.get("PYTHONPATH") + if pythonpath: + env["PYTHONPATH"] = f"{SRC_ROOT}:{pythonpath}" + else: + env["PYTHONPATH"] = str(SRC_ROOT) + + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=True, + ) + payload = json.loads(completed.stdout) + if not isinstance(payload, dict): + raise AssertionError("Subprocess payload must be a JSON object.") + return cast(dict[str, object], payload) + + +def test_import_agents_has_no_tracing_side_effects() -> None: + payload = _run_python( + """ +import gc +import json +import httpx + +clients_before = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client)) +import agents # noqa: F401 +from agents.tracing import processors as tracing_processors +from agents.tracing import setup as tracing_setup +clients_after = sum(1 for obj in gc.get_objects() if isinstance(obj, httpx.Client)) + +print( + json.dumps( + { + "client_delta": clients_after - clients_before, + "provider_initialized": tracing_setup.GLOBAL_TRACE_PROVIDER is not None, + "exporter_initialized": tracing_processors._global_exporter is not None, + "processor_initialized": tracing_processors._global_processor is not None, + "shutdown_handler_registered": tracing_setup._SHUTDOWN_HANDLER_REGISTERED, + } + ) +) +""" + ) + + assert payload["client_delta"] == 0 + assert payload["provider_initialized"] is False + assert payload["exporter_initialized"] is False + assert payload["processor_initialized"] is False + assert payload["shutdown_handler_registered"] is False + + +def test_get_trace_provider_lazily_initializes_defaults() -> None: + payload = _run_python( + """ +import json + +from agents.tracing import setup as tracing_setup +from agents.tracing import processors as tracing_processors + +provider_before = tracing_setup.GLOBAL_TRACE_PROVIDER +exporter_before = tracing_processors._global_exporter +processor_before = tracing_processors._global_processor +shutdown_before = tracing_setup._SHUTDOWN_HANDLER_REGISTERED + +provider = tracing_setup.get_trace_provider() + +provider_after = tracing_setup.GLOBAL_TRACE_PROVIDER +exporter_after = tracing_processors._global_exporter +processor_after = tracing_processors._global_processor +shutdown_after = tracing_setup._SHUTDOWN_HANDLER_REGISTERED + +print( + json.dumps( + { + "provider_before": provider_before is not None, + "exporter_before": exporter_before is not None, + "processor_before": processor_before is not None, + "shutdown_before": shutdown_before, + "provider_after": provider_after is not None, + "exporter_after": exporter_after is not None, + "processor_after": processor_after is not None, + "shutdown_after": shutdown_after, + "provider_matches_global": provider_after is provider, + } + ) +) +""" + ) + + assert payload["provider_before"] is False + assert payload["exporter_before"] is False + assert payload["processor_before"] is False + assert payload["shutdown_before"] is False + + assert payload["provider_after"] is True + assert payload["exporter_after"] is True + assert payload["processor_after"] is True + assert payload["shutdown_after"] is True + assert payload["provider_matches_global"] is True + + +def test_get_trace_provider_bootstraps_once() -> None: + payload = _run_python( + """ +import json + +from agents.tracing import processors as tracing_processors +from agents.tracing import setup as tracing_setup + +registrations = [] + +def fake_register(fn): + registrations.append(fn) + return fn + +tracing_setup.atexit.register = fake_register +tracing_setup.GLOBAL_TRACE_PROVIDER = None +tracing_setup._SHUTDOWN_HANDLER_REGISTERED = False +tracing_processors._global_exporter = None +tracing_processors._global_processor = None + +first = tracing_setup.get_trace_provider() +second = tracing_setup.get_trace_provider() + +print( + json.dumps( + { + "same_provider": first is second, + "shutdown_registration_count": sum( + 1 + for fn in registrations + if getattr(fn, "__name__", "") == "_shutdown_global_trace_provider" + ), + "provider_initialized": tracing_setup.GLOBAL_TRACE_PROVIDER is not None, + "exporter_initialized": tracing_processors._global_exporter is not None, + "processor_initialized": tracing_processors._global_processor is not None, + } + ) +) +""" + ) + + assert payload["same_provider"] is True + assert payload["shutdown_registration_count"] == 1 + assert payload["provider_initialized"] is True + assert payload["exporter_initialized"] is True + assert payload["processor_initialized"] is True + + +def test_set_trace_provider_skips_default_bootstrap() -> None: + payload = _run_python( + """ +import json + +from agents.tracing import processors as tracing_processors +from agents.tracing import setup as tracing_setup +from agents.tracing.provider import DefaultTraceProvider + +registrations = [] + +def fake_register(fn): + registrations.append(fn) + return fn + +tracing_setup.atexit.register = fake_register +tracing_setup.GLOBAL_TRACE_PROVIDER = None +tracing_setup._SHUTDOWN_HANDLER_REGISTERED = False +tracing_processors._global_exporter = None +tracing_processors._global_processor = None + +custom_provider = DefaultTraceProvider() +tracing_setup.set_trace_provider(custom_provider) +retrieved_provider = tracing_setup.get_trace_provider() + +print( + json.dumps( + { + "custom_provider_returned": retrieved_provider is custom_provider, + "shutdown_registration_count": sum( + 1 + for fn in registrations + if getattr(fn, "__name__", "") == "_shutdown_global_trace_provider" + ), + "exporter_initialized": tracing_processors._global_exporter is not None, + "processor_initialized": tracing_processors._global_processor is not None, + } + ) +) +""" + ) + + assert payload["custom_provider_returned"] is True + assert payload["shutdown_registration_count"] == 1 + assert payload["exporter_initialized"] is False + assert payload["processor_initialized"] is False