diff --git a/src/instana/__init__.py b/src/instana/__init__.py index 5f26bc5a..00de8627 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, # 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 new file mode 100644 index 00000000..bfb4c83d --- /dev/null +++ b/src/instana/instrumentation/spyne.py @@ -0,0 +1,125 @@ +# (c) Copyright IBM Corp. 2025 + +try: + import spyne + import wrapt + from typing import TYPE_CHECKING, Dict, Any, Callable, Tuple, Iterable, Type, Optional + + 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 + + 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 "PATH_INFO" in headers: + 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("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 record_error(span: "InstanaSpan", response_string: str, error: Optional[Type[Exception]]) -> None: + resp_code = int(response_string.split()[0]) + + if 500 <= resp_code: + span.record_exception(error) + + + @wrapt.patch_function_wrapper("spyne.server.wsgi", "WsgiApplication.handle_error") + def handle_error_with_instana( + wrapped: Callable[..., Iterable[object]], + instance: "WsgiApplication", + args: Tuple[object], + kwargs: Dict[str, Any], + ) -> Iterable[object]: + ctx = args[0] + + # span created inside process_request() will be handled by finalize() method + if ctx.udc and ctx.udc.span: + return wrapped(*args, **kwargs) + + headers = ctx.transport.req_env + span_context = tracer.extract(Format.HTTP_HEADERS, headers) + + 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 + + tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) + + response = wrapped(*args, **kwargs) + + record_error(span, ctx.transport.resp_code, ctx.in_error or ctx.out_error) + return response + + @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] + response_string = ctx.transport.resp_code + + if ctx.udc and ctx.udc.span and response_string: + span = ctx.udc.span + record_error(span, response_string, ctx.in_error or ctx.out_error) + if span.is_recording(): + span.end() + + ctx.udc.span = None + return wrapped(*args, **kwargs) + + @wrapt.patch_function_wrapper("spyne.application", "Application.process_request") + def process_request_with_instana( + wrapped: Callable[..., None], + instance: "Application", + args: Tuple[object], + kwargs: Dict[str, Any], + ) -> None: + ctx = args[0] + headers = ctx.transport.req_env + span_context = tracer.extract(Format.HTTP_HEADERS, headers) + + with tracer.start_as_current_span( + "rpc-server", + span_context=span_context, + end_on_exit=False, + ) as span: + set_span_attributes(span, headers) + + response = wrapped(*args, **kwargs) + response_headers = ctx.transport.resp_headers + + tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) + + ## Store the span in the user defined context object offered by Spyne + if ctx.udc: + ctx.udc.span = span + else: + ctx.udc = SimpleNamespace() + ctx.udc.span = span + return response + + logger.debug("Instrumenting Spyne") + +except ImportError: + pass 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..366b88f9 --- /dev/null +++ b/tests/apps/spyne_app/app.py @@ -0,0 +1,67 @@ +#!/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 + +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(String, UnsignedInteger, _returns=Iterable(String)) + def say_hello(ctx, name, times): + """ + :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(ctx): + return "

🐍 Hello Stan! 🦄

" + + @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], 'instana.spyne.service.helloworld', + 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__': + spyne_server.request_queue_size = 20 + spyne_server.serve_forever() 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 new file mode 100644 index 00000000..999b9b6d --- /dev/null +++ b/tests/frameworks/test_spyne.py @@ -0,0 +1,340 @@ +# (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 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 == "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: + 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 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 == "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 + + 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 response.status == 404 + + 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 == "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 + 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 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 + + 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 response.status == 404 + + 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 == "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 + 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 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 + + 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 response.status == 500 + + 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_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 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