From 917cb72fe8e44a8b281382f408a07e7ac97cb367 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 3 Jun 2026 16:35:13 +0200 Subject: [PATCH] httpx: fix httpcore instrumentation handling of default port Fix both sync and async instrumentation handling of the default port. If the port is set by the client it would be None so we should treat it as the default one. The async instrumentation also contained a typo when building the url. This has gone unnoticed since the tests using httpx will create DroppedSpans for httpcore spans. --- .../packages/httpx/async/httpcore.py | 4 +-- .../packages/httpx/sync/httpcore.py | 2 +- .../asyncio_tests/httpx_tests.py | 28 +++++++++++++++++++ tests/instrumentation/httpx_tests.py | 26 +++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/elasticapm/instrumentation/packages/httpx/async/httpcore.py b/elasticapm/instrumentation/packages/httpx/async/httpcore.py index 061f2d87e..c7e39dbee 100644 --- a/elasticapm/instrumentation/packages/httpx/async/httpcore.py +++ b/elasticapm/instrumentation/packages/httpx/async/httpcore.py @@ -57,12 +57,12 @@ async def call(self, module, method, wrapped, instance, args, kwargs): url, method, headers = utils.get_request_data(args, kwargs) scheme, host, port, target = url - if port != default_ports.get(scheme): + if port is not None and port != default_ports.get(scheme): host += ":" + str(port) signature = "%s %s" % (method.upper(), host) - url = "%s://%s%s" % (scheme, host, url) + url = "%s://%s%s" % (scheme, host, target) transaction = execution_context.get_transaction() diff --git a/elasticapm/instrumentation/packages/httpx/sync/httpcore.py b/elasticapm/instrumentation/packages/httpx/sync/httpcore.py index 6530f703e..d03ebef2d 100644 --- a/elasticapm/instrumentation/packages/httpx/sync/httpcore.py +++ b/elasticapm/instrumentation/packages/httpx/sync/httpcore.py @@ -47,7 +47,7 @@ class HTTPCoreInstrumentation(AbstractInstrumentedModule): def call(self, module, method, wrapped, instance, args, kwargs): url, method, headers = utils.get_request_data(args, kwargs) scheme, host, port, target = url - if port != default_ports.get(scheme): + if port is not None and port != default_ports.get(scheme): host += ":" + str(port) signature = "%s %s" % (method.upper(), host) diff --git a/tests/instrumentation/asyncio_tests/httpx_tests.py b/tests/instrumentation/asyncio_tests/httpx_tests.py index 2ab017c71..01f05e938 100644 --- a/tests/instrumentation/asyncio_tests/httpx_tests.py +++ b/tests/instrumentation/asyncio_tests/httpx_tests.py @@ -31,6 +31,8 @@ import pytest # isort:skip httpx = pytest.importorskip("httpx") # isort:skip +httpcore = pytest.importorskip("httpcore") # isort:skip + import urllib.parse from elasticapm.conf import constants @@ -40,6 +42,7 @@ pytestmark = [pytest.mark.httpx, pytest.mark.asyncio] +httpcore_version = tuple(map(int, httpcore.__version__.split(".")[:3])) httpx_version = tuple(map(int, httpx.__version__.split(".")[:3])) if httpx_version < (0, 20): @@ -194,3 +197,28 @@ async def test_httpx_streaming(instrument, elasticapm_client, waiting_httpserver span = elasticapm_client.spans_for_transaction(transactions[0])[0] assert span["type"] == "external" assert span["subtype"] == "http" + + +@pytest.mark.skipif(httpcore_version < (0, 17, 3), reason="AsyncMockBackend not available on older versions") +async def test_default_port_handling(instrument, elasticapm_client): + url = "https://example.com/" + + elasticapm_client.begin_transaction("transaction") + network_backend = httpcore.AsyncMockBackend( + buffer=[ + b"HTTP/1.1 200 OK\r\n", + b"Content-Type: plain/text\r\n", + b"Content-Length: 13\r\n", + b"\r\n", + b"Hello, world!", + ] + ) + async with httpcore.AsyncConnectionPool(network_backend=network_backend) as pool: + await pool.request("GET", url) + elasticapm_client.end_transaction("transaction") + + transactions = elasticapm_client.events[TRANSACTION] + span = elasticapm_client.spans_for_transaction(transactions[0])[0] + + assert span["name"] == "GET example.com" + assert span["context"]["http"]["url"] == url diff --git a/tests/instrumentation/httpx_tests.py b/tests/instrumentation/httpx_tests.py index 3c1b8bf15..283ee2e44 100644 --- a/tests/instrumentation/httpx_tests.py +++ b/tests/instrumentation/httpx_tests.py @@ -31,6 +31,8 @@ import pytest # isort:skip httpx = pytest.importorskip("httpx") # isort:skip +httpcore = pytest.importorskip("httpcore") # isort:skip + import urllib.parse from elasticapm.conf import constants @@ -40,6 +42,7 @@ pytestmark = pytest.mark.httpx +httpcore_version = tuple(map(int, httpcore.__version__.split(".")[:3])) httpx_version = tuple(map(int, httpx.__version__.split(".")[:3])) if httpx_version < (0, 20): @@ -136,6 +139,29 @@ def test_httpx_instrumentation_malformed_path(instrument, elasticapm_client): httpx.get("http://") +@pytest.mark.skipif(httpcore_version < (0, 17, 3), reason="MockBackend not available on older versions") +def test_default_port_handling(instrument, elasticapm_client): + url = "https://example.com/" + elasticapm_client.begin_transaction("transaction") + network_backend = httpcore.MockBackend( + buffer=[ + b"HTTP/1.1 200 OK\r\n", + b"Content-Type: plain/text\r\n", + b"Content-Length: 13\r\n", + b"\r\n", + b"Hello, world!", + ] + ) + with httpcore.ConnectionPool(network_backend=network_backend) as pool: + pool.request("GET", url) + elasticapm_client.end_transaction("transaction") + transactions = elasticapm_client.events[TRANSACTION] + span = elasticapm_client.spans_for_transaction(transactions[0])[0] + + assert span["name"] == "GET example.com" + assert span["context"]["http"]["url"] == url + + def test_url_sanitization(instrument, elasticapm_client, waiting_httpserver): waiting_httpserver.serve_content("") url = waiting_httpserver.url + "/hello_world"