Skip to content

Commit ef8e686

Browse files
committed
httpx: add support for OTEL_PYTHON_EXCLUDED_URLS / OTEL_PYTHON_HTTPX_EXCLUDED_URLS
1 parent 620abb5 commit ef8e686

File tree

2 files changed

+107
-3
lines changed

2 files changed

+107
-3
lines changed

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

Lines changed: 73 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)
@@ -533,6 +571,7 @@ def __init__(
533571
)
534572
self._request_hook = request_hook
535573
self._response_hook = response_hook
574+
self._excluded_urls = get_excluded_urls("HTTPX")
536575

537576
def __enter__(self) -> SyncOpenTelemetryTransport:
538577
self._transport.__enter__()
@@ -562,6 +601,12 @@ def handle_request(
562601
method, url, headers, stream, extensions = _extract_parameters(
563602
args, kwargs
564603
)
604+
605+
if self._excluded_urls and self._excluded_urls.url_disabled(
606+
_normalize_url(url)
607+
):
608+
return self._transport.handle_request(*args, **kwargs)
609+
565610
method_original = method.decode()
566611
span_name = _get_default_span_name(method_original)
567612
span_attributes = {}
@@ -726,6 +771,7 @@ def __init__(
726771

727772
self._request_hook = request_hook
728773
self._response_hook = response_hook
774+
self._excluded_urls = get_excluded_urls("HTTPX")
729775

730776
async def __aenter__(self) -> "AsyncOpenTelemetryTransport":
731777
await self._transport.__aenter__()
@@ -753,6 +799,12 @@ async def handle_async_request(
753799
method, url, headers, stream, extensions = _extract_parameters(
754800
args, kwargs
755801
)
802+
803+
if self._excluded_urls and self._excluded_urls.url_disabled(
804+
_normalize_url(url)
805+
):
806+
return await self._transport.handle_async_request(*args, **kwargs)
807+
756808
method_original = method.decode()
757809
span_name = _get_default_span_name(method_original)
758810
span_attributes = {}
@@ -900,6 +952,7 @@ def _instrument(self, **kwargs: typing.Any):
900952
if iscoroutinefunction(async_response_hook)
901953
else None
902954
)
955+
excluded_urls = get_excluded_urls("HTTPX")
903956

904957
_OpenTelemetrySemanticConventionStability._initialize()
905958
sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
@@ -948,6 +1001,7 @@ def _instrument(self, **kwargs: typing.Any):
9481001
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
9491002
request_hook=request_hook,
9501003
response_hook=response_hook,
1004+
excluded_urls=excluded_urls,
9511005
),
9521006
)
9531007
wrap_function_wrapper(
@@ -961,6 +1015,7 @@ def _instrument(self, **kwargs: typing.Any):
9611015
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
9621016
async_request_hook=async_request_hook,
9631017
async_response_hook=async_response_hook,
1018+
excluded_urls=excluded_urls,
9641019
),
9651020
)
9661021

@@ -980,13 +1035,18 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals
9801035
sem_conv_opt_in_mode: _StabilityMode,
9811036
request_hook: RequestHook,
9821037
response_hook: ResponseHook,
1038+
excluded_urls: ExcludeList | None,
9831039
):
9841040
if not is_http_instrumentation_enabled():
9851041
return wrapped(*args, **kwargs)
9861042

9871043
method, url, headers, stream, extensions = _extract_parameters(
9881044
args, kwargs
9891045
)
1046+
1047+
if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)):
1048+
return wrapped(*args, **kwargs)
1049+
9901050
method_original = method.decode()
9911051
span_name = _get_default_span_name(method_original)
9921052
span_attributes = {}
@@ -1096,13 +1156,18 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals
10961156
sem_conv_opt_in_mode: _StabilityMode,
10971157
async_request_hook: AsyncRequestHook,
10981158
async_response_hook: AsyncResponseHook,
1159+
excluded_urls: ExcludeList | None,
10991160
):
11001161
if not is_http_instrumentation_enabled():
11011162
return await wrapped(*args, **kwargs)
11021163

11031164
method, url, headers, stream, extensions = _extract_parameters(
11041165
args, kwargs
11051166
)
1167+
1168+
if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)):
1169+
return await wrapped(*args, **kwargs)
1170+
11061171
method_original = method.decode()
11071172
span_name = _get_default_span_name(method_original)
11081173
span_attributes = {}
@@ -1274,6 +1339,8 @@ def instrument_client(
12741339
# response_hook already set
12751340
async_response_hook = None
12761341

1342+
excluded_urls = get_excluded_urls("HTTPX")
1343+
12771344
if hasattr(client._transport, "handle_request"):
12781345
wrap_function_wrapper(
12791346
client._transport,
@@ -1286,6 +1353,7 @@ def instrument_client(
12861353
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
12871354
request_hook=request_hook,
12881355
response_hook=response_hook,
1356+
excluded_urls=excluded_urls,
12891357
),
12901358
)
12911359
for transport in client._mounts.values():
@@ -1301,6 +1369,7 @@ def instrument_client(
13011369
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13021370
request_hook=request_hook,
13031371
response_hook=response_hook,
1372+
excluded_urls=excluded_urls,
13041373
),
13051374
)
13061375
client._is_instrumented_by_opentelemetry = True
@@ -1316,6 +1385,7 @@ def instrument_client(
13161385
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13171386
async_request_hook=async_request_hook,
13181387
async_response_hook=async_response_hook,
1388+
excluded_urls=excluded_urls,
13191389
),
13201390
)
13211391
for transport in client._mounts.values():
@@ -1331,6 +1401,7 @@ def instrument_client(
13311401
sem_conv_opt_in_mode=sem_conv_opt_in_mode,
13321402
async_request_hook=async_request_hook,
13331403
async_response_hook=async_response_hook,
1404+
excluded_urls=excluded_urls,
13341405
),
13351406
)
13361407
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)