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..3c25b814 --- /dev/null +++ b/src/instana/instrumentation/httpx.py @@ -0,0 +1,129 @@ +# (c) Copyright IBM Corp. 2025 + +try: + 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 + + 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_request_span_attributes( + span: "InstanaSpan", + request: httpx.Request, + ) -> None: + try: + url = request.url + + # 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}" + + 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) + + extract_custom_headers(span, request.headers) + except Exception: + logger.debug("httpx _set_request_span_attributes error: ", exc_info=True) + + def _set_response_span_attributes( + span: "InstanaSpan", + response: Optional[httpx.Response] = None, + ) -> None: + try: + if response.headers: + 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 _set_request_span_attributes error: ", exc_info=True) + + @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, _ = 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 = wrapped(*args, **kwargs) + _set_response_span_attributes(span, response) + except Exception as e: + 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: + 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: diff --git a/tests/clients/test_httpx.py b/tests/clients/test_httpx.py new file mode 100644 index 00000000..db14c892 --- /dev/null +++ b/tests/clients/test_httpx.py @@ -0,0 +1,441 @@ +# (c) Copyright IBM Corp. 2025 + +import pytest +import httpx +from typing import Generator +import asyncio + +from instana.singletons import agent, tracer +from instana.util.ids import hex_id +import tests.apps.flask_app +from tests.helpers import testenv + + +@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 _resource(self, request_mode) -> Generator[None, None, None]: + """SetUp and TearDown""" + # setup + # Clear all spans before a test run + 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 + + 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 = self.execute_request(request_mode, 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 + + 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, request_mode) -> None: + path = "/" + agent.options.allow_exit_as_root = True + res = self.execute_request(request_mode, path) + + 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"] == 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, request_mode) -> None: + path = "/" + with tracer.start_as_current_span("test"): + res = self.execute_request( + request_mode, path + "?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"] == 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, request_mode) -> None: + path = "/notfound" + with tracer.start_as_current_span("test"): + res = self.execute_request(request_mode, path, request_method="POST") + + 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, request_mode) -> None: + path = "/500" + with tracer.start_as_current_span("test"): + res = self.execute_request(request_mode, 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, 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 = self.execute_request(request_mode, 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, 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"] + + request_headers = { + "X-Capture-This-Too": "this too", + "X-Capture-That-Too": "that too", + } + with tracer.start_as_current_span("test"): + res = self.execute_request(request_mode, path, 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 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