diff --git a/.changelog/4954.fixed b/.changelog/4954.fixed new file mode 100644 index 0000000000..724f7e96d5 --- /dev/null +++ b/.changelog/4954.fixed @@ -0,0 +1 @@ +`opentelemetry-instrumentation-openai-v2`: fix `server.address` on openai 3.x diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py index 0bbaf39ccc..7ca43d0649 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse import openai -from httpx import URL from openai import NotGiven from opentelemetry._logs import LogRecord @@ -91,13 +90,14 @@ def get_server_address_and_port( base_url = getattr(base_client, "base_url", None) if not base_url: return None, None - address = None - port = None - if isinstance(base_url, URL): - address = base_url.host - port = base_url.port - elif isinstance(base_url, str): - url = urlparse(base_url) + + # Read the URL structurally rather than by type. openai 3.x builds on httpx2, so base_url is an + # httpx2.URL rather than an httpx.URL, and an isinstance check against either one silently stops + # matching when the SDK changes its HTTP client. + address = getattr(base_url, "host", None) + port = getattr(base_url, "port", None) + if address is None: + url = urlparse(str(base_url)) address = url.hostname port = url.port diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_server_address.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_server_address.py new file mode 100644 index 0000000000..7b1a9c9902 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_server_address.py @@ -0,0 +1,96 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for endpoint extraction in `get_server_address_and_port`. + +The extraction must not depend on which HTTP client library the OpenAI SDK happens to use internally. +openai 3.x builds on httpx2, so `client._client.base_url` is an `httpx2.URL`; an isinstance check against +`httpx.URL` silently stops matching and `server.address` disappears from every span. +""" + +import subprocess +import sys +import textwrap +from importlib.metadata import version + +import pytest + +from opentelemetry.instrumentation.openai_v2.utils import ( + get_server_address_and_port, +) + + +class _Client: + """Stands in for the resource object the instrumentation receives (e.g. `client.chat.completions`), + whose `_client` is the SDK client holding `base_url`.""" + + def __init__(self, base_url): + self._client = type("Inner", (), {"base_url": base_url})() + + +def test_httpx_url(): + httpx = pytest.importorskip("httpx") + address, port = get_server_address_and_port(_Client(httpx.URL("http://localhost:8080/v1"))) + assert address == "localhost" + assert port == 8080 + + +def test_httpx2_url(): + httpx2 = pytest.importorskip("httpx2") + address, port = get_server_address_and_port(_Client(httpx2.URL("http://localhost:8080/v1"))) + assert address == "localhost" + assert port == 8080 + + +def test_string_base_url(): + address, port = get_server_address_and_port(_Client("http://localhost:8080/v1")) + assert address == "localhost" + assert port == 8080 + + +def test_default_https_port_is_omitted(): + address, port = get_server_address_and_port(_Client("https://api.openai.com:443/v1")) + assert address == "api.openai.com" + assert port is None + + +def test_missing_base_url(): + assert get_server_address_and_port(_Client(None)) == (None, None) + assert get_server_address_and_port(object()) == (None, None) + + +def test_importable_without_httpx(): + """The module must import when httpx is not installed. + + This package declares no dependency on httpx, and openai 3.x depends on httpx2 instead, so httpx can + legitimately be absent. A module-scope `from httpx import URL` then makes the instrumentation + unimportable rather than merely degraded. + + Skipped on openai 2.x, which imports httpx itself, so the situation cannot arise there. + """ + if int(version("openai").split(".")[0]) < 3: + pytest.skip("openai 2.x depends on httpx itself, so httpx is never absent") + script = textwrap.dedent( + """ + import sys + + class Blocker: + def find_spec(self, name, path=None, target=None): + if name == "httpx" or name.startswith("httpx."): + raise ModuleNotFoundError("No module named 'httpx'") + return None + + sys.meta_path.insert(0, Blocker()) + for module in [m for m in sys.modules if m == "httpx" or m.startswith("httpx.")]: + del sys.modules[module] + + from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor # noqa: F401 + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr