From 1554742f57b84ac007007fca569d32b1101cba84 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Fri, 28 Mar 2025 12:33:50 +0100 Subject: [PATCH 1/6] feat: Add instrumentation to httpx. Signed-off-by: Paulo Vital --- src/instana/__init__.py | 1 + src/instana/instrumentation/httpx.py | 128 +++++++++++++++++++++++++++ src/instana/span/kind.py | 2 + src/instana/span/registered_span.py | 11 ++- src/instana/util/secrets.py | 2 +- 5 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 src/instana/instrumentation/httpx.py diff --git a/src/instana/__init__.py b/src/instana/__init__.py index c3849aad..7a9ec0b1 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -175,6 +175,7 @@ def boot_agent() -> None: flask, # noqa: F401 # gevent_inst, # noqa: F401 grpcio, # noqa: F401 + httpx, # noqa: F401 logging, # noqa: F401 mysqlclient, # noqa: F401 pep0249, # noqa: F401 diff --git a/src/instana/instrumentation/httpx.py b/src/instana/instrumentation/httpx.py new file mode 100644 index 00000000..d64fd717 --- /dev/null +++ b/src/instana/instrumentation/httpx.py @@ -0,0 +1,128 @@ +# (c) Copyright IBM Corp. 2025 + +try: + from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Optional + + import wrapt + from opentelemetry.semconv.trace import SpanAttributes + from opentelemetry.trace import SpanKind + + import httpx + from instana.log import logger + from instana.propagators.format import Format + from instana.singletons import agent + from instana.util.secrets import strip_secrets_from_query + from instana.util.traceutils import ( + extract_custom_headers, + get_tracer_tuple, + tracing_is_off, + ) + + if TYPE_CHECKING: + from instana.span.span import InstanaSpan + + def _set_span_attributes( + span: "InstanaSpan", + args: Tuple[int, str, Tuple[Any, ...]], + kwargs: Dict[str, Any], + response: Optional[httpx.Response] = None, + ) -> None: + kvs = _collect_request_args(args, kwargs) + if "host" in kvs: + span.set_attribute(SpanAttributes.HTTP_HOST, kvs["host"]) + if "url" in kvs: + span.set_attribute(SpanAttributes.HTTP_URL, kvs["url"]) + if "query" in kvs: + span.set_attribute("http.params", kvs["query"]) + if "method" in kvs: + span.set_attribute(SpanAttributes.HTTP_METHOD, kvs["method"]) + if "path" in kvs: + span.set_attribute("http.path", kvs["path"]) + if "headers" in kvs: + extract_custom_headers(span, kvs["headers"]) + + resp = _collect_response(response) + if "status_code" in resp: + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, resp["status_code"]) + if "headers" in resp: + extract_custom_headers(span, resp["headers"]) + if 500 <= resp["status_code"]: + span.mark_as_errored() + + def _collect_request_args( + args: Tuple[int, str, Tuple[Any, ...]], + kwargs: Dict[str, Any], + ) -> Dict[str, Any]: + kvs = dict() + try: + if isinstance(args[0], httpx.Request): + kvs["host"] = args[0].url.host + kvs["port"] = args[0].url.port + kvs["method"] = args[0].method + kvs["path"] = args[0].url.path + + # Strip any secrets from potential query params + if args[0].url.query: + kvs["query"] = strip_secrets_from_query( + str(args[0].url.query, encoding='utf-8'), + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + + url = f"{args[0].url.scheme}://{kvs["host"]}" + if kvs["port"]: + url += f":{kvs["port"]}" + url += f"{kvs["path"]}" + kvs["url"] = url + + if "headers" in kwargs: + kvs["headers"] = kwargs["headers"].copy() + except Exception: + logger.debug("httpx _collect_request_args error: ", exc_info=True) + finally: + return kvs + + def _collect_response( + response: httpx.Response + ) -> Dict[str, Any]: + kvs = dict() + try: + kvs["status_code"] = response.status_code + if response.headers: + kvs["headers"] = response.headers.copy() + except Exception: + logger.debug("httpx _collect_response error: ", exc_info=True) + finally: + return kvs + + @wrapt.patch_function_wrapper("httpx", "HTTPTransport.handle_request") + def handle_request_with_instana( + wrapped: Callable[..., "httpx.HTTPTransport.handle_request"], + instance: httpx.HTTPTransport, + args: Tuple[int, str, Tuple[Any, ...]], + kwargs: Dict[str, Any], + ) -> httpx.Response: + # If we're not tracing, just return + if tracing_is_off(): + return wrapped(*args, **kwargs) + + tracer, parent_span, span_name = get_tracer_tuple() + parent_context = parent_span.get_span_context() if parent_span else None + + with tracer.start_as_current_span( + "httpx", span_context=parent_context, kind=SpanKind.CLIENT + ) as span: + try: + if "headers" in kwargs: + tracer.inject(span.context, Format.HTTP_HEADERS, kwargs["headers"]) + + response = wrapped(*args, **kwargs) + _set_span_attributes(span, args, kwargs, response) + except Exception as e: + span.record_exception(e) + else: + return response + + logger.debug("Instrumenting httpx") +except ImportError: + pass diff --git a/src/instana/span/kind.py b/src/instana/span/kind.py index c86f8b97..52663b13 100644 --- a/src/instana/span/kind.py +++ b/src/instana/span/kind.py @@ -13,6 +13,7 @@ "aiohttp-server", "django", "http", + "httpx", "tornado-client", "tornado-server", "urllib3", @@ -43,6 +44,7 @@ "celery-client", "couchbase", "dynamodb", + "httpx", "log", "memcache", "mongo", diff --git a/src/instana/span/registered_span.py b/src/instana/span/registered_span.py index 68557c1f..597c371b 100644 --- a/src/instana/span/registered_span.py +++ b/src/instana/span/registered_span.py @@ -7,12 +7,7 @@ from instana.log import logger from instana.span.base_span import BaseSpan -from instana.span.kind import ( - ENTRY_SPANS, - EXIT_SPANS, - HTTP_SPANS, - LOCAL_SPANS, -) +from instana.span.kind import ENTRY_SPANS, EXIT_SPANS, HTTP_SPANS, LOCAL_SPANS if TYPE_CHECKING: from instana.span.span import InstanaSpan @@ -58,6 +53,10 @@ def __init__( if "amqp" in span.name: self.n = "amqp" + # unify the span name for httpx (and future exit HTTP spans) + if "httpx" in span.name: + self.n = "http" + # Logic to store custom attributes for registered spans (not used yet) if len(span.attributes) > 0: self.data["sdk"]["custom"]["tags"] = self._validate_attributes( diff --git a/src/instana/util/secrets.py b/src/instana/util/secrets.py index f5b8c071..c5a01281 100644 --- a/src/instana/util/secrets.py +++ b/src/instana/util/secrets.py @@ -79,7 +79,7 @@ def strip_secrets_from_query(qp, matcher, kwlist): return qp # If there are no key=values, then just return - if not '=' in qp: + if '=' not in qp: return qp if '?' in qp: From 1375c62fcebe0b269ba4f131f2466c952f0048ef Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Wed, 25 Jun 2025 16:18:07 +0530 Subject: [PATCH 2/6] httpx: extract headers from `request` object Signed-off-by: Varsha GS --- src/instana/instrumentation/httpx.py | 111 ++++++++++----------------- 1 file changed, 41 insertions(+), 70 deletions(-) diff --git a/src/instana/instrumentation/httpx.py b/src/instana/instrumentation/httpx.py index d64fd717..ab42c22b 100644 --- a/src/instana/instrumentation/httpx.py +++ b/src/instana/instrumentation/httpx.py @@ -1,13 +1,12 @@ # (c) Copyright IBM Corp. 2025 try: - from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Optional - + import httpx import wrapt + from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Optional from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace import SpanKind - import httpx from instana.log import logger from instana.propagators.format import Format from instana.singletons import agent @@ -21,79 +20,50 @@ if TYPE_CHECKING: from instana.span.span import InstanaSpan - def _set_span_attributes( + def _set_request_span_attributes( span: "InstanaSpan", - args: Tuple[int, str, Tuple[Any, ...]], - kwargs: Dict[str, Any], - response: Optional[httpx.Response] = None, + request: httpx.Request, ) -> None: - kvs = _collect_request_args(args, kwargs) - if "host" in kvs: - span.set_attribute(SpanAttributes.HTTP_HOST, kvs["host"]) - if "url" in kvs: - span.set_attribute(SpanAttributes.HTTP_URL, kvs["url"]) - if "query" in kvs: - span.set_attribute("http.params", kvs["query"]) - if "method" in kvs: - span.set_attribute(SpanAttributes.HTTP_METHOD, kvs["method"]) - if "path" in kvs: - span.set_attribute("http.path", kvs["path"]) - if "headers" in kvs: - extract_custom_headers(span, kvs["headers"]) - - resp = _collect_response(response) - if "status_code" in resp: - span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, resp["status_code"]) - if "headers" in resp: - extract_custom_headers(span, resp["headers"]) - if 500 <= resp["status_code"]: - span.mark_as_errored() - - def _collect_request_args( - args: Tuple[int, str, Tuple[Any, ...]], - kwargs: Dict[str, Any], - ) -> Dict[str, Any]: - kvs = dict() try: - if isinstance(args[0], httpx.Request): - kvs["host"] = args[0].url.host - kvs["port"] = args[0].url.port - kvs["method"] = args[0].method - kvs["path"] = args[0].url.path + url = request.url - # Strip any secrets from potential query params - if args[0].url.query: - kvs["query"] = strip_secrets_from_query( - str(args[0].url.query, encoding='utf-8'), - agent.options.secrets_matcher, - agent.options.secrets_list, - ) + # Strip any secrets from potential query params + if url.query: + formatted_query = strip_secrets_from_query( + str(url.query, encoding="utf-8"), + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + span.set_attribute("http.params", formatted_query) + + url_str = f"{url.scheme}://{url.host}" + if url.port: + url_str += f":{url.port}" + url_str += f"{url.path}" - url = f"{args[0].url.scheme}://{kvs["host"]}" - if kvs["port"]: - url += f":{kvs["port"]}" - url += f"{kvs["path"]}" - kvs["url"] = url + span.set_attribute(SpanAttributes.HTTP_URL, url_str) + span.set_attribute(SpanAttributes.HTTP_HOST, url.host) + span.set_attribute(SpanAttributes.HTTP_METHOD, request.method) + span.set_attribute("http.path", url.path) - if "headers" in kwargs: - kvs["headers"] = kwargs["headers"].copy() + extract_custom_headers(span, request.headers) except Exception: - logger.debug("httpx _collect_request_args error: ", exc_info=True) - finally: - return kvs + logger.debug("httpx _set_request_span_attributes error: ", exc_info=True) - def _collect_response( - response: httpx.Response - ) -> Dict[str, Any]: - kvs = dict() + def _set_response_span_attributes( + span: "InstanaSpan", + response: Optional[httpx.Response] = None, + ) -> None: try: - kvs["status_code"] = response.status_code if response.headers: - kvs["headers"] = response.headers.copy() + extract_custom_headers(span, response.headers) + + status_code = response.status_code + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) + if 500 <= status_code: + span.mark_as_errored() except Exception: - logger.debug("httpx _collect_response error: ", exc_info=True) - finally: - return kvs + logger.debug("httpx _set_request_span_attributes error: ", exc_info=True) @wrapt.patch_function_wrapper("httpx", "HTTPTransport.handle_request") def handle_request_with_instana( @@ -106,18 +76,19 @@ def handle_request_with_instana( if tracing_is_off(): return wrapped(*args, **kwargs) - tracer, parent_span, span_name = get_tracer_tuple() + tracer, parent_span, _ = get_tracer_tuple() parent_context = parent_span.get_span_context() if parent_span else None with tracer.start_as_current_span( "httpx", span_context=parent_context, kind=SpanKind.CLIENT ) as span: try: - if "headers" in kwargs: - tracer.inject(span.context, Format.HTTP_HEADERS, kwargs["headers"]) - + request = args[0] + _set_request_span_attributes(span, request) + tracer.inject(span.context, Format.HTTP_HEADERS, request.headers) + response = wrapped(*args, **kwargs) - _set_span_attributes(span, args, kwargs, response) + _set_response_span_attributes(span, response) except Exception as e: span.record_exception(e) else: From fc35e37abae492a5805eaf79788dec24ec9290a3 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Wed, 25 Jun 2025 16:20:12 +0530 Subject: [PATCH 3/6] tests(httpx): Add tests for `sync` requests Signed-off-by: Varsha GS --- tests/clients/test_httpx.py | 388 ++++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 tests/clients/test_httpx.py diff --git a/tests/clients/test_httpx.py b/tests/clients/test_httpx.py new file mode 100644 index 00000000..227a5167 --- /dev/null +++ b/tests/clients/test_httpx.py @@ -0,0 +1,388 @@ +# (c) Copyright IBM Corp. 2025 + +import pytest +import httpx +from typing import Generator + +from instana.singletons import agent, tracer +from instana.util.ids import hex_id +import tests.apps.flask_app +from tests.helpers import testenv + + +class TestHttpx: + @pytest.fixture(autouse=True) + def _setup(self) -> Generator[None, None, None]: + """SetUp and TearDown""" + # setup + # Clear all spans before a test run + self.host = "127.0.0.1" + self.recorder = tracer.span_processor + self.recorder.clear_spans() + yield + # teardown + # Ensure that allow_exit_as_root has the default value + agent.options.allow_exit_as_root = False + + def test_get_request(self): + with tracer.start_as_current_span("test"): + res = httpx.get(testenv["flask_server"] + "/") + + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + wsgi_span = spans[0] + httpx_span = spans[1] + test_span = spans[2] + + assert res + assert res.status_code == 200 + + assert "X-INSTANA-T" in res.headers + assert int(res.headers["X-INSTANA-T"], 16) + assert res.headers["X-INSTANA-T"] == hex_id(wsgi_span.t) + + assert "X-INSTANA-S" in res.headers + assert int(res.headers["X-INSTANA-S"], 16) + assert res.headers["X-INSTANA-S"] == hex_id(wsgi_span.s) + + assert "X-INSTANA-L" in res.headers + assert res.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in res.headers + server_timing_value = f"intid;desc={hex_id(wsgi_span.t)}" + assert res.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == httpx_span.t + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert httpx_span.p == test_span.s + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not test_span.ec + assert not httpx_span.ec + assert not wsgi_span.ec + + # span names + assert wsgi_span.n == "wsgi" + assert test_span.data["sdk"]["name"] == "test" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == "/" + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + "/" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + def test_get_request_as_root_exit_span(self): + agent.options.allow_exit_as_root = True + res = httpx.get(testenv["flask_server"] + "/") + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + + wsgi_span = spans[0] + httpx_span = spans[1] + + assert res + assert res.status_code == 200 + + assert "X-INSTANA-T" in res.headers + assert int(res.headers["X-INSTANA-T"], 16) + assert res.headers["X-INSTANA-T"] == hex_id(wsgi_span.t) + + assert "X-INSTANA-S" in res.headers + assert int(res.headers["X-INSTANA-S"], 16) + assert res.headers["X-INSTANA-S"] == hex_id(wsgi_span.s) + + assert "X-INSTANA-L" in res.headers + assert res.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in res.headers + server_timing_value = f"intid;desc={hex_id(wsgi_span.t)}" + assert res.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert not httpx_span.p + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not httpx_span.ec + assert not wsgi_span.ec + + # span names + assert wsgi_span.n == "wsgi" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == "/" + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + "/" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + def test_get_request_with_query(self): + with tracer.start_as_current_span("test"): + res = httpx.get(testenv["flask_server"] + "/?user=instana&pass=itsasecret") + + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + wsgi_span = spans[0] + httpx_span = spans[1] + test_span = spans[2] + + assert res + assert res.status_code == 200 + + # Same traceId + assert test_span.t == httpx_span.t + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert httpx_span.p == test_span.s + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not test_span.ec + assert not httpx_span.ec + assert not wsgi_span.ec + + # span names + assert wsgi_span.n == "wsgi" + assert test_span.data["sdk"]["name"] == "test" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == "/" + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + "/" + assert httpx_span.data["http"]["params"] == "user=instana&pass=" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + def test_post_request(self): + path = "/notfound" + with tracer.start_as_current_span("test"): + res = httpx.post(testenv["flask_server"] + "/notfound") + + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + wsgi_span = spans[0] + httpx_span = spans[1] + test_span = spans[2] + + assert res + assert res.status_code == 404 + + # Same traceId + assert test_span.t == httpx_span.t + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert httpx_span.p == test_span.s + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not test_span.ec + assert not httpx_span.ec + assert not wsgi_span.ec + + # span names + assert wsgi_span.n == "wsgi" + assert test_span.data["sdk"]["name"] == "test" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 404 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + path + assert httpx_span.data["http"]["method"] == "POST" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + def test_5xx_request(self): + path = "/500" + with tracer.start_as_current_span("test"): + res = httpx.get(testenv["flask_server"] + path) + + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + wsgi_span = spans[0] + httpx_span = spans[1] + test_span = spans[2] + + assert res + assert res.status_code == 500 + + assert "X-INSTANA-T" in res.headers + assert int(res.headers["X-INSTANA-T"], 16) + assert res.headers["X-INSTANA-T"] == hex_id(wsgi_span.t) + + assert "X-INSTANA-S" in res.headers + assert int(res.headers["X-INSTANA-S"], 16) + assert res.headers["X-INSTANA-S"] == hex_id(wsgi_span.s) + + assert "X-INSTANA-L" in res.headers + assert res.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in res.headers + server_timing_value = f"intid;desc={hex_id(wsgi_span.t)}" + assert res.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == httpx_span.t + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert httpx_span.p == test_span.s + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not test_span.ec + assert httpx_span.ec == 1 + assert wsgi_span.ec == 1 + + # span names + assert wsgi_span.n == "wsgi" + assert test_span.data["sdk"]["name"] == "test" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 500 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + path + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + def test_response_header_capture(self): + original_extra_http_headers = agent.options.extra_http_headers + agent.options.extra_http_headers = ["X-Capture-This", "X-Capture-That"] + path = "/response_headers" + + with tracer.start_as_current_span("test"): + res = httpx.get(testenv["flask_server"] + path) + + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + wsgi_span = spans[0] + httpx_span = spans[1] + test_span = spans[2] + + assert res + assert res.status_code == 200 + + # Same traceId + assert test_span.t == httpx_span.t + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert httpx_span.p == test_span.s + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not test_span.ec + assert not httpx_span.ec + assert not wsgi_span.ec + + # span names + assert wsgi_span.n == "wsgi" + assert test_span.data["sdk"]["name"] == "test" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + path + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + assert "X-Capture-This" in httpx_span.data["http"]["header"] + assert httpx_span.data["http"]["header"]["X-Capture-This"] == "Ok" + assert "X-Capture-That" in httpx_span.data["http"]["header"] + assert httpx_span.data["http"]["header"]["X-Capture-That"] == "Ok too" + + agent.options.extra_http_headers = original_extra_http_headers + + def test_request_header_capture(self): + original_extra_http_headers = agent.options.extra_http_headers + agent.options.extra_http_headers = ["X-Capture-This-Too", "X-Capture-That-Too"] + + request_headers = { + "X-Capture-This-Too": "this too", + "X-Capture-That-Too": "that too", + } + with tracer.start_as_current_span("test"): + res = httpx.get(testenv["flask_server"] + "/", headers=request_headers) + + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + wsgi_span = spans[0] + httpx_span = spans[1] + test_span = spans[2] + + assert res + assert res.status_code == 200 + + # Same traceId + assert test_span.t == httpx_span.t + assert httpx_span.t == wsgi_span.t + + # Parent relationships + assert httpx_span.p == test_span.s + assert wsgi_span.p == httpx_span.s + + # Error logging + assert not test_span.ec + assert not httpx_span.ec + assert not wsgi_span.ec + + # span names + assert wsgi_span.n == "wsgi" + assert test_span.data["sdk"]["name"] == "test" + assert httpx_span.n == "http" + + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.host + assert httpx_span.data["http"]["path"] == "/" + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + "/" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + assert "X-Capture-This-Too" in httpx_span.data["http"]["header"] + assert httpx_span.data["http"]["header"]["X-Capture-This-Too"] == "this too" + assert "X-Capture-That-Too" in httpx_span.data["http"]["header"] + assert httpx_span.data["http"]["header"]["X-Capture-That-Too"] == "that too" + + agent.options.extra_http_headers = original_extra_http_headers From f73ba7e1e8ea9e924f85fbc841c693cbcdf24bd6 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Thu, 26 Jun 2025 12:53:26 +0530 Subject: [PATCH 4/6] feat: Add logic for handling `async` `httpx` requests Signed-off-by: Varsha GS --- src/instana/instrumentation/httpx.py | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/instana/instrumentation/httpx.py b/src/instana/instrumentation/httpx.py index ab42c22b..798f1f07 100644 --- a/src/instana/instrumentation/httpx.py +++ b/src/instana/instrumentation/httpx.py @@ -93,6 +93,35 @@ def handle_request_with_instana( span.record_exception(e) else: return response + + @wrapt.patch_function_wrapper("httpx", "AsyncHTTPTransport.handle_async_request") + async def handle_async_request_with_instana( + wrapped: Callable[..., "httpx.AsyncHTTPTransport.handle_async_request"], + instance: httpx.AsyncHTTPTransport, + args: Tuple[int, str, Tuple[Any, ...]], + kwargs: Dict[str, Any], + ) -> httpx.Response: + # If we're not tracing, just return + if tracing_is_off(): + return await wrapped(*args, **kwargs) + + tracer, parent_span, _ = get_tracer_tuple() + parent_context = parent_span.get_span_context() if parent_span else None + + with tracer.start_as_current_span( + "httpx", span_context=parent_context, kind=SpanKind.CLIENT + ) as span: + try: + request = args[0] + _set_request_span_attributes(span, request) + tracer.inject(span.context, Format.HTTP_HEADERS, request.headers) + + response = await wrapped(*args, **kwargs) + _set_response_span_attributes(span, response) + except Exception as e: + span.record_exception(e) + else: + return response logger.debug("Instrumenting httpx") except ImportError: From d3cbd9b2f06fde0fe05349aae1bb3fae4f6ceffa Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Mon, 30 Jun 2025 12:12:05 +0530 Subject: [PATCH 5/6] tests: Adapt `sanic` tests after `httpx` instrumentation changes Signed-off-by: Varsha GS --- tests/frameworks/test_sanic.py | 461 ++++++++++++++++++--------------- tests/helpers.py | 6 + 2 files changed, 255 insertions(+), 212 deletions(-) diff --git a/tests/frameworks/test_sanic.py b/tests/frameworks/test_sanic.py index 4550415d..31b98a49 100644 --- a/tests/frameworks/test_sanic.py +++ b/tests/frameworks/test_sanic.py @@ -7,7 +7,7 @@ from instana.singletons import tracer, agent from instana.util.ids import hex_id -from tests.helpers import get_first_span_by_filter +from tests.helpers import get_first_span_by_filter, get_first_span_by_name, is_test_span from tests.test_utils import _TraceContextMixin from tests.apps.sanic_app.server import app @@ -16,6 +16,7 @@ class TestSanic(_TraceContextMixin): @classmethod def setup_class(cls) -> None: cls.client = SanicTestClient(app, port=1337, host="127.0.0.1") + cls.endpoint = f"{cls.client.host}:{cls.client.port}" # Hack together a manual custom headers list; We'll use this in tests agent.options.extra_http_headers = [ @@ -47,32 +48,26 @@ def test_vanilla_get(self) -> None: assert spans[0].n == "asgi" def test_basic_get(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/", headers=headers) + path = "/" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 200 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -83,42 +78,47 @@ def test_basic_get(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/" - assert asgi_span.data["http"]["path_tpl"] == "/" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 200 assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] def test_404(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/foo/not_an_int", headers=headers) + path = "/foo/not_an_int" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 404 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -129,9 +129,20 @@ def test_404(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 404 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/foo/not_an_int" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path assert not asgi_span.data["http"]["path_tpl"] assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 404 @@ -139,32 +150,26 @@ def test_404(self) -> None: assert not asgi_span.data["http"]["params"] def test_sanic_exception(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/wrong", headers=headers) + path = "/wrong" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 400 spans = self.recorder.queued_spans() - assert len(spans) == 3 + assert len(spans) == 4 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -175,42 +180,47 @@ def test_sanic_exception(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 400 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/wrong" - assert asgi_span.data["http"]["path_tpl"] == "/wrong" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 400 assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] def test_500_instana_exception(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/instana_exception", headers=headers) + path = "/instana_exception" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 500 spans = self.recorder.queued_spans() - assert len(spans) == 3 + assert len(spans) == 4 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -221,42 +231,47 @@ def test_500_instana_exception(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 500 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert asgi_span.ec == 1 - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/instana_exception" - assert asgi_span.data["http"]["path_tpl"] == "/instana_exception" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 500 assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] def test_500(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/test_request_args", headers=headers) + path = "/test_request_args" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 500 spans = self.recorder.queued_spans() - assert len(spans) == 3 + assert len(spans) == 4 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -267,42 +282,47 @@ def test_500(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 500 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert asgi_span.ec == 1 - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/test_request_args" - assert asgi_span.data["http"]["path_tpl"] == "/test_request_args" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 500 assert asgi_span.data["http"]["error"] == "Something went wrong." assert not asgi_span.data["http"]["params"] def test_path_templates(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/foo/1", headers=headers) + path = "/foo/1" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 200 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -313,9 +333,20 @@ def test_path_templates(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/foo/1" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path assert asgi_span.data["http"]["path_tpl"] == "/foo/" assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 200 @@ -323,32 +354,26 @@ def test_path_templates(self) -> None: assert not asgi_span.data["http"]["params"] def test_secret_scrubbing(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/?secret=shhh", headers=headers) + path = "/" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path+"?secret=shhh") assert response.status_code == 200 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -359,43 +384,50 @@ def test_secret_scrubbing(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/" - assert asgi_span.data["http"]["path_tpl"] == "/" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 200 assert not asgi_span.data["http"]["error"] assert asgi_span.data["http"]["params"] == "secret=" def test_synthetic_request(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() + path = "/" + with tracer.start_as_current_span("test"): headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), "X-INSTANA-SYNTHETIC": "1", } - request, response = self.client.get("/", headers=headers) + request, response = self.client.get(path, headers=headers) assert response.status_code == 200 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) assert "X-INSTANA-T" in response.headers assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) @@ -406,61 +438,71 @@ def test_synthetic_request(self) -> None: assert "Server-Timing" in response.headers assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/" - assert asgi_span.data["http"]["path_tpl"] == "/" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 200 assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] assert asgi_span.sy + assert not httpx_span.sy assert not test_span.sy def test_request_header_capture(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() + path = "/" + with tracer.start_as_current_span("test"): headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), "X-Capture-This": "this", "X-Capture-That": "that", } - request, response = self.client.get("/", headers=headers) + request, response = self.client.get(path, headers=headers) assert response.status_code == 200 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) - assert "X-INSTANA-T" in response.headers - assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) - assert "X-INSTANA-S" in response.headers - assert response.headers["X-INSTANA-S"] == hex_id(asgi_span.s) - assert "X-INSTANA-L" in response.headers - assert response.headers["X-INSTANA-L"] == "1" - assert "Server-Timing" in response.headers - assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/" - assert asgi_span.data["http"]["path_tpl"] == "/" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 200 assert not asgi_span.data["http"]["error"] @@ -472,49 +514,44 @@ def test_request_header_capture(self) -> None: assert "that" == asgi_span.data["http"]["header"]["X-Capture-That"] def test_response_header_capture(self) -> None: - with tracer.start_as_current_span("test") as span: - # As SanicTestClient() is based on httpx, and we don't support it yet, - # we must pass the SDK trace_id and span_id to the sanic server. - span_context = span.get_span_context() - headers = { - "X-INSTANA-T": hex_id(span_context.trace_id), - "X-INSTANA-S": hex_id(span_context.span_id), - } - request, response = self.client.get("/response_headers", headers=headers) + path = "/response_headers" + with tracer.start_as_current_span("test"): + request, response = self.client.get(path) assert response.status_code == 200 spans = self.recorder.queued_spans() - assert len(spans) == 2 + assert len(spans) == 3 - span_filter = ( - lambda span: span.n == "sdk" and span.data["sdk"]["name"] == "test" - ) - test_span = get_first_span_by_filter(spans, span_filter) + test_span = get_first_span_by_filter(spans, is_test_span) assert test_span - span_filter = lambda span: span.n == "asgi" - asgi_span = get_first_span_by_filter(spans, span_filter) + httpx_span = get_first_span_by_name(spans, "http") + assert httpx_span + + asgi_span = get_first_span_by_name(spans, "asgi") assert asgi_span - self.assertTraceContextPropagated(test_span, asgi_span) + self.assertTraceContextPropagated(test_span, httpx_span) + self.assertTraceContextPropagated(httpx_span, asgi_span) - assert "X-INSTANA-T" in response.headers - assert response.headers["X-INSTANA-T"] == hex_id(asgi_span.t) - assert "X-INSTANA-S" in response.headers - assert response.headers["X-INSTANA-S"] == hex_id(asgi_span.s) - assert "X-INSTANA-L" in response.headers - assert response.headers["X-INSTANA-L"] == "1" - assert "Server-Timing" in response.headers - assert response.headers["Server-Timing"] == f"intid;desc={hex_id(asgi_span.t)}" + # httpx + assert httpx_span.data["http"]["status"] == 200 + assert httpx_span.data["http"]["host"] == self.client.host + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == f"http://{self.endpoint}{path}" + assert httpx_span.data["http"]["method"] == "GET" + assert httpx_span.stack + assert isinstance(httpx_span.stack, list) + assert len(httpx_span.stack) > 1 + # sanic assert not asgi_span.ec - assert asgi_span.data["http"]["host"] == "127.0.0.1:1337" - assert asgi_span.data["http"]["path"] == "/response_headers" - assert asgi_span.data["http"]["path_tpl"] == "/response_headers" + assert asgi_span.data["http"]["host"] == self.endpoint + assert asgi_span.data["http"]["path"] == path + assert asgi_span.data["http"]["path_tpl"] == path assert asgi_span.data["http"]["method"] == "GET" assert asgi_span.data["http"]["status"] == 200 - assert not asgi_span.data["http"]["error"] assert not asgi_span.data["http"]["params"] diff --git a/tests/helpers.py b/tests/helpers.py index 622875d5..850ba59b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -108,6 +108,12 @@ def fail_with_message_and_span_dump(msg, spans): pytest.fail(msg + span_dump, True) +def is_test_span(span): + """ + return the filter for test span + """ + return span.n == "sdk" and span.data["sdk"]["name"] == "test" + def get_first_span_by_name(spans, name): """ Get the first span in that has a span.n value of From f57857b817a6be5ad5a1bc197bd4638dbdca8920 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Tue, 1 Jul 2025 12:27:52 +0530 Subject: [PATCH 6/6] tests(httpx): Add tests for `async` requests Signed-off-by: Varsha GS --- src/instana/instrumentation/httpx.py | 5 +- tests/clients/test_httpx.py | 97 +++++++++++++++++++++------- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/src/instana/instrumentation/httpx.py b/src/instana/instrumentation/httpx.py index 798f1f07..3c25b814 100644 --- a/src/instana/instrumentation/httpx.py +++ b/src/instana/instrumentation/httpx.py @@ -35,7 +35,7 @@ def _set_request_span_attributes( agent.options.secrets_list, ) span.set_attribute("http.params", formatted_query) - + url_str = f"{url.scheme}://{url.host}" if url.port: url_str += f":{url.port}" @@ -93,7 +93,7 @@ def handle_request_with_instana( span.record_exception(e) else: return response - + @wrapt.patch_function_wrapper("httpx", "AsyncHTTPTransport.handle_async_request") async def handle_async_request_with_instana( wrapped: Callable[..., "httpx.AsyncHTTPTransport.handle_async_request"], @@ -124,5 +124,6 @@ async def handle_async_request_with_instana( return response logger.debug("Instrumenting httpx") + except ImportError: pass diff --git a/tests/clients/test_httpx.py b/tests/clients/test_httpx.py index 227a5167..db14c892 100644 --- a/tests/clients/test_httpx.py +++ b/tests/clients/test_httpx.py @@ -3,6 +3,7 @@ import pytest import httpx from typing import Generator +import asyncio from instana.singletons import agent, tracer from instana.util.ids import hex_id @@ -10,23 +11,70 @@ from tests.helpers import testenv -class TestHttpx: +@pytest.mark.parametrize("request_mode", ["sync", "async"]) +class TestHttpxClients: + @classmethod + def setup_class(cls) -> None: + cls.client = httpx.Client() + cls.host = "127.0.0.1" + cls.recorder = tracer.span_processor + + def teardown_class(cls) -> None: + cls.client.close() + @pytest.fixture(autouse=True) - def _setup(self) -> Generator[None, None, None]: + def _resource(self, request_mode) -> Generator[None, None, None]: """SetUp and TearDown""" # setup # Clear all spans before a test run - self.host = "127.0.0.1" - self.recorder = tracer.span_processor self.recorder.clear_spans() + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(None) yield # teardown + if self.loop.is_running(): + self.loop.close() # Ensure that allow_exit_as_root has the default value agent.options.allow_exit_as_root = False - def test_get_request(self): + async def get_async_response(self, path, request_method, headers) -> httpx.Response: + """Asynchronous request function""" + async with httpx.AsyncClient() as client: + if request_method == "GET": + response = await client.get( + testenv["flask_server"] + path, headers=headers + ) + elif request_method == "POST": + response = await client.post( + testenv["flask_server"] + path, headers=headers + ) + return response + + # Synchronous request function + def get_sync_response(self, path, request_method, headers) -> httpx.Response: + """Synchronous request function""" + if request_method == "GET": + response = self.client.get(testenv["flask_server"] + path, headers=headers) + elif request_method == "POST": + response = self.client.post(testenv["flask_server"] + path, headers=headers) + return response + + def execute_request( + self, request_mode, path, request_method="GET", headers=None + ) -> httpx.Response: + if request_mode == "async": + res = self.loop.run_until_complete( + self.get_async_response(path, request_method, headers) + ) + elif request_mode == "sync": + res = self.get_sync_response(path, request_method, headers) + return res + + def test_get_request(self, request_mode) -> None: + path = "/" with tracer.start_as_current_span("test"): - res = httpx.get(testenv["flask_server"] + "/") + res = self.execute_request(request_mode, path) spans = self.recorder.queued_spans() assert len(spans) == 3 @@ -81,9 +129,10 @@ def test_get_request(self): assert isinstance(httpx_span.stack, list) assert len(httpx_span.stack) > 1 - def test_get_request_as_root_exit_span(self): + def test_get_request_as_root_exit_span(self, request_mode) -> None: + path = "/" agent.options.allow_exit_as_root = True - res = httpx.get(testenv["flask_server"] + "/") + res = self.execute_request(request_mode, path) spans = self.recorder.queued_spans() assert len(spans) == 2 @@ -127,16 +176,19 @@ def test_get_request_as_root_exit_span(self): # httpx assert httpx_span.data["http"]["status"] == 200 assert httpx_span.data["http"]["host"] == self.host - assert httpx_span.data["http"]["path"] == "/" - assert httpx_span.data["http"]["url"] == testenv["flask_server"] + "/" + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + path assert httpx_span.data["http"]["method"] == "GET" assert httpx_span.stack assert isinstance(httpx_span.stack, list) assert len(httpx_span.stack) > 1 - def test_get_request_with_query(self): + def test_get_request_with_query(self, request_mode) -> None: + path = "/" with tracer.start_as_current_span("test"): - res = httpx.get(testenv["flask_server"] + "/?user=instana&pass=itsasecret") + res = self.execute_request( + request_mode, path + "?user=instana&pass=itsasecret" + ) spans = self.recorder.queued_spans() assert len(spans) == 3 @@ -169,18 +221,18 @@ def test_get_request_with_query(self): # httpx assert httpx_span.data["http"]["status"] == 200 assert httpx_span.data["http"]["host"] == self.host - assert httpx_span.data["http"]["path"] == "/" - assert httpx_span.data["http"]["url"] == testenv["flask_server"] + "/" + assert httpx_span.data["http"]["path"] == path + assert httpx_span.data["http"]["url"] == testenv["flask_server"] + path assert httpx_span.data["http"]["params"] == "user=instana&pass=" assert httpx_span.data["http"]["method"] == "GET" assert httpx_span.stack assert isinstance(httpx_span.stack, list) assert len(httpx_span.stack) > 1 - def test_post_request(self): + def test_post_request(self, request_mode) -> None: path = "/notfound" with tracer.start_as_current_span("test"): - res = httpx.post(testenv["flask_server"] + "/notfound") + res = self.execute_request(request_mode, path, request_method="POST") spans = self.recorder.queued_spans() assert len(spans) == 3 @@ -220,10 +272,10 @@ def test_post_request(self): assert isinstance(httpx_span.stack, list) assert len(httpx_span.stack) > 1 - def test_5xx_request(self): + def test_5xx_request(self, request_mode) -> None: path = "/500" with tracer.start_as_current_span("test"): - res = httpx.get(testenv["flask_server"] + path) + res = self.execute_request(request_mode, path) spans = self.recorder.queued_spans() assert len(spans) == 3 @@ -278,13 +330,13 @@ def test_5xx_request(self): assert isinstance(httpx_span.stack, list) assert len(httpx_span.stack) > 1 - def test_response_header_capture(self): + def test_response_header_capture(self, request_mode) -> None: original_extra_http_headers = agent.options.extra_http_headers agent.options.extra_http_headers = ["X-Capture-This", "X-Capture-That"] path = "/response_headers" with tracer.start_as_current_span("test"): - res = httpx.get(testenv["flask_server"] + path) + res = self.execute_request(request_mode, path) spans = self.recorder.queued_spans() assert len(spans) == 3 @@ -331,7 +383,8 @@ def test_response_header_capture(self): agent.options.extra_http_headers = original_extra_http_headers - def test_request_header_capture(self): + def test_request_header_capture(self, request_mode) -> None: + path = "/" original_extra_http_headers = agent.options.extra_http_headers agent.options.extra_http_headers = ["X-Capture-This-Too", "X-Capture-That-Too"] @@ -340,7 +393,7 @@ def test_request_header_capture(self): "X-Capture-That-Too": "that too", } with tracer.start_as_current_span("test"): - res = httpx.get(testenv["flask_server"] + "/", headers=request_headers) + res = self.execute_request(request_mode, path, headers=request_headers) spans = self.recorder.queued_spans() assert len(spans) == 3