Skip to content

Commit c1c80f2

Browse files
committed
httpx: add support for OTEL_PYTHON_EXCLUDED_URLS / OTEL_PYTHON_HTTPX_EXCLUDED_URLS
1 parent e3d3817 commit c1c80f2

File tree

2 files changed

+110
-3
lines changed

2 files changed

+110
-3
lines changed

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

Lines changed: 76 additions & 2 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,21 @@ 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+
if port:
364+
return f"{scheme}://{host}:{port}{path}"
365+
else:
366+
return f"{scheme}://{host}{path}"
367+
368+
return str(url)
369+
370+
333371
def _inject_propagation_headers(headers, args, kwargs):
334372
_headers = _prepare_headers(headers)
335373
inject(_headers)
@@ -485,6 +523,7 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
485523
right after the span is created
486524
response_hook: A hook that receives the span, request, and response
487525
that is called right before the span ends
526+
excluded_urls: an ExcludeList instance as returned by opentelemetry.util.http.get_excluded_urls
488527
"""
489528

490529
def __init__(
@@ -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 = {}
@@ -676,6 +722,7 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
676722
right after the span is created
677723
response_hook: A hook that receives the span, request, and response
678724
that is called right before the span ends
725+
excluded_urls: an ExcludeList instance as returned by opentelemetry.util.http.get_
679726
"""
680727

681728
def __init__(
@@ -685,6 +732,7 @@ def __init__(
685732
meter_provider: MeterProvider | None = None,
686733
request_hook: AsyncRequestHook | None = None,
687734
response_hook: AsyncResponseHook | None = None,
735+
excluded_urls: ExcludeList | None = None,
688736
):
689737
_OpenTelemetrySemanticConventionStability._initialize()
690738
self._sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
@@ -726,6 +774,7 @@ def __init__(
726774

727775
self._request_hook = request_hook
728776
self._response_hook = response_hook
777+
self._excluded_urls = excluded_urls
729778

730779
async def __aenter__(self) -> "AsyncOpenTelemetryTransport":
731780
await self._transport.__aenter__()
@@ -753,6 +802,12 @@ async def handle_async_request(
753802
method, url, headers, stream, extensions = _extract_parameters(
754803
args, kwargs
755804
)
805+
806+
if self._excluded_urls and self._excluded_urls.url_disabled(
807+
_normalize_url(url)
808+
):
809+
return await self._transport.handle_async_request(*args, **kwargs)
810+
756811
method_original = method.decode()
757812
span_name = _get_default_span_name(method_original)
758813
span_attributes = {}
@@ -900,6 +955,7 @@ def _instrument(self, **kwargs: typing.Any):
900955
if iscoroutinefunction(async_response_hook)
901956
else None
902957
)
958+
excluded_urls = get_excluded_urls("HTTPX")
903959

904960
_OpenTelemetrySemanticConventionStability._initialize()
905961
sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
@@ -948,6 +1004,7 @@ def _instrument(self, **kwargs: typing.Any):
9481004
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
9491005
request_hook=request_hook,
9501006
response_hook=response_hook,
1007+
excluded_urls=excluded_urls,
9511008
),
9521009
)
9531010
wrap_function_wrapper(
@@ -961,6 +1018,7 @@ def _instrument(self, **kwargs: typing.Any):
9611018
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
9621019
async_request_hook=async_request_hook,
9631020
async_response_hook=async_response_hook,
1021+
excluded_urls=excluded_urls,
9641022
),
9651023
)
9661024

@@ -980,13 +1038,18 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals
9801038
sem_conv_opt_in_mode: _StabilityMode,
9811039
request_hook: RequestHook,
9821040
response_hook: ResponseHook,
1041+
excluded_urls: ExcludeList | None,
9831042
):
9841043
if not is_http_instrumentation_enabled():
9851044
return wrapped(*args, **kwargs)
9861045

9871046
method, url, headers, stream, extensions = _extract_parameters(
9881047
args, kwargs
9891048
)
1049+
1050+
if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)):
1051+
return wrapped(*args, **kwargs)
1052+
9901053
method_original = method.decode()
9911054
span_name = _get_default_span_name(method_original)
9921055
span_attributes = {}
@@ -1096,13 +1159,18 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals
10961159
sem_conv_opt_in_mode: _StabilityMode,
10971160
async_request_hook: AsyncRequestHook,
10981161
async_response_hook: AsyncResponseHook,
1162+
excluded_urls: ExcludeList | None,
10991163
):
11001164
if not is_http_instrumentation_enabled():
11011165
return await wrapped(*args, **kwargs)
11021166

