From 2ab9c41942baec3d1a1b33b2bb7fac23e62eeb57 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Mon, 3 Mar 2025 14:33:28 +0530 Subject: [PATCH 1/9] feat: Add instrumentation to Spyne Signed-off-by: Varsha GS --- src/instana/__init__.py | 1 + src/instana/instrumentation/spyne.py | 69 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/instana/instrumentation/spyne.py diff --git a/src/instana/__init__.py b/src/instana/__init__.py index 5f26bc5a..f46e5461 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -188,6 +188,7 @@ def boot_agent() -> None: sqlalchemy, # noqa: F401 starlette, # noqa: F401 urllib3, # noqa: F401 + spyne, ) from instana.instrumentation.aiohttp import ( client as aiohttp_client, # noqa: F401 diff --git a/src/instana/instrumentation/spyne.py b/src/instana/instrumentation/spyne.py new file mode 100644 index 00000000..94f46aca --- /dev/null +++ b/src/instana/instrumentation/spyne.py @@ -0,0 +1,69 @@ +# (c) Copyright IBM Corp. 2025 + +try: + import spyne + import wrapt + + from opentelemetry.semconv.trace import SpanAttributes + + from instana.log import logger + from instana.singletons import agent, tracer + from instana.propagators.format import Format + from instana.util.secrets import strip_secrets_from_query + + + @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication._WsgiApplication__finalize") + def finalize_with_instana(wrapped, instance, args, kwargs): + ctx = args[0] + span = ctx.udc + if span: + resp_code = int(ctx.transport.resp_code.split()[0]) + + if 500 <= resp_code: + span.mark_as_errored() + + span.set_attribute( + SpanAttributes.HTTP_STATUS_CODE, int(resp_code) + ) + if span.is_recording(): + span.end() + + ctx.udc = None + return wrapped(*args, **kwargs) + + + @wrapt.patch_function_wrapper("spyne.application", "Application.process_request") + def process_request_with_instana(wrapped, instance, args, kwargs): + ctx = args[0] + headers = ctx.in_document + span_context = tracer.extract(Format.HTTP_HEADERS, headers) + + with tracer.start_as_current_span( + "spyne", span_context=span_context, end_on_exit=False, + ) as span: + if "REQUEST_METHOD" in headers: + span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) + if "PATH_INFO" in headers: + span.set_attribute(SpanAttributes.HTTP_URL, headers["PATH_INFO"]) + if "QUERY_STRING" in headers and len(headers["QUERY_STRING"]): + scrubbed_params = strip_secrets_from_query( + headers["QUERY_STRING"], + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + span.set_attribute("http.params", scrubbed_params) + if "HTTP_HOST" in headers: + span.set_attribute("http.host", headers["HTTP_HOST"]) + + response = wrapped(*args, **kwargs) + ctx = args[0] + tracer.inject(span.context, Format.HTTP_HEADERS, ctx.transport.resp_headers) + + ## Store the span in the user defined context object offered by Spyne + ctx.udc = span + return response + + logger.debug("Instrumenting Spyne") + +except ImportError: + pass From 41a3338aaf14b2972c5c8787de4480e595ccd942 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Mon, 3 Mar 2025 14:38:38 +0530 Subject: [PATCH 2/9] test(spyne): Added initial tests for spyne Signed-off-by: Varsha GS --- tests/apps/spyne_app/__init__.py | 10 +++ tests/apps/spyne_app/app.py | 72 +++++++++++++++ tests/frameworks/test_spyne.py | 149 +++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 tests/apps/spyne_app/__init__.py create mode 100644 tests/apps/spyne_app/app.py create mode 100644 tests/frameworks/test_spyne.py diff --git a/tests/apps/spyne_app/__init__.py b/tests/apps/spyne_app/__init__.py new file mode 100644 index 00000000..446a4a56 --- /dev/null +++ b/tests/apps/spyne_app/__init__.py @@ -0,0 +1,10 @@ +# (c) Copyright IBM Corp. 2025 + +import os +from tests.apps.spyne_app.app import spyne_server as server +from tests.apps.utils import launch_background_thread + +app_thread = None + +if not os.environ.get('CASSANDRA_TEST') and app_thread is None: + app_thread = launch_background_thread(server.serve_forever, "Spyne") diff --git a/tests/apps/spyne_app/app.py b/tests/apps/spyne_app/app.py new file mode 100644 index 00000000..56ecc722 --- /dev/null +++ b/tests/apps/spyne_app/app.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) Copyright IBM Corp. 2025 + +import logging + +from wsgiref.simple_server import make_server +from spyne import Application, rpc, ServiceBase, Iterable, UnsignedInteger, \ + String, Unicode, M, UnsignedInteger32 + +from spyne.protocol.json import JsonDocument +from spyne.protocol.http import HttpRpc +from spyne.server.wsgi import WsgiApplication + +from spyne.error import ResourceNotFoundError + +from tests.helpers import testenv + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger(__name__) + +testenv["spyne_port"] = 10818 +testenv["spyne_server"] = ("http://127.0.0.1:" + str(testenv["spyne_port"])) + +class HelloWorldService(ServiceBase): + # @rpc(Unicode, _returns=Unicode) + # def get_resource(self, resource_id): + # # Simulate checking for a resource + # if resource_id != "existing_resource": + # raise ResourceNotFoundError( + # "Resource not found", + # "The requested resource does not exist." + # ) + # return f"Resource {resource_id} found." + + @rpc(String, UnsignedInteger, _returns=Iterable(String)) + def say_hello(ctx, name, times): + """ + Docstrings for service methods do appear as documentation in the + interface documents. What fun! + + :param name: The name to say hello to + :param times: The number of times to say hello + + :returns: An array of 'Hello, ' strings, repeated times. + """ + + for i in range(times): + yield 'Hello, %s' % name + + @rpc(_returns=Unicode) + def hello(self): + return "

🐍 Hello Stan! 🦄

