Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions src/agents/tracing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import atexit

from .config import TracingConfig
from .context import TraceCtxManager
from .create import (
Expand All @@ -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,
Expand Down Expand Up @@ -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)
41 changes: 36 additions & 5 deletions src/agents/tracing/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 44 additions & 5 deletions src/agents/tracing/setup.py
Original file line number Diff line number Diff line change
@@ -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
213 changes: 213 additions & 0 deletions tests/tracing/test_import_side_effects.py
Original file line number Diff line number Diff line change
@@ -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