Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/instana/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions src/instana/instrumentation/spyne.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
GSVarsha marked this conversation as resolved.
Outdated

@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)
Comment thread
GSVarsha marked this conversation as resolved.
Outdated

@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:
Comment thread
GSVarsha marked this conversation as resolved.
Outdated
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
Comment thread
GSVarsha marked this conversation as resolved.
Outdated

logger.debug("Instrumenting Spyne")

except ImportError:
pass
10 changes: 10 additions & 0 deletions tests/apps/spyne_app/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
67 changes: 67 additions & 0 deletions tests/apps/spyne_app/app.py
Original file line number Diff line number Diff line change
@@ -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, <name>' strings, repeated <times> times.
"""

for i in range(times):
yield 'Hello, %s' % name

@rpc(_returns=Unicode)
def hello(ctx):
return "<center><h1>🐍 Hello Stan! 🦄</h1></center>"

@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()
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*")
Expand Down
Loading