" + + @rpc(M(UnsignedInteger32)) + def del_user(ctx, user_id): + raise ResourceNotFoundError(user_id) + + +application = Application([HelloWorldService], 'spyne.examples.hello.http', + in_protocol=HttpRpc(validator='soft'), + out_protocol=JsonDocument(ignore_wrappers=True), +) +wsgi_app = WsgiApplication(application) +spyne_server = make_server('127.0.0.1', testenv["spyne_port"], wsgi_app) + +if __name__ == '__main__': + # logging.info("listening to http://127.0.0.1:8000") + # logging.info("wsdl is at: http://localhost:8000/?wsdl") + spyne_server.request_queue_size = 20 + spyne_server.serve_forever() diff --git a/tests/frameworks/test_spyne.py b/tests/frameworks/test_spyne.py new file mode 100644 index 00000000..23e748d9 --- /dev/null +++ b/tests/frameworks/test_spyne.py @@ -0,0 +1,149 @@ +# (c) Copyright IBM Corp. 2025 + +import time +import urllib3 +import pytest +from typing import Generator + +from tests.apps import spyne_app +from tests.helpers import testenv +from instana.singletons import agent, tracer +from instana.span.span import get_current_span +from instana.util.ids import hex_id + + +class TestSpyne: + @pytest.fixture(autouse=True) + def _resource(self) -> Generator[None, None, None]: + """Clear all spans before a test run""" + self.http = urllib3.PoolManager() + self.recorder = tracer.span_processor + self.recorder.clear_spans() + time.sleep(0.1) + + def test_vanilla_requests(self) -> None: + response = self.http.request("GET", testenv["spyne_server"] + "/hello") + spans = self.recorder.queued_spans() + + assert len(spans) == 1 + assert get_current_span().is_recording() is False + assert response.status == 200 + + def test_get_request(self) -> None: + with tracer.start_as_current_span("test"): + response = self.http.request("GET", testenv["spyne_server"] + "/hello") + + spans = self.recorder.queued_spans() + + assert len(spans) == 3 + assert get_current_span().is_recording() is False + + spyne_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + assert 200 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec is None + assert spyne_span.ec is None + + # wsgi + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/hello" == spyne_span.data["http"]["url"] + assert "GET" == spyne_span.data["http"]["method"] + assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None + + def test_secret_scrubbing(self) -> None: + with tracer.start_as_current_span("test"): + response = self.http.request("GET", testenv["spyne_server"] + "/say_hello?name=World×=4&secret=sshhh") + + spans = self.recorder.queued_spans() + + assert len(spans) == 3 + assert get_current_span().is_recording() is False + + spyne_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + assert 200 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec is None + assert spyne_span.ec is None + + # wsgi + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/say_hello" == spyne_span.data["http"]["url"] + assert spyne_span.data["http"]["params"] == "name=World×=4&secret=" + assert "GET" == spyne_span.data["http"]["method"] + assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None From 01b120eae58ca975787ff7847281d9697646f9ab Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Thu, 6 Mar 2025 14:27:11 +0530 Subject: [PATCH 3/9] instrumentation(spyne): Handle errors and support custom headers Signed-off-by: Varsha GS --- src/instana/instrumentation/spyne.py | 63 +++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/instana/instrumentation/spyne.py b/src/instana/instrumentation/spyne.py index 94f46aca..c58a42fd 100644 --- a/src/instana/instrumentation/spyne.py +++ b/src/instana/instrumentation/spyne.py @@ -10,13 +10,62 @@ from instana.singletons import agent, tracer from instana.propagators.format import Format from instana.util.secrets import strip_secrets_from_query + from instana.util.traceutils import extract_custom_headers + + + @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication.handle_error") + def handle_error_with_instana(wrapped, instance, args, kwargs): + ctx = args[0] + span = ctx.udc + if span: + return wrapped(*args, **kwargs) + + headers = ctx.in_document + span_context = tracer.extract(Format.HTTP_HEADERS, headers) + + with tracer.start_as_current_span( + "spyne", span_context=span_context + ) as span: + extract_custom_headers(span, headers, format=True) + + if "REQUEST_METHOD" in headers: + span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) + if "PATH_INFO" in headers: + span.set_attribute(SpanAttributes.HTTP_URL, headers["PATH_INFO"]) + if "QUERY_STRING" in headers and len(headers["QUERY_STRING"]): + scrubbed_params = strip_secrets_from_query( + headers["QUERY_STRING"], + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + span.set_attribute("http.params", scrubbed_params) + if "HTTP_HOST" in headers: + span.set_attribute("http.host", headers["HTTP_HOST"]) + + response_headers = ctx.transport.resp_headers + + extract_custom_headers(span, response_headers, format=False) + tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) + + response = wrapped(*args, **kwargs) + + resp_code = int(ctx.transport.resp_code.split()[0]) + + if 500 <= resp_code: + span.mark_as_errored() + + span.set_attribute( + SpanAttributes.HTTP_STATUS_CODE, int(resp_code) + ) + return response + - @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication._WsgiApplication__finalize") def finalize_with_instana(wrapped, instance, args, kwargs): ctx = args[0] span = ctx.udc - if span: + + if span and ctx.transport.resp_code: resp_code = int(ctx.transport.resp_code.split()[0]) if 500 <= resp_code: @@ -28,7 +77,7 @@ def finalize_with_instana(wrapped, instance, args, kwargs): if span.is_recording(): span.end() - ctx.udc = None + ctx.udc = None return wrapped(*args, **kwargs) @@ -41,6 +90,8 @@ def process_request_with_instana(wrapped, instance, args, kwargs): with tracer.start_as_current_span( "spyne", span_context=span_context, end_on_exit=False, ) as span: + extract_custom_headers(span, headers, format=True) + if "REQUEST_METHOD" in headers: span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) if "PATH_INFO" in headers: @@ -56,8 +107,10 @@ def process_request_with_instana(wrapped, instance, args, kwargs): span.set_attribute("http.host", headers["HTTP_HOST"]) response = wrapped(*args, **kwargs) - ctx = args[0] - tracer.inject(span.context, Format.HTTP_HEADERS, ctx.transport.resp_headers) + response_headers = ctx.transport.resp_headers + + extract_custom_headers(span, response_headers, format=False) + tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) ## Store the span in the user defined context object offered by Spyne ctx.udc = span From 087b01b7540f6496e7c49644d741e3e1f07c3e9b Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Thu, 6 Mar 2025 14:28:06 +0530 Subject: [PATCH 4/9] test(spyne): Add tests for error handling and custom headers Signed-off-by: Varsha GS --- tests/apps/spyne_app/app.py | 33 ++- tests/frameworks/test_spyne.py | 353 ++++++++++++++++++++++++++++++++- 2 files changed, 365 insertions(+), 21 deletions(-) diff --git a/tests/apps/spyne_app/app.py b/tests/apps/spyne_app/app.py index 56ecc722..985861ca 100644 --- a/tests/apps/spyne_app/app.py +++ b/tests/apps/spyne_app/app.py @@ -7,7 +7,7 @@ from wsgiref.simple_server import make_server from spyne import Application, rpc, ServiceBase, Iterable, UnsignedInteger, \ - String, Unicode, M, UnsignedInteger32 + String, Unicode from spyne.protocol.json import JsonDocument from spyne.protocol.http import HttpRpc @@ -24,22 +24,9 @@ testenv["spyne_server"] = ("http://127.0.0.1:" + str(testenv["spyne_port"])) class HelloWorldService(ServiceBase): - # @rpc(Unicode, _returns=Unicode) - # def get_resource(self, resource_id): - # # Simulate checking for a resource - # if resource_id != "existing_resource": - # raise ResourceNotFoundError( - # "Resource not found", - # "The requested resource does not exist." - # ) - # return f"Resource {resource_id} found." - @rpc(String, UnsignedInteger, _returns=Iterable(String)) def say_hello(ctx, name, times): """ - Docstrings for service methods do appear as documentation in the - interface documents. What fun! - :param name: The name to say hello to :param times: The number of times to say hello @@ -50,12 +37,22 @@ def say_hello(ctx, name, times): yield 'Hello, %s' % name @rpc(_returns=Unicode) - def hello(self): + def hello(ctx): return "

