Skip to content

Commit b9be76f

Browse files
authored
httpx: add support for OTEL_PYTHON_EXCLUDED_URLS / OTEL_PYTHON_HTTPX_EXCLUDED_URLS (open-telemetry#3837)
* httpx: add support for OTEL_PYTHON_EXCLUDED_URLS / OTEL_PYTHON_HTTPX_EXCLUDED_URLS * Add changelog * Fix lint * Revert a change to a teardown, add a comment instead * Test both environment variables
1 parent 620abb5 commit b9be76f

File tree

3 files changed

+135
-3
lines changed

3 files changed

+135
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- `opentelemetry-instrumentation-aiohttp-client`: add support for url exclusions via `OTEL_PYTHON_EXCLUDED_URLS` / `OTEL_PYTHON_AIOHTTP_CLIENT_EXCLUDED_URLS`
1717
([#3850](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3850))
18+
- `opentelemetry-instrumentation-httpx`: add support for url exclusions via `OTEL_PYTHON_EXCLUDED_URLS` / `OTEL_PYTHON_HTTPX_EXCLUDED_URLS`
19+
([#3837](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3837))
1820

1921
### Fixed
2022

instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,24 @@ async def async_response_hook(span, request, response):
199199
response_hook=async_response_hook
200200
)
201201
202+
203+
Configuration
204+
-------------
205+
206+
Exclude lists
207+
*************
208+
To exclude certain URLs from tracking, set the environment variable ``OTEL_PYTHON_HTTPX_EXCLUDED_URLS``
209+
(or ``OTEL_PYTHON_EXCLUDED_URLS`` to cover all instrumentations) to a string of comma delimited regexes that match the
210+
URLs.
211+
212+
For example,
213+
214+
::
215+
216+
export OTEL_PYTHON_HTTPX_EXCLUDED_URLS="client/.*/info,healthcheck"
217+
218+
will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``.
219+
202220
API
203221
---
204222
"""
@@ -259,7 +277,12 @@ async def async_response_hook(span, request, response):
259277
from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer
260278
from opentelemetry.trace.span import Span
261279
from opentelemetry.trace.status import StatusCode
262-
from opentelemetry.util.http import redact_url, sanitize_method
280+
from opentelemetry.util.http import (
281+
ExcludeList,
282+
get_excluded_urls,
283+
redact_url,
284+
sanitize_method,
285+
)
263286

264287
_logger = logging.getLogger(__name__)
265288

@@ -304,7 +327,7 @@ def _extract_parameters(
304327
args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
305328
) -> tuple[
306329
bytes,
307-
httpx.URL,
330+
httpx.URL | tuple[bytes, bytes, int | None, bytes],
308331
httpx.Headers | None,
309332
httpx.SyncByteStream | httpx.AsyncByteStream | None,
310333
dict[str, typing.Any],
@@ -330,6 +353,22 @@ def _extract_parameters(
330353
return method, url, headers, stream, extensions
331354

332355

356+
def _normalize_url(
357+
url: httpx.URL | tuple[bytes, bytes, int | None, bytes],
358+
) -> str:
359+
if isinstance(url, tuple):
360+
scheme, host, port, path = [
361+
part.decode() if isinstance(part, bytes) else part for part in url
362+
]
363+
return (
364+
f"{scheme}://{host}:{port}{path}"
365+
if port
366+
else f"{scheme}://{host}{path}"
367+
)
368+
369+
return str(url)
370+
371+
333372
def _inject_propagation_headers(headers, args, kwargs):
334373
_headers = _prepare_headers(headers)
335374
inject(_headers)
@@ -533,6 +572,7 @@ def __init__(
533572
)
534573
self._request_hook = request_hook
535574
self._response_hook = response_hook
575+
self._excluded_urls = get_excluded_urls("HTTPX")
536576

537577
def __enter__(self) -> SyncOpenTelemetryTransport:
538578
self._transport.__enter__()
@@ -562,6 +602,12 @@ def handle_request(
562602
method, url, headers, stream, extensions = _extract_parameters(
563603
args, kwargs
564604
)
605+
606+
if self._excluded_urls and self._excluded_urls.url_disabled(
607+
_normalize_url(url)
608+
):
609+
return self._transport.handle_request(*args, **kwargs)
610+
565611
method_original = method.decode()
566612
span_name = _get_default_span_name(method_original)
567613
span_attributes = {}
@@ -726,6 +772,7 @@ def __init__(
726772

727773
self._request_hook = request_hook
728774
self._response_hook = response_hook
775+
self._excluded_urls = get_excluded_urls("HTTPX")
729776

730777
async def __aenter__(self) -> "AsyncOpenTelemetryTransport":
731778
await self._transport.__aenter__()
@@ -753,6 +800,12 @@ async def handle_async_request(
753800
method, url, headers, stream, extensions = _extract_parameters(
754801
args, kwargs
755802
)
803+
804+
if self._excluded_urls and self._excluded_urls.url_disabled(
805+
_normalize_url(url)
806+
):
807+
return await self._transport.handle_async_request(*args, **kwargs)
808+
756809
method_original = method.decode()
757810
span_name = _get_default_span_name(method_original)
758811
span_attributes = {}
@@ -900,6 +953,7 @@ def _instrument(self, **kwargs: typing.Any):
900953
if iscoroutinefunction(async_response_hook)
901954
else None
902955
)
956+
excluded_urls = get_excluded_urls("HTTPX")
903957

904958
_OpenTelemetrySemanticConventionStability._initialize()
905959
sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
@@ -948,6 +1002,7 @@ def _instrument(self, **kwargs: typing.Any):
9481002
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
9491003
request_hook=request_hook,
9501004
response_hook=response_hook,
1005+
excluded_urls=excluded_urls,
9511006
),
9521007
)
9531008
wrap_function_wrapper(
@@ -961,6 +1016,7 @@ def _instrument(self, **kwargs: typing.Any):
9611016
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
9621017
async_request_hook=async_request_hook,
9631018
async_response_hook=async_response_hook,
1019+
excluded_urls=excluded_urls,
9641020
),
9651021
)
9661022

@@ -980,13 +1036,18 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals
9801036
sem_conv_opt_in_mode: _StabilityMode,
9811037
request_hook: RequestHook,
9821038
response_hook: ResponseHook,
1039+
excluded_urls: ExcludeList | None,
9831040
):
9841041
if not is_http_instrumentation_enabled():
9851042
return wrapped(*args, **kwargs)
9861043

9871044
method, url, headers, stream, extensions = _extract_parameters(
9881045
args, kwargs
9891046
)
1047+
1048+
if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)):
1049+
return wrapped(*args, **kwargs)
1050+
9901051
method_original = method.decode()
9911052
span_name = _get_default_span_name(method_original)
9921053
span_attributes = {}
@@ -1096,13 +1157,18 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals
10961157
sem_conv_opt_in_mode: _StabilityMode,
10971158
async_request_hook: AsyncRequestHook,
10981159
async_response_hook: AsyncResponseHook,
1160+
excluded_urls: ExcludeList | None,
10991161
):
11001162
if not is_http_instrumentation_enabled():
11011163
return await wrapped(*args, **kwargs)
11021164

11031165
method, url, headers, stream, extensions = _extract_parameters(
11041166
args, kwargs
11051167
)
1168+
1169+
if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)):
1170+
return await wrapped(*args, **kwargs)
1171+
11061172
method_original = method.decode()
11071173
span_name = _get_default_span_name(method_original)
11081174
span_attributes = {}
@@ -1198,7 +1264,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals
11981264

11991265
return response
12001266

1201-
# pylint: disable=too-many-branches
1267+
# pylint: disable=too-many-branches,too-many-locals
12021268
@classmethod
12031269
def instrument_client(
12041270
cls,
@@ -1274,6 +1340,8 @@ def instrument_client(
12741340
# response_hook already set
12751341
async_response_hook = None
12761342

1343+
excluded_urls = get_excluded_urls("HTTPX")
1344+
12771345
if hasattr(client._transport, "handle_request"):
12781346
wrap_function_wrapper(
12791347
client._transport,
@@ -1286,6 +1354,7 @@ def instrument_client(
12861354
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
12871355
request_hook=request_hook,
12881356
response_hook=response_hook,
1357+
excluded_urls=excluded_urls,
12891358
),
12901359
)
12911360
for transport in client._mounts.values():
@@ -1301,6 +1370,7 @@ def instrument_client(
13011370
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13021371
request_hook=request_hook,
13031372
response_hook=response_hook,
1373+
excluded_urls=excluded_urls,
13041374
),
13051375
)
13061376
client._is_instrumented_by_opentelemetry = True
@@ -1316,6 +1386,7 @@ def instrument_client(
13161386
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13171387
async_request_hook=async_request_hook,
13181388
async_response_hook=async_response_hook,
1389+
excluded_urls=excluded_urls,
13191390
),
13201391
)
13211392
for transport in client._mounts.values():
@@ -1331,6 +1402,7 @@ def instrument_client(
13311402
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13321403
async_request_hook=async_request_hook,
13331404
async_response_hook=async_response_hook,
1405+
excluded_urls=excluded_urls,
13341406
),
13351407
)
13361408
client._is_instrumented_by_opentelemetry = True

instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,24 @@ def test_if_headers_equals_none(self):
738738
self.assertEqual(result.text, "Hello!")
739739
self.assert_span()
740740

741+
def test_ignores_excluded_urls(self):
742+
for env_var in (
743+
"OTEL_PYTHON_HTTPX_EXCLUDED_URLS",
744+
"OTEL_PYTHON_EXCLUDED_URLS",
745+
):
746+
with self.subTest(env_var=env_var):
747+
with mock.patch.dict(
748+
"os.environ", {env_var: self.URL}, clear=True
749+
):
750+
client = self.create_client()
751+
HTTPXClientInstrumentor().instrument_client(
752+
client=client
753+
)
754+
self.perform_request(self.URL, client=client)
755+
self.assert_span(num_spans=0)
756+
self.assert_metrics(num_metrics=0)
757+
HTTPXClientInstrumentor().uninstrument_client(client)
758+
741759
class BaseManualTest(BaseTest, metaclass=abc.ABCMeta):
742760
@abc.abstractmethod
743761
def create_transport(
@@ -972,6 +990,26 @@ def test_client_mounts_with_instrumented_transport(self):
972990
self.assertEqual(spans[0].attributes[HTTP_URL], self.URL)
973991
self.assertEqual(spans[1].attributes[HTTP_URL], https_url)
974992

993+
def test_ignores_excluded_urls(self):
994+
for env_var in (
995+
"OTEL_PYTHON_HTTPX_EXCLUDED_URLS",
996+
"OTEL_PYTHON_EXCLUDED_URLS",
997+
):
998+
with self.subTest(env_var=env_var):
999+
with mock.patch.dict(
1000+
"os.environ", {env_var: self.URL}, clear=True
1001+
):
1002+
client = self.create_client()
1003+
HTTPXClientInstrumentor().instrument_client(
1004+
client=client
1005+
)
1006+
self.perform_request(self.URL, client=client)
1007+
self.assert_span(num_spans=0)
1008+
self.assert_metrics(num_metrics=0)
1009+
HTTPXClientInstrumentor().uninstrument_client(
1010+
client=client
1011+
)
1012+
9751013
@mock.patch.dict("os.environ", {"NO_PROXY": ""}, clear=True)
9761014
class BaseInstrumentorTest(BaseTest, metaclass=abc.ABCMeta):
9771015
@abc.abstractmethod
@@ -998,6 +1036,8 @@ def setUp(self):
9981036
HTTPXClientInstrumentor().instrument_client(self.client)
9991037

10001038
def tearDown(self):
1039+
# TODO: uninstrument() is required in order to avoid leaks for instrumentations
1040+
# but we should audit the single tests and fix any missing uninstrumentation
10011041
HTTPXClientInstrumentor().uninstrument()
10021042

10031043
def create_proxy_mounts(self):
@@ -1329,6 +1369,24 @@ def test_uninstrument_client_with_proxy(self):
13291369
self.assertEqual(result.text, "Hello!")
13301370
self.assert_span()
13311371

1372+
def test_ignores_excluded_urls(self):
1373+
for env_var in (
1374+
"OTEL_PYTHON_HTTPX_EXCLUDED_URLS",
1375+
"OTEL_PYTHON_EXCLUDED_URLS",
1376+
):
1377+
with self.subTest(env_var=env_var):
1378+
client = self.create_client()
1379+
with mock.patch.dict(
1380+
"os.environ", {env_var: self.URL}, clear=True
1381+
):
1382+
HTTPXClientInstrumentor().instrument_client(
1383+
client=client
1384+
)
1385+
self.perform_request(self.URL, client=client)
1386+
self.assert_span(num_spans=0)
1387+
self.assert_metrics(num_metrics=0)
1388+
HTTPXClientInstrumentor().uninstrument_client(client)
1389+
13321390

13331391
class TestSyncIntegration(BaseTestCases.BaseManualTest):
13341392
def setUp(self):

0 commit comments

Comments
 (0)