From a73d393368d61f963d2b93c68d6e2c3912c1e047 Mon Sep 17 00:00:00 2001 From: tsumon Date: Mon, 14 Sep 2026 17:24:44 +0800 Subject: [PATCH 1/7] fix(tracing): honour OPENAI_TRACING_INGEST_ENDPOINT and warn on OPENAI_BASE_URL mismatch --- src/agents/tracing/processors.py | 42 +++++++- tests/tracing/test_processor_endpoint.py | 122 +++++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/tracing/test_processor_endpoint.py diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index b61f3e7976..c65b3090b7 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -23,6 +23,9 @@ from .spans import Span from .traces import Trace +# Warn once per process when model traffic is redirected but traces still go to OpenAI. +_warned_default_trace_endpoint_with_custom_model_base = False + class ConsoleSpanExporter(TracingExporter): """Prints the traces and spans to the console.""" @@ -59,7 +62,7 @@ def __init__( api_key: str | None = None, organization: str | None = None, project: str | None = None, - endpoint: str = _OPENAI_TRACING_INGEST_ENDPOINT, + endpoint: str | None = None, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 30.0, @@ -72,7 +75,9 @@ def __init__( `os.environ["OPENAI_ORG_ID"]` if not provided. project: The OpenAI project to use. Defaults to `os.environ["OPENAI_PROJECT_ID"]` if not provided. - endpoint: The HTTP endpoint to which traces/spans are posted. + endpoint: The HTTP endpoint to which traces/spans are posted. Defaults to + `os.environ["OPENAI_TRACING_INGEST_ENDPOINT"]` if not provided, otherwise the + OpenAI traces ingest endpoint. This is independent of `OPENAI_BASE_URL`. max_retries: Maximum number of retries upon failures. base_delay: Base delay (in seconds) for the first backoff. max_delay: Maximum delay (in seconds) for backoff growth. @@ -80,7 +85,7 @@ def __init__( self._api_key = api_key self._organization = organization self._project = project - self.endpoint = endpoint + self._endpoint = endpoint self.max_retries = max_retries self.base_delay = base_delay self.max_delay = max_delay @@ -88,6 +93,7 @@ def __init__( # Keep a client open for connection pooling across multiple export calls self._client = httpx2.Client(timeout=httpx2.Timeout(timeout=60, connect=5.0)) + self._warn_if_trace_endpoint_ignores_model_base_url() def set_api_key(self, api_key: str): """Set the OpenAI API key for the exporter. @@ -115,6 +121,36 @@ def organization(self): def project(self): return self._project or os.environ.get("OPENAI_PROJECT_ID") + @cached_property + def endpoint(self) -> str: + return ( + self._endpoint + or os.environ.get("OPENAI_TRACING_INGEST_ENDPOINT") + or self._OPENAI_TRACING_INGEST_ENDPOINT + ) + + def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: + global _warned_default_trace_endpoint_with_custom_model_base + if _warned_default_trace_endpoint_with_custom_model_base: + return + if os.environ.get("OPENAI_AGENTS_DISABLE_TRACING", "false").lower() in ("true", "1"): + return + model_base = ( + os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE") or "" + ).strip() + if not model_base: + return + if not self._should_sanitize_for_openai_tracing_api(): + return + _warned_default_trace_endpoint_with_custom_model_base = True + logger.warning( + "[non-fatal] Tracing still exports to %s while model traffic uses %s. " + "Set OPENAI_TRACING_INGEST_ENDPOINT to redirect traces, or disable tracing with " + "OPENAI_AGENTS_DISABLE_TRACING=1.", + self.endpoint, + model_base, + ) + def export(self, items: list[Trace | Span[Any]]) -> None: self._export_with_deadline(items, deadline=None) diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py new file mode 100644 index 0000000000..518e8e8869 --- /dev/null +++ b/tests/tracing/test_processor_endpoint.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace +from typing import Any, cast + +import agents.tracing.processors as processors +from agents.tracing.processors import BackendSpanExporter +from agents.tracing.spans import Span +from agents.tracing.traces import Trace + +DEFAULT_ENDPOINT = BackendSpanExporter._OPENAI_TRACING_INGEST_ENDPOINT +CUSTOM_ENDPOINT = "https://traces.example.test/v1/traces/ingest" +MODEL_BASE = "https://gateway.example.test/v1" + + +def _reset_warning(monkeypatch) -> None: + monkeypatch.setattr(processors, "_warned_default_trace_endpoint_with_custom_model_base", False) + + +def test_endpoint_defaults_to_openai_ingest(monkeypatch): + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + _reset_warning(monkeypatch) + + exporter = BackendSpanExporter() + + assert exporter.endpoint == DEFAULT_ENDPOINT + + +def test_endpoint_from_env(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + _reset_warning(monkeypatch) + + exporter = BackendSpanExporter() + + assert exporter.endpoint == CUSTOM_ENDPOINT + + +def test_constructor_endpoint_wins_over_env(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + _reset_warning(monkeypatch) + + exporter = BackendSpanExporter(endpoint="https://explicit.example.test/ingest") + + assert exporter.endpoint == "https://explicit.example.test/ingest" + + +def test_export_posts_to_env_endpoint(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + _reset_warning(monkeypatch) + + class DummyItem: + tracing_api_key = None + + def export(self) -> dict[str, str]: + return {"id": "span-1"} + + calls: list[dict[str, Any]] = [] + + def fake_post(*, url, headers, json): + calls.append({"url": url, "headers": headers, "json": json}) + return SimpleNamespace(status_code=200, text="ok") + + exporter = BackendSpanExporter() + exporter.set_api_key("test-key") + monkeypatch.setattr(exporter, "_client", SimpleNamespace(post=fake_post)) + exporter.export(cast(list[Trace | Span[Any]], [DummyItem()])) + + assert len(calls) == 1 + assert calls[0]["url"] == CUSTOM_ENDPOINT + + +def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + monkeypatch.delenv("OPENAI_AGENTS_DISABLE_TRACING", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + BackendSpanExporter() + BackendSpanExporter() + + warnings = [ + record.message for record in caplog.records if "Tracing still exports" in record.message + ] + assert len(warnings) == 1 + assert DEFAULT_ENDPOINT in warnings[0] + assert MODEL_BASE in warnings[0] + assert "OPENAI_TRACING_INGEST_ENDPOINT" in warnings[0] + + +def test_no_warning_when_tracing_endpoint_is_custom(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_AGENTS_DISABLE_TRACING", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + BackendSpanExporter() + + assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + +def test_no_warning_when_tracing_is_disabled(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) + monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "1") + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + BackendSpanExporter() + + assert not [record for record in caplog.records if "Tracing still exports" in record.message] From ca34a12c2f92bfccbb00f40887fa03ca86d57185 Mon Sep 17 00:00:00 2001 From: tsumon Date: Mon, 14 Sep 2026 18:33:50 +0800 Subject: [PATCH 2/7] fix(tracing): address review on OPENAI_BASE_URL mismatch warning Warn on the first enabled export, not construction. Ignore OPENAI_API_BASE. Redact userinfo, query, and fragment from logged URLs. --- src/agents/tracing/processors.py | 34 +++++++--- tests/tracing/test_processor_endpoint.py | 80 +++++++++++++++++++++--- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index c65b3090b7..b0e73af187 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -10,6 +10,7 @@ from collections.abc import Callable from functools import cached_property from typing import Any, cast +from urllib.parse import urlsplit, urlunsplit import httpx2 @@ -27,6 +28,26 @@ _warned_default_trace_endpoint_with_custom_model_base = False +def _redact_url_for_log(url: str) -> str: + """Drop userinfo, query, and fragment so gateway credentials never reach logs.""" + try: + parts = urlsplit(url) + except ValueError: + return "" + + hostname = parts.hostname or "" + if ":" in hostname: + host = f"[{hostname}]" + else: + host = hostname + if parts.port is not None: + netloc = f"{host}:{parts.port}" + else: + netloc = host + redacted = urlunsplit((parts.scheme, netloc, parts.path, "", "")) + return redacted or "" + + class ConsoleSpanExporter(TracingExporter): """Prints the traces and spans to the console.""" @@ -93,7 +114,6 @@ def __init__( # Keep a client open for connection pooling across multiple export calls self._client = httpx2.Client(timeout=httpx2.Timeout(timeout=60, connect=5.0)) - self._warn_if_trace_endpoint_ignores_model_base_url() def set_api_key(self, api_key: str): """Set the OpenAI API key for the exporter. @@ -133,11 +153,7 @@ def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: global _warned_default_trace_endpoint_with_custom_model_base if _warned_default_trace_endpoint_with_custom_model_base: return - if os.environ.get("OPENAI_AGENTS_DISABLE_TRACING", "false").lower() in ("true", "1"): - return - model_base = ( - os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE") or "" - ).strip() + model_base = (os.environ.get("OPENAI_BASE_URL") or "").strip() if not model_base: return if not self._should_sanitize_for_openai_tracing_api(): @@ -147,8 +163,8 @@ def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: "[non-fatal] Tracing still exports to %s while model traffic uses %s. " "Set OPENAI_TRACING_INGEST_ENDPOINT to redirect traces, or disable tracing with " "OPENAI_AGENTS_DISABLE_TRACING=1.", - self.endpoint, - model_base, + _redact_url_for_log(self.endpoint), + _redact_url_for_log(model_base), ) def export(self, items: list[Trace | Span[Any]]) -> None: @@ -158,6 +174,8 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float if not items: return + self._warn_if_trace_endpoint_ignores_model_base_url() + grouped_items: dict[str | None, list[Trace | Span[Any]]] = {} for item in items: key = item.tracing_api_key diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py index 518e8e8869..e874c91a02 100644 --- a/tests/tracing/test_processor_endpoint.py +++ b/tests/tracing/test_processor_endpoint.py @@ -5,7 +5,7 @@ from typing import Any, cast import agents.tracing.processors as processors -from agents.tracing.processors import BackendSpanExporter +from agents.tracing.processors import BackendSpanExporter, _redact_url_for_log from agents.tracing.spans import Span from agents.tracing.traces import Trace @@ -18,10 +18,26 @@ def _reset_warning(monkeypatch) -> None: monkeypatch.setattr(processors, "_warned_default_trace_endpoint_with_custom_model_base", False) +def _export_once(monkeypatch, exporter: BackendSpanExporter | None = None) -> BackendSpanExporter: + class DummyItem: + tracing_api_key = None + + def export(self) -> dict[str, str]: + return {"id": "span-1"} + + def fake_post(*, url, headers, json): + return SimpleNamespace(status_code=200, text="ok") + + exporter = exporter or BackendSpanExporter() + exporter.set_api_key("test-key") + monkeypatch.setattr(exporter, "_client", SimpleNamespace(post=fake_post)) + exporter.export(cast(list[Trace | Span[Any]], [DummyItem()])) + return exporter + + def test_endpoint_defaults_to_openai_ingest(monkeypatch): monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) _reset_warning(monkeypatch) exporter = BackendSpanExporter() @@ -32,7 +48,6 @@ def test_endpoint_defaults_to_openai_ingest(monkeypatch): def test_endpoint_from_env(monkeypatch): monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) _reset_warning(monkeypatch) exporter = BackendSpanExporter() @@ -43,7 +58,6 @@ def test_endpoint_from_env(monkeypatch): def test_constructor_endpoint_wins_over_env(monkeypatch): monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) _reset_warning(monkeypatch) exporter = BackendSpanExporter(endpoint="https://explicit.example.test/ingest") @@ -54,7 +68,6 @@ def test_constructor_endpoint_wins_over_env(monkeypatch): def test_export_posts_to_env_endpoint(monkeypatch): monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) _reset_warning(monkeypatch) class DummyItem: @@ -78,17 +91,27 @@ def fake_post(*, url, headers, json): assert calls[0]["url"] == CUSTOM_ENDPOINT -def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): +def test_constructor_does_not_warn(monkeypatch, caplog): monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) - monkeypatch.delenv("OPENAI_API_BASE", raising=False) monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) - monkeypatch.delenv("OPENAI_AGENTS_DISABLE_TRACING", raising=False) _reset_warning(monkeypatch) with caplog.at_level(logging.WARNING, logger="openai.agents"): BackendSpanExporter() BackendSpanExporter() + assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + +def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + _export_once(monkeypatch) + warnings = [ record.message for record in caplog.records if "Tracing still exports" in record.message ] @@ -98,14 +121,44 @@ def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): assert "OPENAI_TRACING_INGEST_ENDPOINT" in warnings[0] +def test_no_warning_when_only_openai_api_base_is_set(monkeypatch, caplog): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_API_BASE", MODEL_BASE) + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + + assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + +def test_warning_redacts_credentials_in_logged_urls(monkeypatch, caplog): + secret_base = "https://user:s3cret@gateway.example.test/v1?token=signed" + monkeypatch.setenv("OPENAI_BASE_URL", secret_base) + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + + warnings = [ + record.message for record in caplog.records if "Tracing still exports" in record.message + ] + assert len(warnings) == 1 + assert "user:s3cret" not in warnings[0] + assert "token=signed" not in warnings[0] + assert "https://gateway.example.test/v1" in warnings[0] + assert _redact_url_for_log(secret_base) in warnings[0] + + def test_no_warning_when_tracing_endpoint_is_custom(monkeypatch, caplog): monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) - monkeypatch.delenv("OPENAI_AGENTS_DISABLE_TRACING", raising=False) _reset_warning(monkeypatch) with caplog.at_level(logging.WARNING, logger="openai.agents"): - BackendSpanExporter() + _export_once(monkeypatch) assert not [record for record in caplog.records if "Tracing still exports" in record.message] @@ -120,3 +173,10 @@ def test_no_warning_when_tracing_is_disabled(monkeypatch, caplog): BackendSpanExporter() assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + +def test_redact_url_for_log_strips_userinfo_query_and_fragment(): + assert ( + _redact_url_for_log("https://user:pass@api.example.test:8443/v1/traces?sig=abc#frag") + == "https://api.example.test:8443/v1/traces" + ) From 5c33235ace36b20e74af0cfed610cc5c07909ef0 Mon Sep 17 00:00:00 2001 From: tsumon Date: Mon, 14 Sep 2026 19:29:24 +0800 Subject: [PATCH 3/7] fix(tracing): warn on OPENAI_BASE_URL mismatch only after a usable key Skip the once-only warning when export is skipped for a missing API key, so a later set_tracing_export_api_key() can still surface the mismatch. --- src/agents/tracing/processors.py | 4 +-- tests/tracing/test_processor_endpoint.py | 34 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index b0e73af187..d2f87b5217 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -174,8 +174,6 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float if not items: return - self._warn_if_trace_endpoint_ignores_model_base_url() - grouped_items: dict[str | None, list[Trace | Span[Any]]] = {} for item in items: key = item.tracing_api_key @@ -187,6 +185,8 @@ def _export_with_deadline(self, items: list[Trace | Span[Any]], deadline: float logger.warning("OPENAI_API_KEY is not set, skipping trace export") continue + self._warn_if_trace_endpoint_ignores_model_base_url() + sanitize_for_openai = self._should_sanitize_for_openai_tracing_api() data: list[dict[str, Any]] = [] for item in grouped: diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py index e874c91a02..cb8ddde627 100644 --- a/tests/tracing/test_processor_endpoint.py +++ b/tests/tracing/test_processor_endpoint.py @@ -121,6 +121,40 @@ def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): assert "OPENAI_TRACING_INGEST_ENDPOINT" in warnings[0] +def test_no_warning_until_a_trace_can_be_sent(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + class DummyItem: + tracing_api_key = None + + def export(self) -> dict[str, str]: + return {"id": "span-1"} + + def fake_post(*, url, headers, json): + return SimpleNamespace(status_code=200, text="ok") + + exporter = BackendSpanExporter() + monkeypatch.setattr(exporter, "_client", SimpleNamespace(post=fake_post)) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + exporter.export(cast(list[Trace | Span[Any]], [DummyItem()])) + + assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + exporter.set_api_key("test-key") + with caplog.at_level(logging.WARNING, logger="openai.agents"): + exporter.export(cast(list[Trace | Span[Any]], [DummyItem()])) + + warnings = [ + record.message for record in caplog.records if "Tracing still exports" in record.message + ] + assert len(warnings) == 1 + assert MODEL_BASE in warnings[0] + + def test_no_warning_when_only_openai_api_base_is_set(monkeypatch, caplog): monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.setenv("OPENAI_API_BASE", MODEL_BASE) From 938fd7f3fe6a634007b8e8f6333471f0684cc03f Mon Sep 17 00:00:00 2001 From: tsumon Date: Mon, 14 Sep 2026 19:46:16 +0800 Subject: [PATCH 4/7] fix(tracing): skip OpenAI-origin mismatch warning and tolerate bad ports Do not warn when OPENAI_BASE_URL is api.openai.com. Invalid ports in that URL no longer raise during log redaction and cannot drop a trace export. --- src/agents/tracing/processors.py | 45 +++++++++++++++++++----- tests/tracing/test_processor_endpoint.py | 44 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index d2f87b5217..d19ea43b64 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -28,23 +28,45 @@ _warned_default_trace_endpoint_with_custom_model_base = False -def _redact_url_for_log(url: str) -> str: - """Drop userinfo, query, and fragment so gateway credentials never reach logs.""" +def _split_url(url: str) -> tuple[str, str, int | None, str] | None: try: parts = urlsplit(url) + hostname = parts.hostname or "" + try: + port = parts.port + except ValueError: + port = None + return parts.scheme, hostname, port, parts.path except ValueError: - return "" + return None + + +def _url_origin(url: str) -> str | None: + parsed = _split_url(url) + if parsed is None: + return None + scheme, hostname, port, _path = parsed + if not hostname: + return None + scheme = scheme.lower() or "https" + hostname = hostname.lower() + if port is None or (scheme == "https" and port == 443) or (scheme == "http" and port == 80): + return f"{scheme}://{hostname}" + return f"{scheme}://{hostname}:{port}" + - hostname = parts.hostname or "" +def _redact_url_for_log(url: str) -> str: + """Drop userinfo, query, and fragment so gateway credentials never reach logs.""" + parsed = _split_url(url) + if parsed is None: + return "" + scheme, hostname, port, path = parsed if ":" in hostname: host = f"[{hostname}]" else: host = hostname - if parts.port is not None: - netloc = f"{host}:{parts.port}" - else: - netloc = host - redacted = urlunsplit((parts.scheme, netloc, parts.path, "", "")) + netloc = f"{host}:{port}" if port is not None else host + redacted = urlunsplit((scheme, netloc, path, "", "")) return redacted or "" @@ -158,6 +180,11 @@ def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: return if not self._should_sanitize_for_openai_tracing_api(): return + model_origin = _url_origin(model_base) + if model_origin is not None and model_origin == _url_origin( + self._OPENAI_TRACING_INGEST_ENDPOINT + ): + return _warned_default_trace_endpoint_with_custom_model_base = True logger.warning( "[non-fatal] Tracing still exports to %s while model traffic uses %s. " diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py index cb8ddde627..f0b5c5f6c1 100644 --- a/tests/tracing/test_processor_endpoint.py +++ b/tests/tracing/test_processor_endpoint.py @@ -155,6 +155,44 @@ def fake_post(*, url, headers, json): assert MODEL_BASE in warnings[0] +def test_no_warning_when_openai_base_url_is_openai_origin(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + + assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + +def test_no_warning_when_openai_base_url_has_trailing_slash(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1/") + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + + assert not [record for record in caplog.records if "Tracing still exports" in record.message] + + +def test_export_survives_invalid_model_base_port(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", "https://gateway.example.test:99999/v1") + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + + warnings = [ + record.message for record in caplog.records if "Tracing still exports" in record.message + ] + assert len(warnings) == 1 + assert "99999" not in warnings[0] + assert "https://gateway.example.test/v1" in warnings[0] + + def test_no_warning_when_only_openai_api_base_is_set(monkeypatch, caplog): monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.setenv("OPENAI_API_BASE", MODEL_BASE) @@ -214,3 +252,9 @@ def test_redact_url_for_log_strips_userinfo_query_and_fragment(): _redact_url_for_log("https://user:pass@api.example.test:8443/v1/traces?sig=abc#frag") == "https://api.example.test:8443/v1/traces" ) + + +def test_redact_url_for_log_handles_invalid_port(): + assert _redact_url_for_log("https://gateway.example.test:99999/v1") == ( + "https://gateway.example.test/v1" + ) From 444ddffa8862d965fe880a287f3b10468a7e38d4 Mon Sep 17 00:00:00 2001 From: tsumon Date: Mon, 14 Sep 2026 20:21:57 +0800 Subject: [PATCH 5/7] fix(tracing): redact path in mismatch warning and normalize ingest URLs --- src/agents/tracing/processors.py | 47 +++++++++------ tests/tracing/test_processor_endpoint.py | 75 ++++++++++++++++++++---- 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index d19ea43b64..3dc104ef92 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -10,7 +10,7 @@ from collections.abc import Callable from functools import cached_property from typing import Any, cast -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import urlsplit import httpx2 @@ -41,33 +41,33 @@ def _split_url(url: str) -> tuple[str, str, int | None, str] | None: return None -def _url_origin(url: str) -> str | None: +def _url_origin_and_path(url: str) -> tuple[str, str] | None: parsed = _split_url(url) if parsed is None: return None - scheme, hostname, port, _path = parsed + scheme, hostname, port, path = parsed if not hostname: return None scheme = scheme.lower() or "https" hostname = hostname.lower() if port is None or (scheme == "https" and port == 443) or (scheme == "http" and port == 80): - return f"{scheme}://{hostname}" - return f"{scheme}://{hostname}:{port}" + origin = f"{scheme}://{hostname}" + else: + origin = f"{scheme}://{hostname}:{port}" + return origin, path.rstrip("/") + + +def _url_origin(url: str) -> str | None: + parsed = _url_origin_and_path(url) + return None if parsed is None else parsed[0] def _redact_url_for_log(url: str) -> str: - """Drop userinfo, query, and fragment so gateway credentials never reach logs.""" - parsed = _split_url(url) - if parsed is None: + """Drop userinfo, path, query, and fragment so gateway credentials never reach logs.""" + origin = _url_origin(url) + if origin is None: return "" - scheme, hostname, port, path = parsed - if ":" in hostname: - host = f"[{hostname}]" - else: - host = hostname - netloc = f"{host}:{port}" if port is not None else host - redacted = urlunsplit((scheme, netloc, path, "", "")) - return redacted or "" + return origin class ConsoleSpanExporter(TracingExporter): @@ -186,12 +186,19 @@ def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: ): return _warned_default_trace_endpoint_with_custom_model_base = True + if self._endpoint is not None: + redirect_hint = ( + "Pass a different endpoint= to BackendSpanExporter, or omit that argument so " + "OPENAI_TRACING_INGEST_ENDPOINT can redirect traces" + ) + else: + redirect_hint = "Set OPENAI_TRACING_INGEST_ENDPOINT to redirect traces" logger.warning( "[non-fatal] Tracing still exports to %s while model traffic uses %s. " - "Set OPENAI_TRACING_INGEST_ENDPOINT to redirect traces, or disable tracing with " - "OPENAI_AGENTS_DISABLE_TRACING=1.", + "%s, or disable tracing with OPENAI_AGENTS_DISABLE_TRACING=1.", _redact_url_for_log(self.endpoint), _redact_url_for_log(model_base), + redirect_hint, ) def export(self, items: list[Trace | Span[Any]]) -> None: @@ -337,7 +344,9 @@ def _sleep_before_retry(self, sleep_time: float, deadline: float | None) -> bool return True def _should_sanitize_for_openai_tracing_api(self) -> bool: - return self.endpoint.rstrip("/") == self._OPENAI_TRACING_INGEST_ENDPOINT.rstrip("/") + endpoint = _url_origin_and_path(self.endpoint) + default = _url_origin_and_path(self._OPENAI_TRACING_INGEST_ENDPOINT) + return endpoint is not None and endpoint == default def _sanitize_for_openai_tracing_api(self, payload_item: dict[str, Any]) -> dict[str, Any]: """Drop or truncate span fields known to be rejected by traces ingest.""" diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py index f0b5c5f6c1..bc509c9857 100644 --- a/tests/tracing/test_processor_endpoint.py +++ b/tests/tracing/test_processor_endpoint.py @@ -12,6 +12,8 @@ DEFAULT_ENDPOINT = BackendSpanExporter._OPENAI_TRACING_INGEST_ENDPOINT CUSTOM_ENDPOINT = "https://traces.example.test/v1/traces/ingest" MODEL_BASE = "https://gateway.example.test/v1" +MODEL_ORIGIN = "https://gateway.example.test" +DEFAULT_ORIGIN = "https://api.openai.com" def _reset_warning(monkeypatch) -> None: @@ -116,8 +118,9 @@ def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): record.message for record in caplog.records if "Tracing still exports" in record.message ] assert len(warnings) == 1 - assert DEFAULT_ENDPOINT in warnings[0] - assert MODEL_BASE in warnings[0] + assert DEFAULT_ORIGIN in warnings[0] + assert MODEL_ORIGIN in warnings[0] + assert "/v1" not in warnings[0] assert "OPENAI_TRACING_INGEST_ENDPOINT" in warnings[0] @@ -152,7 +155,7 @@ def fake_post(*, url, headers, json): record.message for record in caplog.records if "Tracing still exports" in record.message ] assert len(warnings) == 1 - assert MODEL_BASE in warnings[0] + assert MODEL_ORIGIN in warnings[0] def test_no_warning_when_openai_base_url_is_openai_origin(monkeypatch, caplog): @@ -190,7 +193,8 @@ def test_export_survives_invalid_model_base_port(monkeypatch, caplog): ] assert len(warnings) == 1 assert "99999" not in warnings[0] - assert "https://gateway.example.test/v1" in warnings[0] + assert MODEL_ORIGIN in warnings[0] + assert "/v1" not in warnings[0] def test_no_warning_when_only_openai_api_base_is_set(monkeypatch, caplog): @@ -220,7 +224,8 @@ def test_warning_redacts_credentials_in_logged_urls(monkeypatch, caplog): assert len(warnings) == 1 assert "user:s3cret" not in warnings[0] assert "token=signed" not in warnings[0] - assert "https://gateway.example.test/v1" in warnings[0] + assert MODEL_ORIGIN in warnings[0] + assert "/v1" not in warnings[0] assert _redact_url_for_log(secret_base) in warnings[0] @@ -247,14 +252,64 @@ def test_no_warning_when_tracing_is_disabled(monkeypatch, caplog): assert not [record for record in caplog.records if "Tracing still exports" in record.message] -def test_redact_url_for_log_strips_userinfo_query_and_fragment(): +def test_warning_redacts_credential_bearing_paths(monkeypatch, caplog): + secret_base = "https://gateway.example.test/v1/tenants/tok_secret" + monkeypatch.setenv("OPENAI_BASE_URL", secret_base) + monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch) + + warnings = [ + record.message for record in caplog.records if "Tracing still exports" in record.message + ] + assert len(warnings) == 1 + assert "tok_secret" not in warnings[0] + assert "/tenants/" not in warnings[0] + assert MODEL_ORIGIN in warnings[0] + + +def test_equivalent_openai_ingest_urls_still_sanitize(monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + for equivalent in ( + "https://api.openai.com:443/v1/traces/ingest", + "https://API.OPENAI.COM/v1/traces/ingest", + "https://api.openai.com/v1/traces/ingest/", + ): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", equivalent) + exporter = BackendSpanExporter() + assert exporter._should_sanitize_for_openai_tracing_api() is True + + +def test_custom_ingest_endpoint_does_not_sanitize(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + exporter = BackendSpanExporter() + assert exporter._should_sanitize_for_openai_tracing_api() is False + + +def test_warning_for_explicit_endpoint_points_at_constructor(monkeypatch, caplog): + monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + _reset_warning(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="openai.agents"): + _export_once(monkeypatch, BackendSpanExporter(endpoint=DEFAULT_ENDPOINT)) + + warnings = [ + record.message for record in caplog.records if "Tracing still exports" in record.message + ] + assert len(warnings) == 1 + assert "endpoint=" in warnings[0] + assert "omit that argument" in warnings[0] + + +def test_redact_url_for_log_strips_userinfo_path_query_and_fragment(): assert ( _redact_url_for_log("https://user:pass@api.example.test:8443/v1/traces?sig=abc#frag") - == "https://api.example.test:8443/v1/traces" + == "https://api.example.test:8443" ) def test_redact_url_for_log_handles_invalid_port(): - assert _redact_url_for_log("https://gateway.example.test:99999/v1") == ( - "https://gateway.example.test/v1" - ) + assert _redact_url_for_log("https://gateway.example.test:99999/v1") == MODEL_ORIGIN From 9e71d7f7914d376ec87252c1b5bf7ccd7cdb4af5 Mon Sep 17 00:00:00 2001 From: tsumon Date: Mon, 14 Sep 2026 21:11:46 +0800 Subject: [PATCH 6/7] fix(tracing): keep BackendSpanExporter.endpoint assignable after construction Restore the released public attribute so exporter.endpoint = url updates the configured ingest target and invalidates the resolved value. --- src/agents/tracing/processors.py | 22 ++++++--- tests/tracing/test_processor_endpoint.py | 60 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 3dc104ef92..0aec1f36c8 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -163,13 +163,23 @@ def organization(self): def project(self): return self._project or os.environ.get("OPENAI_PROJECT_ID") - @cached_property + def _invalidate_endpoint(self) -> None: + self.__dict__.pop("_resolved_endpoint", None) + + @property def endpoint(self) -> str: - return ( - self._endpoint - or os.environ.get("OPENAI_TRACING_INGEST_ENDPOINT") - or self._OPENAI_TRACING_INGEST_ENDPOINT - ) + if "_resolved_endpoint" not in self.__dict__: + self.__dict__["_resolved_endpoint"] = ( + self._endpoint + or os.environ.get("OPENAI_TRACING_INGEST_ENDPOINT") + or self._OPENAI_TRACING_INGEST_ENDPOINT + ) + return self.__dict__["_resolved_endpoint"] + + @endpoint.setter + def endpoint(self, value: str) -> None: + self._endpoint = value + self._invalidate_endpoint() def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: global _warned_default_trace_endpoint_with_custom_model_base diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py index bc509c9857..56fbcb2f29 100644 --- a/tests/tracing/test_processor_endpoint.py +++ b/tests/tracing/test_processor_endpoint.py @@ -67,6 +67,36 @@ def test_constructor_endpoint_wins_over_env(monkeypatch): assert exporter.endpoint == "https://explicit.example.test/ingest" +def test_post_construction_endpoint_assignment_before_first_read(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + _reset_warning(monkeypatch) + + assigned = "https://assigned.example.test/ingest" + exporter = BackendSpanExporter() + exporter.endpoint = assigned + + assert exporter.endpoint == assigned + assert exporter._endpoint == assigned + assert exporter._should_sanitize_for_openai_tracing_api() is False + + +def test_post_construction_endpoint_assignment_invalidates_cache(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + _reset_warning(monkeypatch) + + assigned = "https://assigned.example.test/ingest" + exporter = BackendSpanExporter() + assert exporter.endpoint == CUSTOM_ENDPOINT + + exporter.endpoint = assigned + + assert exporter.endpoint == assigned + assert exporter._endpoint == assigned + assert exporter._should_sanitize_for_openai_tracing_api() is False + + def test_export_posts_to_env_endpoint(monkeypatch): monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) @@ -93,6 +123,36 @@ def fake_post(*, url, headers, json): assert calls[0]["url"] == CUSTOM_ENDPOINT +def test_export_posts_to_assigned_endpoint(monkeypatch): + monkeypatch.setenv("OPENAI_TRACING_INGEST_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + _reset_warning(monkeypatch) + + assigned = "https://assigned.example.test/ingest" + + class DummyItem: + tracing_api_key = None + + def export(self) -> dict[str, str]: + return {"id": "span-1"} + + calls: list[dict[str, Any]] = [] + + def fake_post(*, url, headers, json): + calls.append({"url": url, "headers": headers, "json": json}) + return SimpleNamespace(status_code=200, text="ok") + + exporter = BackendSpanExporter() + assert exporter.endpoint == CUSTOM_ENDPOINT + exporter.endpoint = assigned + exporter.set_api_key("test-key") + monkeypatch.setattr(exporter, "_client", SimpleNamespace(post=fake_post)) + exporter.export(cast(list[Trace | Span[Any]], [DummyItem()])) + + assert len(calls) == 1 + assert calls[0]["url"] == assigned + + def test_constructor_does_not_warn(monkeypatch, caplog): monkeypatch.setenv("OPENAI_BASE_URL", MODEL_BASE) monkeypatch.delenv("OPENAI_TRACING_INGEST_ENDPOINT", raising=False) From f730292c87997af9b5352ef6f03a07da78b60add Mon Sep 17 00:00:00 2001 From: tsumon Date: Tue, 15 Sep 2026 08:30:00 +0800 Subject: [PATCH 7/7] fix(tracing): phrase mismatch warning as an OPENAI_BASE_URL notice The exporter cannot see OpenAIProvider(base_url=...), so do not claim model traffic uses the environment value. --- src/agents/tracing/processors.py | 4 ++-- tests/tracing/test_processor_endpoint.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 0aec1f36c8..89ee54a533 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -24,7 +24,7 @@ from .spans import Span from .traces import Trace -# Warn once per process when model traffic is redirected but traces still go to OpenAI. +# Warn once per process when OPENAI_BASE_URL is set but traces still go to OpenAI. _warned_default_trace_endpoint_with_custom_model_base = False @@ -204,7 +204,7 @@ def _warn_if_trace_endpoint_ignores_model_base_url(self) -> None: else: redirect_hint = "Set OPENAI_TRACING_INGEST_ENDPOINT to redirect traces" logger.warning( - "[non-fatal] Tracing still exports to %s while model traffic uses %s. " + "[non-fatal] Tracing still exports to %s while OPENAI_BASE_URL is %s. " "%s, or disable tracing with OPENAI_AGENTS_DISABLE_TRACING=1.", _redact_url_for_log(self.endpoint), _redact_url_for_log(model_base), diff --git a/tests/tracing/test_processor_endpoint.py b/tests/tracing/test_processor_endpoint.py index 56fbcb2f29..f34a2c3943 100644 --- a/tests/tracing/test_processor_endpoint.py +++ b/tests/tracing/test_processor_endpoint.py @@ -181,6 +181,8 @@ def test_warns_once_when_model_base_url_diverges(monkeypatch, caplog): assert DEFAULT_ORIGIN in warnings[0] assert MODEL_ORIGIN in warnings[0] assert "/v1" not in warnings[0] + assert "OPENAI_BASE_URL" in warnings[0] + assert "model traffic uses" not in warnings[0] assert "OPENAI_TRACING_INGEST_ENDPOINT" in warnings[0]