🐍 Hello Stan! 🦄

" - @rpc(M(UnsignedInteger32)) - def del_user(ctx, user_id): + @rpc(_returns=Unicode) + def response_headers(ctx): + ctx.transport.add_header("X-Capture-This", "this") + ctx.transport.add_header("X-Capture-That", "that") + return "Stan wuz here with headers!" + + @rpc(UnsignedInteger) + def custom_404(ctx, user_id): raise ResourceNotFoundError(user_id) + + @rpc() + def exception(ctx): + raise Exception('fake error') application = Application([HelloWorldService], 'spyne.examples.hello.http', @@ -66,7 +63,5 @@ def del_user(ctx, user_id): spyne_server = make_server('127.0.0.1', testenv["spyne_port"], wsgi_app) if __name__ == '__main__': - # logging.info("listening to http://127.0.0.1:8000") - # logging.info("wsdl is at: http://localhost:8000/?wsdl") spyne_server.request_queue_size = 20 spyne_server.serve_forever() diff --git a/tests/frameworks/test_spyne.py b/tests/frameworks/test_spyne.py index 23e748d9..3ce281fa 100644 --- a/tests/frameworks/test_spyne.py +++ b/tests/frameworks/test_spyne.py @@ -77,7 +77,7 @@ def test_get_request(self) -> None: assert urllib3_span.ec is None assert spyne_span.ec is None - # wsgi + # spyne assert "spyne" == spyne_span.n assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] @@ -136,7 +136,7 @@ def test_secret_scrubbing(self) -> None: assert urllib3_span.ec is None assert spyne_span.ec is None - # wsgi + # spyne assert "spyne" == spyne_span.n assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] @@ -147,3 +147,352 @@ def test_secret_scrubbing(self) -> None: assert 200 == spyne_span.data["http"]["status"] assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None + + def test_request_header_capture(self) -> None: + # Hack together a manual custom headers list + 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"): + response = self.http.request("GET", testenv["spyne_server"] + "/hello", headers=request_headers) + + spans = self.recorder.queued_spans() + + assert len(spans) == 3 + + spyne_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert 200 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec is None + assert spyne_span.ec is None + + # spyne + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/hello" == spyne_span.data["http"]["url"] + assert "GET" == spyne_span.data["http"]["method"] + assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None + + # custom headers + assert "X-Capture-This-Too" in spyne_span.data["http"]["header"] + assert spyne_span.data["http"]["header"]["X-Capture-This-Too"] == "this too" + assert "X-Capture-That-Too" in spyne_span.data["http"]["header"] + assert spyne_span.data["http"]["header"]["X-Capture-That-Too"] == "that too" + + agent.options.extra_http_headers = original_extra_http_headers + + def test_response_header_capture(self) -> None: + # Hack together a manual custom headers list + original_extra_http_headers = agent.options.extra_http_headers + agent.options.extra_http_headers = ["X-Capture-This", "X-Capture-That"] + + with tracer.start_as_current_span("test"): + response = self.http.request("GET", testenv["spyne_server"] + "/response_headers") + + spans = self.recorder.queued_spans() + + assert len(spans) == 3 + + spyne_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert 200 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + # Synthetic + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec is None + assert spyne_span.ec is None + + # spyne + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/response_headers" == spyne_span.data["http"]["url"] + assert "GET" == spyne_span.data["http"]["method"] + assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None + + # custom headers + assert "X-Capture-This" in spyne_span.data["http"]["header"] + assert spyne_span.data["http"]["header"]["X-Capture-This"] == "this" + assert "X-Capture-That" in spyne_span.data["http"]["header"] + assert spyne_span.data["http"]["header"]["X-Capture-That"] == "that" + + agent.options.extra_http_headers = original_extra_http_headers + + def test_custom_404(self) -> None: + with tracer.start_as_current_span("test"): + response = self.http.request("GET", testenv["spyne_server"] + "/custom_404?user_id=9876") + + spans = self.recorder.queued_spans() + + assert len(spans) == 4 + assert get_current_span().is_recording() is False + + log_span = spans[0] + spyne_span = spans[1] + urllib3_span = spans[2] + test_span = spans[3] + + assert response + assert 404 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + # Synthetic + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec is None + assert spyne_span.ec is None + + # spyne + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/custom_404" == spyne_span.data["http"]["url"] + assert "GET" == spyne_span.data["http"]["method"] + assert 404 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None + + # urllib3 + assert "test" == test_span.data["sdk"]["name"] + assert "urllib3" == urllib3_span.n + assert 404 == urllib3_span.data["http"]["status"] + assert ( + testenv["spyne_server"] + "/custom_404" == urllib3_span.data["http"]["url"] + ) + assert "GET" == urllib3_span.data["http"]["method"] + assert urllib3_span.stack is not None + assert type(urllib3_span.stack) is list + assert len(urllib3_span.stack) > 1 + + def test_404(self) -> None: + with tracer.start_as_current_span("test"): + response = self.http.request("GET", testenv["spyne_server"] + "/11111") + + spans = self.recorder.queued_spans() + + assert len(spans) == 3 + assert get_current_span().is_recording() is False + + spyne_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + assert 404 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + # Synthetic + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec is None + assert spyne_span.ec is None + + # spyne + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/11111" == spyne_span.data["http"]["url"] + assert "GET" == spyne_span.data["http"]["method"] + assert 404 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None + + # urllib3 + assert "test" == test_span.data["sdk"]["name"] + assert "urllib3" == urllib3_span.n + assert 404 == urllib3_span.data["http"]["status"] + assert ( + testenv["spyne_server"] + "/11111" == urllib3_span.data["http"]["url"] + ) + assert "GET" == urllib3_span.data["http"]["method"] + assert urllib3_span.stack is not None + assert type(urllib3_span.stack) is list + assert len(urllib3_span.stack) > 1 + + def test_500(self) -> None: + with tracer.start_as_current_span("test"): + response = self.http.request("GET", testenv["spyne_server"] + "/exception") + + spans = self.recorder.queued_spans() + + assert len(spans) == 4 + assert get_current_span().is_recording() is False + + log_span = spans[0] + spyne_span = spans[1] + urllib3_span = spans[2] + test_span = spans[3] + + assert response + assert 500 == response.status + + assert "X-INSTANA-T" in response.headers + assert int(response.headers["X-INSTANA-T"], 16) + assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) + + assert "X-INSTANA-S" in response.headers + assert int(response.headers["X-INSTANA-S"], 16) + assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) + + assert "X-INSTANA-L" in response.headers + assert response.headers["X-INSTANA-L"] == "1" + + assert "Server-Timing" in response.headers + server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" + assert response.headers["Server-Timing"] == server_timing_value + + # Same traceId + assert test_span.t == urllib3_span.t + assert urllib3_span.t == spyne_span.t + + # Parent relationships + assert urllib3_span.p == test_span.s + assert spyne_span.p == urllib3_span.s + + assert spyne_span.sy is None + assert urllib3_span.sy is None + assert test_span.sy is None + + # Error logging + assert test_span.ec is None + assert urllib3_span.ec == 1 + assert spyne_span.ec == 1 + + # spyne + assert "spyne" == spyne_span.n + assert ( + "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] + ) + assert "/exception" == spyne_span.data["http"]["url"] + assert "GET" == spyne_span.data["http"]["method"] + assert 500 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["error"] is None + assert spyne_span.stack is None From ddd8c4a5469d47d9d540ac6682ec5839460d6852 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Thu, 6 Mar 2025 15:19:01 +0530 Subject: [PATCH 5/9] chore(spyne): Add spyne to requirements - Spyne only supports python < 3.12 Signed-off-by: Varsha GS --- tests/conftest.py | 5 ++ tests/frameworks/test_spyne.py | 86 +++++++++++++++++----------------- tests/requirements.txt | 3 +- 3 files changed, 50 insertions(+), 44 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 342be521..130950e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,11 @@ if not os.environ.get("KAFKA_TEST"): collect_ignore_glob.append("*kafka/test*") +if sys.version_info >= (3, 12): + # Currently Spyne does not support python > 3.12 + collect_ignore_glob.append("*test_spyne*") + + if sys.version_info >= (3, 13): # Currently not installable dependencies because of 3.13 incompatibilities collect_ignore_glob.append("*test_sanic*") diff --git a/tests/frameworks/test_spyne.py b/tests/frameworks/test_spyne.py index 3ce281fa..4b0fd1c9 100644 --- a/tests/frameworks/test_spyne.py +++ b/tests/frameworks/test_spyne.py @@ -43,7 +43,7 @@ def test_get_request(self) -> None: test_span = spans[2] assert response - assert 200 == response.status + assert response.status == 200 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -78,13 +78,13 @@ def test_get_request(self) -> None: assert spyne_span.ec is None # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/hello" == spyne_span.data["http"]["url"] - assert "GET" == spyne_span.data["http"]["method"] - assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["url"] == "/hello" + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 200 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None @@ -102,7 +102,7 @@ def test_secret_scrubbing(self) -> None: test_span = spans[2] assert response - assert 200 == response.status + assert response.status == 200 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -137,14 +137,14 @@ def test_secret_scrubbing(self) -> None: assert spyne_span.ec is None # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/say_hello" == spyne_span.data["http"]["url"] + assert spyne_span.data["http"]["url"] == "/say_hello" assert spyne_span.data["http"]["params"] == "name=World×=4&secret=" - assert "GET" == spyne_span.data["http"]["method"] - assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 200 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None @@ -169,7 +169,7 @@ def test_request_header_capture(self) -> None: urllib3_span = spans[1] test_span = spans[2] - assert 200 == response.status + assert response.status == 200 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -204,13 +204,13 @@ def test_request_header_capture(self) -> None: assert spyne_span.ec is None # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/hello" == spyne_span.data["http"]["url"] - assert "GET" == spyne_span.data["http"]["method"] - assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["url"] == "/hello" + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 200 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None @@ -238,7 +238,7 @@ def test_response_header_capture(self) -> None: urllib3_span = spans[1] test_span = spans[2] - assert 200 == response.status + assert response.status == 200 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -274,13 +274,13 @@ def test_response_header_capture(self) -> None: assert spyne_span.ec is None # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/response_headers" == spyne_span.data["http"]["url"] - assert "GET" == spyne_span.data["http"]["method"] - assert 200 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["url"] == "/response_headers" + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 200 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None @@ -307,7 +307,7 @@ def test_custom_404(self) -> None: test_span = spans[3] assert response - assert 404 == response.status + assert response.status == 404 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -343,24 +343,24 @@ def test_custom_404(self) -> None: assert spyne_span.ec is None # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/custom_404" == spyne_span.data["http"]["url"] - assert "GET" == spyne_span.data["http"]["method"] - assert 404 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["url"] == "/custom_404" + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 404 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None # urllib3 - assert "test" == test_span.data["sdk"]["name"] - assert "urllib3" == urllib3_span.n - assert 404 == urllib3_span.data["http"]["status"] + assert test_span.data["sdk"]["name"] == "test" + assert urllib3_span.n == "urllib3" + assert urllib3_span.data["http"]["status"] == 404 assert ( testenv["spyne_server"] + "/custom_404" == urllib3_span.data["http"]["url"] ) - assert "GET" == urllib3_span.data["http"]["method"] + assert urllib3_span.data["http"]["method"] == "GET" assert urllib3_span.stack is not None assert type(urllib3_span.stack) is list assert len(urllib3_span.stack) > 1 @@ -379,7 +379,7 @@ def test_404(self) -> None: test_span = spans[2] assert response - assert 404 == response.status + assert response.status == 404 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -415,24 +415,24 @@ def test_404(self) -> None: assert spyne_span.ec is None # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/11111" == spyne_span.data["http"]["url"] - assert "GET" == spyne_span.data["http"]["method"] - assert 404 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["url"] == "/11111" + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 404 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None # urllib3 - assert "test" == test_span.data["sdk"]["name"] - assert "urllib3" == urllib3_span.n - assert 404 == urllib3_span.data["http"]["status"] + assert test_span.data["sdk"]["name"] == "test" + assert urllib3_span.n == "urllib3" + assert urllib3_span.data["http"]["status"] == 404 assert ( testenv["spyne_server"] + "/11111" == urllib3_span.data["http"]["url"] ) - assert "GET" == urllib3_span.data["http"]["method"] + assert urllib3_span.data["http"]["method"] == "GET" assert urllib3_span.stack is not None assert type(urllib3_span.stack) is list assert len(urllib3_span.stack) > 1 @@ -452,7 +452,7 @@ def test_500(self) -> None: test_span = spans[3] assert response - assert 500 == response.status + assert response.status == 500 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -487,12 +487,12 @@ def test_500(self) -> None: assert spyne_span.ec == 1 # spyne - assert "spyne" == spyne_span.n + assert spyne_span.n == "spyne" assert ( "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] ) - assert "/exception" == spyne_span.data["http"]["url"] - assert "GET" == spyne_span.data["http"]["method"] - assert 500 == spyne_span.data["http"]["status"] + assert spyne_span.data["http"]["url"] == "/exception" + assert spyne_span.data["http"]["method"] == "GET" + assert spyne_span.data["http"]["status"] == 500 assert spyne_span.data["http"]["error"] is None assert spyne_span.stack is None diff --git a/tests/requirements.txt b/tests/requirements.txt index 9856462a..c79fa153 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -34,8 +34,9 @@ responses<=0.17.0 sanic<=24.6.0; python_version < "3.9" sanic>=19.9.0; python_version >= "3.9" and python_version < "3.13" sanic-testing>=24.6.0; python_version < "3.13" -starlette>=0.38.2; python_version == "3.13" +spyne>=2.14.0; python_version < "3.12" sqlalchemy>=2.0.0 +starlette>=0.38.2; python_version == "3.13" tornado>=6.4.1 uvicorn>=0.13.4 urllib3>=1.26.5 From 244d67e23e7eda31f84e465cba9ade51c71df097 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Fri, 7 Mar 2025 15:28:21 +0530 Subject: [PATCH 6/9] chore(spyne): Handle repetitive code Signed-off-by: Varsha GS --- src/instana/instrumentation/spyne.py | 75 ++++++++++++---------------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/src/instana/instrumentation/spyne.py b/src/instana/instrumentation/spyne.py index c58a42fd..fbb9de5e 100644 --- a/src/instana/instrumentation/spyne.py +++ b/src/instana/instrumentation/spyne.py @@ -12,11 +12,37 @@ from instana.util.secrets import strip_secrets_from_query from instana.util.traceutils import extract_custom_headers + def set_span_attributes(span, headers): + if "REQUEST_METHOD" in headers: + span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) + if "PATH_INFO" in headers: + span.set_attribute(SpanAttributes.HTTP_URL, headers["PATH_INFO"]) + if "QUERY_STRING" in headers and len(headers["QUERY_STRING"]): + scrubbed_params = strip_secrets_from_query( + headers["QUERY_STRING"], + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + span.set_attribute("http.params", scrubbed_params) + if "HTTP_HOST" in headers: + span.set_attribute("http.host", headers["HTTP_HOST"]) + + def set_response_status_code(span, response_string): + resp_code = int(response_string.split()[0]) + + if 500 <= resp_code: + span.mark_as_errored() + + span.set_attribute( + SpanAttributes.HTTP_STATUS_CODE, int(resp_code) + ) @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication.handle_error") def handle_error_with_instana(wrapped, instance, args, kwargs): ctx = args[0] span = ctx.udc + + # span created inside process_request() will be handled by finalize() method if span: return wrapped(*args, **kwargs) @@ -28,19 +54,7 @@ def handle_error_with_instana(wrapped, instance, args, kwargs): ) as span: extract_custom_headers(span, headers, format=True) - if "REQUEST_METHOD" in headers: - span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) - if "PATH_INFO" in headers: - span.set_attribute(SpanAttributes.HTTP_URL, headers["PATH_INFO"]) - if "QUERY_STRING" in headers and len(headers["QUERY_STRING"]): - scrubbed_params = strip_secrets_from_query( - headers["QUERY_STRING"], - agent.options.secrets_matcher, - agent.options.secrets_list, - ) - span.set_attribute("http.params", scrubbed_params) - if "HTTP_HOST" in headers: - span.set_attribute("http.host", headers["HTTP_HOST"]) + set_span_attributes(span, headers) response_headers = ctx.transport.resp_headers @@ -49,14 +63,7 @@ def handle_error_with_instana(wrapped, instance, args, kwargs): response = wrapped(*args, **kwargs) - resp_code = int(ctx.transport.resp_code.split()[0]) - - if 500 <= resp_code: - span.mark_as_errored() - - span.set_attribute( - SpanAttributes.HTTP_STATUS_CODE, int(resp_code) - ) + set_response_status_code(span, ctx.transport.resp_code) return response @@ -64,16 +71,10 @@ def handle_error_with_instana(wrapped, instance, args, kwargs): def finalize_with_instana(wrapped, instance, args, kwargs): ctx = args[0] span = ctx.udc + response_string = ctx.transport.resp_code - if span and ctx.transport.resp_code: - resp_code = int(ctx.transport.resp_code.split()[0]) - - if 500 <= resp_code: - span.mark_as_errored() - - span.set_attribute( - SpanAttributes.HTTP_STATUS_CODE, int(resp_code) - ) + if span and response_string: + set_response_status_code(span, response_string) if span.is_recording(): span.end() @@ -92,19 +93,7 @@ def process_request_with_instana(wrapped, instance, args, kwargs): ) as span: extract_custom_headers(span, headers, format=True) - if "REQUEST_METHOD" in headers: - span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) - if "PATH_INFO" in headers: - span.set_attribute(SpanAttributes.HTTP_URL, headers["PATH_INFO"]) - if "QUERY_STRING" in headers and len(headers["QUERY_STRING"]): - scrubbed_params = strip_secrets_from_query( - headers["QUERY_STRING"], - agent.options.secrets_matcher, - agent.options.secrets_list, - ) - span.set_attribute("http.params", scrubbed_params) - if "HTTP_HOST" in headers: - span.set_attribute("http.host", headers["HTTP_HOST"]) + set_span_attributes(span, headers) response = wrapped(*args, **kwargs) response_headers = ctx.transport.resp_headers From 4ed457c611a4ff4afdee2063d482770fe70ea5e3 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Mon, 10 Mar 2025 13:07:19 +0530 Subject: [PATCH 7/9] style(spyne): Add typehints Signed-off-by: Varsha GS --- src/instana/instrumentation/spyne.py | 57 ++++++++++++++++++---------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/instana/instrumentation/spyne.py b/src/instana/instrumentation/spyne.py index fbb9de5e..b2fed7cf 100644 --- a/src/instana/instrumentation/spyne.py +++ b/src/instana/instrumentation/spyne.py @@ -3,6 +3,7 @@ try: import spyne import wrapt + from typing import TYPE_CHECKING, Dict, Any, Callable, Tuple, Iterable from opentelemetry.semconv.trace import SpanAttributes @@ -12,7 +13,12 @@ from instana.util.secrets import strip_secrets_from_query from instana.util.traceutils import extract_custom_headers - def set_span_attributes(span, headers): + if TYPE_CHECKING: + from instana.span.span import InstanaSpan + from spyne.application import Application + from spyne.server.wsgi import WsgiApplication + + def set_span_attributes(span: "InstanaSpan", headers: Dict[str, Any]) -> None: if "REQUEST_METHOD" in headers: span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) if "PATH_INFO" in headers: @@ -27,48 +33,55 @@ def set_span_attributes(span, headers): if "HTTP_HOST" in headers: span.set_attribute("http.host", headers["HTTP_HOST"]) - def set_response_status_code(span, response_string): + def set_response_status_code(span: "InstanaSpan", response_string: str) -> None: resp_code = int(response_string.split()[0]) if 500 <= resp_code: span.mark_as_errored() - span.set_attribute( - SpanAttributes.HTTP_STATUS_CODE, int(resp_code) - ) + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, int(resp_code)) @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication.handle_error") - def handle_error_with_instana(wrapped, instance, args, kwargs): + def handle_error_with_instana( + wrapped: Callable[..., Iterable[object]], + instance: "WsgiApplication", + args: Tuple[object], + kwargs: Dict[str, Any], + ) -> Iterable[object]: ctx = args[0] span = ctx.udc # span created inside process_request() will be handled by finalize() method if span: return wrapped(*args, **kwargs) - + headers = ctx.in_document span_context = tracer.extract(Format.HTTP_HEADERS, headers) - with tracer.start_as_current_span( - "spyne", span_context=span_context - ) as span: + with tracer.start_as_current_span("spyne", span_context=span_context) as span: extract_custom_headers(span, headers, format=True) set_span_attributes(span, headers) response_headers = ctx.transport.resp_headers - + extract_custom_headers(span, response_headers, format=False) tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) response = wrapped(*args, **kwargs) set_response_status_code(span, ctx.transport.resp_code) - return response - + return response - @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication._WsgiApplication__finalize") - def finalize_with_instana(wrapped, instance, args, kwargs): + @wrapt.patch_function_wrapper( + "spyne.server.wsgi", "WsgiApplication._WsgiApplication__finalize" + ) + def finalize_with_instana( + wrapped: Callable[..., Tuple[()]], + instance: "WsgiApplication", + args: Tuple[object], + kwargs: Dict[str, Any], + ) -> Tuple[()]: ctx = args[0] span = ctx.udc response_string = ctx.transport.resp_code @@ -81,15 +94,21 @@ def finalize_with_instana(wrapped, instance, args, kwargs): ctx.udc = None return wrapped(*args, **kwargs) - @wrapt.patch_function_wrapper("spyne.application", "Application.process_request") - def process_request_with_instana(wrapped, instance, args, kwargs): + def process_request_with_instana( + wrapped: Callable[..., None], + instance: "Application", + args: Tuple[object], + kwargs: Dict[str, Any], + ) -> None: ctx = args[0] headers = ctx.in_document span_context = tracer.extract(Format.HTTP_HEADERS, headers) with tracer.start_as_current_span( - "spyne", span_context=span_context, end_on_exit=False, + "spyne", + span_context=span_context, + end_on_exit=False, ) as span: extract_custom_headers(span, headers, format=True) @@ -97,7 +116,7 @@ def process_request_with_instana(wrapped, instance, args, kwargs): response = wrapped(*args, **kwargs) response_headers = ctx.transport.resp_headers - + extract_custom_headers(span, response_headers, format=False) tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) From 9220dd1038f1b6f04636a369f102848440b45fd4 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Thu, 13 Mar 2025 13:44:54 +0530 Subject: [PATCH 8/9] spyne: Add support for UDC Signed-off-by: Varsha GS --- src/instana/instrumentation/spyne.py | 16 ++++++++++------ tests/apps/spyne_app/app.py | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/instana/instrumentation/spyne.py b/src/instana/instrumentation/spyne.py index b2fed7cf..26d14964 100644 --- a/src/instana/instrumentation/spyne.py +++ b/src/instana/instrumentation/spyne.py @@ -4,6 +4,7 @@ import spyne import wrapt from typing import TYPE_CHECKING, Dict, Any, Callable, Tuple, Iterable + from types import SimpleNamespace from opentelemetry.semconv.trace import SpanAttributes @@ -49,10 +50,9 @@ def handle_error_with_instana( kwargs: Dict[str, Any], ) -> Iterable[object]: ctx = args[0] - span = ctx.udc # span created inside process_request() will be handled by finalize() method - if span: + if ctx.udc and ctx.udc.span: return wrapped(*args, **kwargs) headers = ctx.in_document @@ -83,15 +83,15 @@ def finalize_with_instana( kwargs: Dict[str, Any], ) -> Tuple[()]: ctx = args[0] - span = ctx.udc response_string = ctx.transport.resp_code - if span and response_string: + if ctx.udc and ctx.udc.span and response_string: + span = ctx.udc.span set_response_status_code(span, response_string) if span.is_recording(): span.end() - ctx.udc = None + ctx.udc.span = None return wrapped(*args, **kwargs) @wrapt.patch_function_wrapper("spyne.application", "Application.process_request") @@ -121,7 +121,11 @@ def process_request_with_instana( tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) ## Store the span in the user defined context object offered by Spyne - ctx.udc = span + if ctx.udc: + ctx.udc.span = span + else: + ctx.udc = SimpleNamespace() + ctx.udc.span = span return response logger.debug("Instrumenting Spyne") diff --git a/tests/apps/spyne_app/app.py b/tests/apps/spyne_app/app.py index 985861ca..366b88f9 100644 --- a/tests/apps/spyne_app/app.py +++ b/tests/apps/spyne_app/app.py @@ -55,7 +55,7 @@ def exception(ctx): raise Exception('fake error') -application = Application([HelloWorldService], 'spyne.examples.hello.http', +application = Application([HelloWorldService], 'instana.spyne.service.helloworld', in_protocol=HttpRpc(validator='soft'), out_protocol=JsonDocument(ignore_wrappers=True), ) From 43e8cacd1e2b30fa3114cc5bd647368c8bee2531 Mon Sep 17 00:00:00 2001 From: Varsha GS Date: Wed, 19 Mar 2025 21:28:50 +0530 Subject: [PATCH 9/9] spyne: rpc-server adaptation Signed-off-by: Varsha GS --- src/instana/__init__.py | 2 +- src/instana/instrumentation/spyne.py | 41 ++--- tests/frameworks/test_spyne.py | 216 ++++----------------------- 3 files changed, 46 insertions(+), 213 deletions(-) diff --git a/src/instana/__init__.py b/src/instana/__init__.py index f46e5461..00de8627 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -188,7 +188,7 @@ def boot_agent() -> None: sqlalchemy, # noqa: F401 starlette, # noqa: F401 urllib3, # noqa: F401 - spyne, + spyne, # noqa: F401 ) from instana.instrumentation.aiohttp import ( client as aiohttp_client, # noqa: F401 diff --git a/src/instana/instrumentation/spyne.py b/src/instana/instrumentation/spyne.py index 26d14964..bfb4c83d 100644 --- a/src/instana/instrumentation/spyne.py +++ b/src/instana/instrumentation/spyne.py @@ -3,16 +3,14 @@ try: import spyne import wrapt - from typing import TYPE_CHECKING, Dict, Any, Callable, Tuple, Iterable - from types import SimpleNamespace + from typing import TYPE_CHECKING, Dict, Any, Callable, Tuple, Iterable, Type, Optional - from opentelemetry.semconv.trace import SpanAttributes + from types import SimpleNamespace from instana.log import logger from instana.singletons import agent, tracer from instana.propagators.format import Format from instana.util.secrets import strip_secrets_from_query - from instana.util.traceutils import extract_custom_headers if TYPE_CHECKING: from instana.span.span import InstanaSpan @@ -20,27 +18,26 @@ from spyne.server.wsgi import WsgiApplication def set_span_attributes(span: "InstanaSpan", headers: Dict[str, Any]) -> None: - if "REQUEST_METHOD" in headers: - span.set_attribute(SpanAttributes.HTTP_METHOD, headers["REQUEST_METHOD"]) if "PATH_INFO" in headers: - span.set_attribute(SpanAttributes.HTTP_URL, headers["PATH_INFO"]) + span.set_attribute("rpc.call", headers["PATH_INFO"]) if "QUERY_STRING" in headers and len(headers["QUERY_STRING"]): scrubbed_params = strip_secrets_from_query( headers["QUERY_STRING"], agent.options.secrets_matcher, agent.options.secrets_list, ) - span.set_attribute("http.params", scrubbed_params) - if "HTTP_HOST" in headers: - span.set_attribute("http.host", headers["HTTP_HOST"]) + span.set_attribute("rpc.params", scrubbed_params) + if "REMOTE_ADDR" in headers: + span.set_attribute("rpc.host", headers["REMOTE_ADDR"]) + if "SERVER_PORT" in headers: + span.set_attribute("rpc.port", headers["SERVER_PORT"]) - def set_response_status_code(span: "InstanaSpan", response_string: str) -> None: + def record_error(span: "InstanaSpan", response_string: str, error: Optional[Type[Exception]]) -> None: resp_code = int(response_string.split()[0]) if 500 <= resp_code: - span.mark_as_errored() + span.record_exception(error) - span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, int(resp_code)) @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication.handle_error") def handle_error_with_instana( @@ -55,22 +52,19 @@ def handle_error_with_instana( if ctx.udc and ctx.udc.span: return wrapped(*args, **kwargs) - headers = ctx.in_document + headers = ctx.transport.req_env span_context = tracer.extract(Format.HTTP_HEADERS, headers) - with tracer.start_as_current_span("spyne", span_context=span_context) as span: - extract_custom_headers(span, headers, format=True) - + with tracer.start_as_current_span("rpc-server", span_context=span_context) as span: set_span_attributes(span, headers) response_headers = ctx.transport.resp_headers - extract_custom_headers(span, response_headers, format=False) tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) response = wrapped(*args, **kwargs) - set_response_status_code(span, ctx.transport.resp_code) + record_error(span, ctx.transport.resp_code, ctx.in_error or ctx.out_error) return response @wrapt.patch_function_wrapper( @@ -87,7 +81,7 @@ def finalize_with_instana( if ctx.udc and ctx.udc.span and response_string: span = ctx.udc.span - set_response_status_code(span, response_string) + record_error(span, response_string, ctx.in_error or ctx.out_error) if span.is_recording(): span.end() @@ -102,22 +96,19 @@ def process_request_with_instana( kwargs: Dict[str, Any], ) -> None: ctx = args[0] - headers = ctx.in_document + headers = ctx.transport.req_env span_context = tracer.extract(Format.HTTP_HEADERS, headers) with tracer.start_as_current_span( - "spyne", + "rpc-server", span_context=span_context, end_on_exit=False, ) as span: - extract_custom_headers(span, headers, format=True) - set_span_attributes(span, headers) response = wrapped(*args, **kwargs) response_headers = ctx.transport.resp_headers - extract_custom_headers(span, response_headers, format=False) tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) ## Store the span in the user defined context object offered by Spyne diff --git a/tests/frameworks/test_spyne.py b/tests/frameworks/test_spyne.py index 4b0fd1c9..999b9b6d 100644 --- a/tests/frameworks/test_spyne.py +++ b/tests/frameworks/test_spyne.py @@ -78,14 +78,11 @@ def test_get_request(self) -> None: assert spyne_span.ec is None # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/hello" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 200 - assert spyne_span.data["http"]["error"] is None + assert spyne_span.n == "rpc-server" + assert spyne_span.data["rpc"]["host"] == "127.0.0.1" + assert spyne_span.data["rpc"]["call"] == "/hello" + assert spyne_span.data["rpc"]["port"] == str(testenv["spyne_port"]) + assert spyne_span.data["rpc"]["error"] is None assert spyne_span.stack is None def test_secret_scrubbing(self) -> None: @@ -137,161 +134,14 @@ def test_secret_scrubbing(self) -> None: assert spyne_span.ec is None # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/say_hello" - assert spyne_span.data["http"]["params"] == "name=World×=4&secret=" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 200 - assert spyne_span.data["http"]["error"] is None - assert spyne_span.stack is None - - def test_request_header_capture(self) -> None: - # Hack together a manual custom headers list - 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"): - response = self.http.request("GET", testenv["spyne_server"] + "/hello", headers=request_headers) - - spans = self.recorder.queued_spans() - - assert len(spans) == 3 - - spyne_span = spans[0] - urllib3_span = spans[1] - test_span = spans[2] - - assert response.status == 200 - - assert "X-INSTANA-T" in response.headers - assert int(response.headers["X-INSTANA-T"], 16) - assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) - - assert "X-INSTANA-S" in response.headers - assert int(response.headers["X-INSTANA-S"], 16) - assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) - - assert "X-INSTANA-L" in response.headers - assert response.headers["X-INSTANA-L"] == "1" - - assert "Server-Timing" in response.headers - server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" - assert response.headers["Server-Timing"] == server_timing_value - - # Same traceId - assert test_span.t == urllib3_span.t - assert urllib3_span.t == spyne_span.t - - # Parent relationships - assert urllib3_span.p == test_span.s - assert spyne_span.p == urllib3_span.s - - assert spyne_span.sy is None - assert urllib3_span.sy is None - assert test_span.sy is None - - # Error logging - assert test_span.ec is None - assert urllib3_span.ec is None - assert spyne_span.ec is None - - # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/hello" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 200 - assert spyne_span.data["http"]["error"] is None - assert spyne_span.stack is None - - # custom headers - assert "X-Capture-This-Too" in spyne_span.data["http"]["header"] - assert spyne_span.data["http"]["header"]["X-Capture-This-Too"] == "this too" - assert "X-Capture-That-Too" in spyne_span.data["http"]["header"] - assert spyne_span.data["http"]["header"]["X-Capture-That-Too"] == "that too" - - agent.options.extra_http_headers = original_extra_http_headers - - def test_response_header_capture(self) -> None: - # Hack together a manual custom headers list - original_extra_http_headers = agent.options.extra_http_headers - agent.options.extra_http_headers = ["X-Capture-This", "X-Capture-That"] - - with tracer.start_as_current_span("test"): - response = self.http.request("GET", testenv["spyne_server"] + "/response_headers") - - spans = self.recorder.queued_spans() - - assert len(spans) == 3 - - spyne_span = spans[0] - urllib3_span = spans[1] - test_span = spans[2] - - assert response.status == 200 - - assert "X-INSTANA-T" in response.headers - assert int(response.headers["X-INSTANA-T"], 16) - assert response.headers["X-INSTANA-T"] == hex_id(spyne_span.t) - - assert "X-INSTANA-S" in response.headers - assert int(response.headers["X-INSTANA-S"], 16) - assert response.headers["X-INSTANA-S"] == hex_id(spyne_span.s) - - assert "X-INSTANA-L" in response.headers - assert response.headers["X-INSTANA-L"] == "1" - - assert "Server-Timing" in response.headers - server_timing_value = f"intid;desc={hex_id(spyne_span.t)}" - assert response.headers["Server-Timing"] == server_timing_value - - # Same traceId - assert test_span.t == urllib3_span.t - assert urllib3_span.t == spyne_span.t - - # Parent relationships - assert urllib3_span.p == test_span.s - assert spyne_span.p == urllib3_span.s - - # Synthetic - assert spyne_span.sy is None - assert urllib3_span.sy is None - assert test_span.sy is None - - # Error logging - assert test_span.ec is None - assert urllib3_span.ec is None - assert spyne_span.ec is None - - # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/response_headers" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 200 - assert spyne_span.data["http"]["error"] is None + assert spyne_span.n == "rpc-server" + assert spyne_span.data["rpc"]["host"] == "127.0.0.1" + assert spyne_span.data["rpc"]["call"] == "/say_hello" + assert spyne_span.data["rpc"]["params"] == "name=World×=4&secret=" + assert spyne_span.data["rpc"]["port"] == str(testenv["spyne_port"]) + assert spyne_span.data["rpc"]["error"] is None assert spyne_span.stack is None - # custom headers - assert "X-Capture-This" in spyne_span.data["http"]["header"] - assert spyne_span.data["http"]["header"]["X-Capture-This"] == "this" - assert "X-Capture-That" in spyne_span.data["http"]["header"] - assert spyne_span.data["http"]["header"]["X-Capture-That"] == "that" - - agent.options.extra_http_headers = original_extra_http_headers - def test_custom_404(self) -> None: with tracer.start_as_current_span("test"): response = self.http.request("GET", testenv["spyne_server"] + "/custom_404?user_id=9876") @@ -307,7 +157,7 @@ def test_custom_404(self) -> None: test_span = spans[3] assert response - assert response.status == 404 + assert response.status == 404 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -343,14 +193,12 @@ def test_custom_404(self) -> None: assert spyne_span.ec is None # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/custom_404" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 404 - assert spyne_span.data["http"]["error"] is None + assert spyne_span.n == "rpc-server" + assert spyne_span.data["rpc"]["host"] == "127.0.0.1" + assert spyne_span.data["rpc"]["call"] == "/custom_404" + assert spyne_span.data["rpc"]["port"] == str(testenv["spyne_port"]) + assert spyne_span.data["rpc"]["params"] == "user_id=9876" + assert spyne_span.data["rpc"]["error"] is None assert spyne_span.stack is None # urllib3 @@ -379,7 +227,7 @@ def test_404(self) -> None: test_span = spans[2] assert response - assert response.status == 404 + assert response.status == 404 assert "X-INSTANA-T" in response.headers assert int(response.headers["X-INSTANA-T"], 16) @@ -415,14 +263,11 @@ def test_404(self) -> None: assert spyne_span.ec is None # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/11111" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 404 - assert spyne_span.data["http"]["error"] is None + assert spyne_span.n == "rpc-server" + assert spyne_span.data["rpc"]["host"] == "127.0.0.1" + assert spyne_span.data["rpc"]["call"] == "/11111" + assert spyne_span.data["rpc"]["port"] == str(testenv["spyne_port"]) + assert spyne_span.data["rpc"]["error"] is None assert spyne_span.stack is None # urllib3 @@ -487,12 +332,9 @@ def test_500(self) -> None: assert spyne_span.ec == 1 # spyne - assert spyne_span.n == "spyne" - assert ( - "127.0.0.1:" + str(testenv["spyne_port"]) == spyne_span.data["http"]["host"] - ) - assert spyne_span.data["http"]["url"] == "/exception" - assert spyne_span.data["http"]["method"] == "GET" - assert spyne_span.data["http"]["status"] == 500 - assert spyne_span.data["http"]["error"] is None + assert spyne_span.n == "rpc-server" + assert spyne_span.data["rpc"]["host"] == "127.0.0.1" + assert spyne_span.data["rpc"]["call"] == "/exception" + assert spyne_span.data["rpc"]["port"] == str(testenv["spyne_port"]) + assert spyne_span.data["rpc"]["error"] assert spyne_span.stack is None