11031167
method, url, headers, stream, extensions = _extract_parameters(
11041168
args, kwargs
11051169
)
1170+
1171+
if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)):
1172+
return await wrapped(*args, **kwargs)
1173+
11061174
method_original = method.decode()
11071175
span_name = _get_default_span_name(method_original)
11081176
span_attributes = {}
@@ -1274,6 +1342,8 @@ def instrument_client(
12741342
# response_hook already set
12751343
async_response_hook = None
12761344

1345+
excluded_urls = get_excluded_urls("HTTPX")
1346+
12771347
if hasattr(client._transport, "handle_request"):
12781348
wrap_function_wrapper(
12791349
client._transport,
@@ -1286,6 +1356,7 @@ def instrument_client(
12861356
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
12871357
request_hook=request_hook,
12881358
response_hook=response_hook,
1359+
excluded_urls=excluded_urls,
12891360
),
12901361
)
12911362
for transport in client._mounts.values():
@@ -1301,6 +1372,7 @@ def instrument_client(
13011372
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13021373
request_hook=request_hook,
13031374
response_hook=response_hook,
1375+
excluded_urls=excluded_urls,
13041376
),
13051377
)
13061378
client._is_instrumented_by_opentelemetry = True
@@ -1316,6 +1388,7 @@ def instrument_client(
13161388
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13171389
async_request_hook=async_request_hook,
13181390
async_response_hook=async_response_hook,
1391+
excluded_urls=excluded_urls,
13191392
),
13201393
)
13211394
for transport in client._mounts.values():
@@ -1331,6 +1404,7 @@ def instrument_client(
13311404
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13321405
async_request_hook=async_request_hook,
13331406
async_response_hook=async_response_hook,
1407+
excluded_urls=excluded_urls,
13341408
),
13351409
)
13361410
client._is_instrumented_by_opentelemetry = True

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,17 @@ 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+
with mock.patch.dict(
743+
"os.environ", {"OTEL_PYTHON_HTTPX_EXCLUDED_URLS": self.URL}
744+
):
745+
client = self.create_client()
746+
HTTPXClientInstrumentor().instrument_client(client=client)
747+
self.perform_request(self.URL, client=client)
748+
self.assert_span(num_spans=0)
749+
self.assert_metrics(num_metrics=0)
750+
HTTPXClientInstrumentor().uninstrument_client(client)
751+
741752
class BaseManualTest(BaseTest, metaclass=abc.ABCMeta):
742753
@abc.abstractmethod
743754
def create_transport(
@@ -972,6 +983,17 @@ def test_client_mounts_with_instrumented_transport(self):
972983
self.assertEqual(spans[0].attributes[HTTP_URL], self.URL)
973984
self.assertEqual(spans[1].attributes[HTTP_URL], https_url)
974985

986+
def test_ignores_excluded_urls(self):
987+
with mock.patch.dict(
988+
"os.environ", {"OTEL_PYTHON_HTTPX_EXCLUDED_URLS": self.URL}
989+
):
990+
client = self.create_client()
991+
HTTPXClientInstrumentor().instrument_client(client=client)
992+
self.perform_request(self.URL, client=client)
993+
self.assert_span(num_spans=0)
994+
self.assert_metrics(num_metrics=0)
995+
HTTPXClientInstrumentor().uninstrument_client(client=client)
996+
975997
@mock.patch.dict("os.environ", {"NO_PROXY": ""}, clear=True)
976998
class BaseInstrumentorTest(BaseTest, metaclass=abc.ABCMeta):
977999
@abc.abstractmethod
@@ -998,7 +1020,7 @@ def setUp(self):
9981020
HTTPXClientInstrumentor().instrument_client(self.client)
9991021

10001022
def tearDown(self):
1001-
HTTPXClientInstrumentor().uninstrument()
1023+
HTTPXClientInstrumentor().uninstrument_client(self.client)
10021024

10031025
def create_proxy_mounts(self):
10041026
return {
@@ -1329,6 +1351,17 @@ def test_uninstrument_client_with_proxy(self):
13291351
self.assertEqual(result.text, "Hello!")
13301352
self.assert_span()
13311353

1354+
def test_ignores_excluded_urls(self):
1355+
# need to instrument again for the environment variable
1356+
HTTPXClientInstrumentor().uninstrument_client(self.client)
1357+
with mock.patch.dict(
1358+
"os.environ", {"OTEL_PYTHON_HTTPX_EXCLUDED_URLS": self.URL}
1359+
):
1360+
HTTPXClientInstrumentor().instrument_client(client=self.client)
1361+
self.perform_request(self.URL, client=self.client)
1362+
self.assert_span(num_spans=0)
1363+
self.assert_metrics(num_metrics=0)
1364+
13321365

13331366
class TestSyncIntegration(BaseTestCases.BaseManualTest):
13341367
def setUp(self):

0 commit comments

Comments
 (0)