diff --git a/.gitignore b/.gitignore index bfb55dcf..149fc092 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,4 @@ uv.lock # Sandbox sandbox/ +.bob/ diff --git a/src/instana/__init__.py b/src/instana/__init__.py index 347f97ed..a8ecf07d 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -213,6 +213,12 @@ def boot_agent() -> None: from instana.instrumentation.tornado import ( server as tornado_server, # noqa: F401 ) + from instana.instrumentation.twisted import ( + client as twisted_client, # noqa: F401 + ) + from instana.instrumentation.twisted import ( + server as twisted_server, # noqa: F401 + ) def _start_profiler() -> None: diff --git a/src/instana/instrumentation/twisted/__init__.py b/src/instana/instrumentation/twisted/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/instana/instrumentation/twisted/client.py b/src/instana/instrumentation/twisted/client.py new file mode 100644 index 00000000..2e810f05 --- /dev/null +++ b/src/instana/instrumentation/twisted/client.py @@ -0,0 +1,128 @@ +# (c) Copyright IBM Corp. 2026 + +try: + from typing import TYPE_CHECKING, Callable, Union + + import wrapt + from opentelemetry.context import get_current + from opentelemetry.semconv.trace import SpanAttributes + from twisted.python.failure import Failure + from twisted.web.http_headers import Headers as TwistedHeaders + + from instana.log import logger + from instana.propagators.format import Format + from instana.singletons import agent, get_tracer + from instana.span.span import get_current_span + from instana.util.secrets import strip_secrets_from_query + from instana.util.traceutils import extract_custom_headers + + if TYPE_CHECKING: + from twisted.internet.defer import Deferred + from twisted.web.iweb import IResponse + + from instana.span.span import InstanaSpan + + @wrapt.patch_function_wrapper("twisted.web.client", "Agent.request") + def request_with_instana( + wrapped: "Callable[..., Deferred]", + instance: object, + argv: tuple[object, ...], + kwargs: dict[str, object], + ) -> "Deferred": + try: + parent_span = get_current_span() + + # If we're not tracing, just return + if not parent_span.is_recording(): + return wrapped(*argv, **kwargs) + + # argv: (method, url[, headers[, bodyProducer]]) + method = argv[0] + url = argv[1] + headers = argv[2] if len(argv) > 2 else kwargs.get("headers") + + method_str = ( + method.decode("latin-1") if isinstance(method, bytes) else str(method) + ) + url_str = url.decode("latin-1") if isinstance(url, bytes) else str(url) + + parent_context = get_current() + tracer = get_tracer() + span = tracer.start_span("twisted-client", context=parent_context) + + # Query param scrubbing + parts = url_str.split("?", 1) + span.set_attribute(SpanAttributes.HTTP_URL, parts[0]) + if len(parts) > 1 and parts[1]: + cleaned_qp = strip_secrets_from_query( + parts[1], + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + span.set_attribute("http.params", cleaned_qp) + + span.set_attribute(SpanAttributes.HTTP_METHOD, method_str) + + # Build / augment headers with trace correlation + if headers is None or not isinstance(headers, TwistedHeaders): + headers = TwistedHeaders({}) + + # Capture outgoing request headers + headers_dict = { + k.decode("latin-1"): v[0].decode("utf-8") + for k, v in headers.getAllRawHeaders() + } + extract_custom_headers(span, headers_dict) + + # Inject Instana correlation headers + inject_carrier = {} + tracer.inject(span.context, Format.HTTP_HEADERS, inject_carrier) + for key, value in inject_carrier.items(): + headers.setRawHeaders(key.encode("latin-1"), [value.encode("utf-8")]) + + # Rebuild argv with the modified headers + new_argv = (argv[0], argv[1], headers) + argv[3:] + + deferred = wrapped(*new_argv, **kwargs) + + if deferred is not None: + deferred.addBoth(finish_tracing, span) + + return deferred + except Exception: + logger.debug("twisted client request_with_instana", exc_info=True) + return wrapped(*argv, **kwargs) + + def finish_tracing( + result: "Union[IResponse, Failure]", span: "InstanaSpan" + ) -> "Union[IResponse, Failure]": + """Callback/errback attached to the Agent.request Deferred.""" + try: + if isinstance(result, Failure): + span.record_exception(result.value) + else: + status_code = result.code + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) + + # Capture response headers + headers_dict = { + k.decode("latin-1"): v[0].decode("utf-8") + for k, v in result.headers.getAllRawHeaders() + } + extract_custom_headers(span, headers_dict) + + if status_code >= 500: + span.mark_as_errored({ + "http.error": result.phrase.decode("latin-1") + }) + except Exception: + logger.debug("twisted client finish_tracing", exc_info=True) + finally: + if span.is_recording(): + span.end() + + return result + + logger.debug("Instrumenting twisted client") +except ImportError: + pass diff --git a/src/instana/instrumentation/twisted/server.py b/src/instana/instrumentation/twisted/server.py new file mode 100644 index 00000000..1a64aa6a --- /dev/null +++ b/src/instana/instrumentation/twisted/server.py @@ -0,0 +1,147 @@ +# (c) Copyright IBM Corp. 2026 + +try: + from typing import TYPE_CHECKING, Callable, Optional + + import wrapt + from opentelemetry import context, trace + from opentelemetry.semconv.trace import SpanAttributes + + from instana.log import logger + from instana.propagators.format import Format + from instana.singletons import agent, get_tracer + from instana.util.secrets import strip_secrets_from_query + from instana.util.traceutils import extract_custom_headers + + if TYPE_CHECKING: + from twisted.python.failure import Failure + from twisted.web.http import Request + from twisted.web.resource import Resource + + @wrapt.patch_function_wrapper("twisted.web.resource", "Resource.render") + def render_with_instana( + wrapped: "Callable[..., Optional[bytes]]", + instance: "Resource", + argv: tuple[object, ...], + kwargs: dict[str, object], + ) -> Optional[bytes]: + request = argv[0] + span = None + token = None + try: + tracer = get_tracer() + + # Extract parent context from incoming request headers + headers_dict = {} + parent_context = None + if request.requestHeaders: + headers_dict = { + k.decode("latin-1"): v[0].decode("utf-8") + for k, v in request.requestHeaders.getAllRawHeaders() + } + parent_context = tracer.extract(Format.HTTP_HEADERS, headers_dict) + + span = tracer.start_span("twisted-server", context=parent_context) + + # Set span as current so downstream code (e.g. twisted-client) can find it + ctx = trace.set_span_in_context(span) + token = context.attach(ctx) + request._instana_token = token + + # Extract the URL components + host = request.getHeader("host") or "" + scheme = "https" if request.isSecure() else "http" + raw_path = request.path + path = ( + raw_path.decode("latin-1") if isinstance(raw_path, bytes) else raw_path + ) + url = f"{scheme}://{host}{path}" + span.set_attribute(SpanAttributes.HTTP_URL, url) + + raw_method = request.method + method = ( + raw_method.decode("latin-1") + if isinstance(raw_method, bytes) + else raw_method + ) + span.set_attribute(SpanAttributes.HTTP_METHOD, method) + + # Query param scrubbing + raw_query = request.uri + query = ( + raw_query.decode("latin-1") + if isinstance(raw_query, bytes) + else raw_query + ) + if "?" in query: + qs = query.split("?", 1)[1] + if qs: + cleaned_qp = strip_secrets_from_query( + qs, + agent.options.secrets_matcher, + agent.options.secrets_list, + ) + span.set_attribute("http.params", cleaned_qp) + + # Request header tracking support + extract_custom_headers(span, headers_dict) + + # Inject correlation headers into response + response_headers = {} + tracer.inject(span.context, Format.HTTP_HEADERS, response_headers) + for key, value in response_headers.items(): + request.setHeader(key.encode("latin-1"), value.encode("utf-8")) + + # Store span on request for later retrieval + request._instana = span + request._instana_finished = False + + finish_deferred = request.notifyFinish() + finish_deferred.addBoth(finish_tracing, request) + + return wrapped(*argv, **kwargs) + except Exception: + if span is not None and span.is_recording(): + span.end() + if token is not None: + context.detach(token) + logger.debug("twisted server render_with_instana", exc_info=True) + return wrapped(*argv, **kwargs) + + def finish_tracing( + result: "Optional[Failure]", request: "Request" + ) -> "Optional[Failure]": + """Finish tracing when the Twisted request lifecycle completes.""" + if request._instana_finished: + return result + + request._instana_finished = True + span = request._instana + try: + status_code = request.code + if isinstance(status_code, int): + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) + + # Capture response headers + response_hdrs = { + k.decode("latin-1"): v[0].decode("utf-8") + for k, v in request.responseHeaders.getAllRawHeaders() + } + extract_custom_headers(span, response_hdrs) + + if isinstance(status_code, int) and status_code >= 500: + span.mark_as_errored({ + "http.error": request.code_message.decode("latin-1") + }) + except Exception: + logger.debug("twisted server finish_tracing", exc_info=True) + finally: + if span.is_recording(): + span.end() + context.detach(request._instana_token) + + return result + + logger.debug("Instrumenting twisted server") +except ImportError: + pass diff --git a/src/instana/span/kind.py b/src/instana/span/kind.py index 1cc5e8dd..f7a074c3 100644 --- a/src/instana/span/kind.py +++ b/src/instana/span/kind.py @@ -16,6 +16,8 @@ "httpx", "tornado-client", "tornado-server", + "twisted-client", + "twisted-server", "urllib3", "wsgi", "asgi", @@ -31,6 +33,7 @@ "rabbitmq", "rpc-server", "tornado-server", + "twisted-server", "gcps-consumer", "asgi", "kafka-consumer", @@ -57,6 +60,7 @@ "sqlalchemy", "s3", "tornado-client", + "twisted-client", "urllib3", "pymongo", "gcs", diff --git a/tests/apps/twisted_server/__init__.py b/tests/apps/twisted_server/__init__.py new file mode 100644 index 00000000..058f1258 --- /dev/null +++ b/tests/apps/twisted_server/__init__.py @@ -0,0 +1,30 @@ +# (c) Copyright IBM Corp. 2026 + +import os +import socket + +from tests.apps.utils import launch_background_thread +from tests.helpers import testenv + +app_thread = None + + +def _get_free_port() -> int: + """Ask the OS for a free port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +if not any(( + app_thread, + os.environ.get("GEVENT_TEST"), + os.environ.get("CASSANDRA_TEST"), +)): + testenv["twisted_port"] = _get_free_port() + testenv["twisted_server"] = "http://127.0.0.1:" + str(testenv["twisted_port"]) + + # Background Twisted application + from .app import run_server + + app_thread = launch_background_thread(run_server, "Twisted") diff --git a/tests/apps/twisted_server/app.py b/tests/apps/twisted_server/app.py new file mode 100644 index 00000000..1788f67a --- /dev/null +++ b/tests/apps/twisted_server/app.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) Copyright IBM Corp. 2026 + +from twisted.internet import reactor +from twisted.web import server +from twisted.web.client import Agent, readBody +from twisted.web.http import Request +from twisted.web.http_headers import Headers +from twisted.web.resource import Resource + +from tests.helpers import testenv + + +class RootResource(Resource): + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + return b"Hello Twisted" + + def render_POST(self, request: Request) -> bytes: + return b"Hello Twisted post" + + +class R301Resource(Resource): + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + request.setResponseCode(301) + request.setHeader(b"location", b"/") + return b"" + + +class R404Resource(Resource): + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + request.setResponseCode(404) + return b"Not Found" + + +class R500Resource(Resource): + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + request.setResponseCode(500) + return b"Internal Server Error" + + +class ResponseHeadersResource(Resource): + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + request.setHeader(b"X-Capture-This-Too", b"this too") + request.setHeader(b"X-Capture-That-Too", b"that too") + return b"Stan wuz here with headers!" + + +class FetchResource(Resource): + """GET /fetch?url= — makes an outbound Agent.request so + twisted-client instrumentation is exercised from within the reactor.""" + + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + target = request.args.get(b"url", [None])[0] + if not target: + request.setResponseCode(400) + return b"missing url param" + + agent_obj = Agent(reactor) + d = agent_obj.request(b"GET", target, Headers({}), None) + + def on_response(response: object) -> object: + return readBody(response) + + def on_body(body: bytes) -> None: + request.write(b"Fetched: " + body) + request.finish() + + def on_error(failure: object) -> None: + request.setResponseCode(502) + request.write(b"Fetch error: " + failure.getErrorMessage().encode()) + request.finish() + + d.addCallback(on_response) + d.addCallback(on_body) + d.addErrback(on_error) + return server.NOT_DONE_YET + + +class TwistedApp(Resource): + """Root resource that dispatches to child resources by path.""" + + def getChild(self, path: bytes, request: Request) -> Resource: + if path == b"": + # / — serve root + return RootResource() + if path == b"301": + return R301Resource() + if path == b"404": + return R404Resource() + if path == b"500": + return R500Resource() + if path == b"response_headers": + return ResponseHeadersResource() + if path == b"fetch": + return FetchResource() + return Resource.getChild(self, path, request) + + def render_GET(self, request: Request) -> bytes: + return b"Hello Twisted" + + def render_POST(self, request: Request) -> bytes: + return b"Hello Twisted post" + + +def run_server() -> None: + root = TwistedApp() + site = server.Site(root) + reactor.listenTCP(testenv["twisted_port"], site) + reactor.run(installSignalHandlers=False) diff --git a/tests/frameworks/test_twisted_client.py b/tests/frameworks/test_twisted_client.py new file mode 100644 index 00000000..55b23058 --- /dev/null +++ b/tests/frameworks/test_twisted_client.py @@ -0,0 +1,290 @@ +# (c) Copyright IBM Corp. 2026 + +import threading +import time +from typing import Generator, Optional +from urllib.parse import urlencode + +import pytest +from twisted.internet import reactor +from twisted.web.client import Agent +from twisted.web.http_headers import Headers + +import tests.apps.twisted_server # noqa: F401 +from instana.singletons import agent, get_tracer +from instana.span.span import get_current_span +from tests.helpers import get_first_span_by_name, testenv + + +class TestTwistedClient: + @pytest.fixture(autouse=True) + def _resource(self) -> Generator[None, None, None]: + """Clear all spans before a test run and restore agent options after.""" + self.tracer = get_tracer() + self.recorder = self.tracer.span_processor + self.recorder.clear_spans() + original_extra_http_headers = agent.options.extra_http_headers + yield + agent.options.extra_http_headers = original_extra_http_headers + + def _make_request( + self, + path: str, + method: str = "GET", + headers: Optional[dict] = None, + params: Optional[dict] = None, + ) -> tuple[object, object]: + """Run a Twisted Agent request from within a test span.""" + result_holder = {} + error_holder = {} + + def run_in_reactor() -> object: + def on_response(response: object) -> None: + result_holder["response"] = response + + def on_error(failure: object) -> None: + error_holder["failure"] = failure + + twisted_headers = Headers({}) + if headers: + for k, v in headers.items(): + twisted_headers.setRawHeaders(k, [v]) + + agent_obj = Agent(reactor) + + url = (testenv["twisted_server"] + path).encode("utf-8") + if params: + url = ( + testenv["twisted_server"] + path + "?" + urlencode(params) + ).encode("utf-8") + + d = agent_obj.request(method.encode("utf-8"), url, twisted_headers, None) + d.addCallbacks(on_response, on_error) + return d + + event = threading.Event() + + def run() -> None: + with self.tracer.start_as_current_span("test"): + d = run_in_reactor() + + def done(result: object) -> object: + event.set() + return result + + d.addBoth(done) + + reactor.callFromThread(run) + event.wait(timeout=5) + + return result_holder.get("response"), error_holder.get("failure") + + @pytest.mark.parametrize( + "path, method, status", + [ + ("/", "GET", 200), + ("/", "POST", 200), + ("/301", "GET", 301), + ("/404", "GET", 404), + ], + ) + def test_basic_request(self, path: str, method: str, status: int) -> None: + response, failure = self._make_request(path, method=method) + + assert failure is None + assert response is not None + assert response.code == status + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + server_span = get_first_span_by_name(spans, "twisted-server") + client_span = get_first_span_by_name(spans, "twisted-client") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert client_span.t == traceId + assert server_span.t == traceId + + # Parent relationships: test → client → server + assert client_span.p == test_span.s + assert server_span.p == client_span.s + + # No errors on any span + assert not test_span.ec + assert not client_span.ec + assert not server_span.ec + + # Client span attributes + assert client_span.data["http"]["status"] == status + assert client_span.data["http"]["method"] == method + assert client_span.data["http"]["url"] == testenv["twisted_server"] + path + + def test_get_500(self) -> None: + response, failure = self._make_request("/500") + + assert failure is None + assert response is not None + assert response.code == 500 + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + server_span = get_first_span_by_name(spans, "twisted-server") + client_span = get_first_span_by_name(spans, "twisted-client") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert client_span.t == traceId + assert server_span.t == traceId + + # Parent relationships + assert client_span.p == test_span.s + assert server_span.p == client_span.s + + # Error counters + assert not test_span.ec + assert client_span.ec == 1 + assert server_span.ec == 1 + + # Client span attributes + assert client_span.data["http"]["status"] == 500 + assert client_span.data["http"]["method"] == "GET" + assert client_span.data["http"]["url"] == testenv["twisted_server"] + "/500" + assert client_span.data["http"]["error"] == "Internal Server Error" + + def test_get_with_params_to_scrub(self) -> None: + response, failure = self._make_request("/", params={"secret": "yeah"}) + + assert failure is None + assert response is not None + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + client_span = get_first_span_by_name(spans, "twisted-client") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId + assert client_span.t == test_span.t + + # Client span attributes — secret query param must be scrubbed + assert client_span.data["http"]["status"] == 200 + assert client_span.data["http"]["method"] == "GET" + assert client_span.data["http"]["url"] == testenv["twisted_server"] + "/" + assert client_span.data["http"]["params"] == "secret=" + + def test_request_header_capture(self) -> None: + agent.options.extra_http_headers = ["X-Capture-This", "X-Capture-That"] + + response, failure = self._make_request( + "/", + headers={"X-Capture-This": "this", "X-Capture-That": "that"}, + ) + + assert failure is None + assert response is not None + + time.sleep(0.5) + spans = self.recorder.queued_spans() + + client_span = get_first_span_by_name(spans, "twisted-client") + + # Outgoing request headers must be captured on the client span + assert "X-Capture-This" in client_span.data["http"]["header"] + assert client_span.data["http"]["header"]["X-Capture-This"] == "this" + assert "X-Capture-That" in client_span.data["http"]["header"] + assert client_span.data["http"]["header"]["X-Capture-That"] == "that" + + def test_response_header_capture(self) -> None: + agent.options.extra_http_headers = ["X-Capture-This-Too", "X-Capture-That-Too"] + + response, failure = self._make_request("/response_headers") + + assert failure is None + assert response is not None + + time.sleep(0.5) + spans = self.recorder.queued_spans() + + client_span = get_first_span_by_name(spans, "twisted-client") + + # Response headers received from server must be captured on the client span + assert "X-Capture-This-Too" in client_span.data["http"]["header"] + assert client_span.data["http"]["header"]["X-Capture-This-Too"] == "this too" + assert "X-Capture-That-Too" in client_span.data["http"]["header"] + assert client_span.data["http"]["header"]["X-Capture-That-Too"] == "that too" + + def test_agent_request_without_active_span(self) -> None: + """Agent.request with no active span must skip client instrumentation + (exercises the early-return branch in request_with_instana).""" + result_holder = {} + event = threading.Event() + + def do_request() -> None: + # No active span — parent_span.is_recording() will be False + agent_obj = Agent(reactor) + d = agent_obj.request( + b"GET", + (testenv["twisted_server"] + "/").encode(), + Headers({}), + None, + ) + + def on_response(response: object) -> None: + result_holder["code"] = response.code + event.set() + + def on_error(failure: object) -> None: + result_holder["error"] = str(failure) + event.set() + + d.addCallbacks(on_response, on_error) + + reactor.callFromThread(do_request) + event.wait(timeout=5) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + + # No twisted-client span should be created (no active parent span) + assert get_first_span_by_name(spans, "twisted-client") is None + assert result_holder.get("code") == 200 + + def test_agent_request_network_failure(self) -> None: + """Agent.request to an unreachable host exercises the Failure errback + path in finish_tracing (client.py).""" + event = threading.Event() + + def do_request() -> None: + with self.tracer.start_as_current_span("test"): + agent_obj = Agent(reactor) + # Port 19999 is not listening — connection refused → Failure + d = agent_obj.request( + b"GET", + b"http://127.0.0.1:19999/", + Headers({}), + None, + ) + + def done(_: object) -> None: + event.set() + + d.addBoth(done) + + reactor.callFromThread(do_request) + event.wait(timeout=5) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + + twisted_client_span = get_first_span_by_name(spans, "twisted-client") + + # Failure path must mark the span errored exactly once + assert twisted_client_span.ec == 1 + assert not get_current_span().is_recording() diff --git a/tests/frameworks/test_twisted_server.py b/tests/frameworks/test_twisted_server.py new file mode 100644 index 00000000..c3ce7174 --- /dev/null +++ b/tests/frameworks/test_twisted_server.py @@ -0,0 +1,383 @@ +# (c) Copyright IBM Corp. 2026 + +import time +from typing import Generator + +import pytest +import requests + +import tests.apps.twisted_server # noqa: F401 +from instana.singletons import agent, get_tracer +from instana.util.ids import hex_id +from tests.helpers import get_first_span_by_name, testenv + + +class TestTwistedServer: + @pytest.fixture(autouse=True) + def _resource(self) -> Generator[None, None, None]: + """Clear all spans before a test run and restore agent options after.""" + self.tracer = get_tracer() + self.recorder = self.tracer.span_processor + self.recorder.clear_spans() + original_extra_http_headers = agent.options.extra_http_headers + yield + agent.options.extra_http_headers = original_extra_http_headers + + def test_get(self) -> None: + with self.tracer.start_as_current_span("test"): + response = requests.get(testenv["twisted_server"] + "/") + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + urllib3_span = get_first_span_by_name(spans, "urllib3") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert urllib3_span.t == traceId + assert twisted_span.t == traceId + + # Parent relationships + assert urllib3_span.p == test_span.s + assert twisted_span.p == urllib3_span.s + + # No errors on any span + assert not test_span.ec + assert not urllib3_span.ec + assert not twisted_span.ec + + # Server span attributes + assert twisted_span.data["http"]["status"] == 200 + assert twisted_span.data["http"]["url"] == testenv["twisted_server"] + "/" + assert not twisted_span.data["http"].get("params") + assert twisted_span.data["http"]["method"] == "GET" + assert not twisted_span.stack + + # Synthetic flag + assert not twisted_span.sy + assert not urllib3_span.sy + assert not test_span.sy + + # Correlation headers injected into response + assert "X-INSTANA-T" in response.headers + assert response.headers["X-INSTANA-T"] == hex_id(traceId) + assert "X-INSTANA-S" in response.headers + assert response.headers["X-INSTANA-S"] == hex_id(twisted_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(traceId)}" + + def test_post(self) -> None: + with self.tracer.start_as_current_span("test"): + response = requests.post( + testenv["twisted_server"] + "/", data={"hello": "post"} + ) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + urllib3_span = get_first_span_by_name(spans, "urllib3") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert urllib3_span.t == traceId + assert twisted_span.t == traceId + + # Parent relationships + assert urllib3_span.p == test_span.s + assert twisted_span.p == urllib3_span.s + + # No errors on any span + assert not test_span.ec + assert not urllib3_span.ec + assert not twisted_span.ec + + # Server span attributes + assert twisted_span.data["http"]["status"] == 200 + assert twisted_span.data["http"]["url"] == testenv["twisted_server"] + "/" + assert not twisted_span.data["http"].get("params") + assert twisted_span.data["http"]["method"] == "POST" + assert not twisted_span.stack + + assert "X-INSTANA-T" in response.headers + assert response.headers["X-INSTANA-T"] == hex_id(traceId) + assert "X-INSTANA-S" in response.headers + assert response.headers["X-INSTANA-S"] == hex_id(twisted_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(traceId)}" + + def test_synthetic_request(self) -> None: + with self.tracer.start_as_current_span("test"): + _ = requests.get( + testenv["twisted_server"] + "/", + headers={"X-INSTANA-SYNTHETIC": "1"}, + ) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + urllib3_span = get_first_span_by_name(spans, "urllib3") + test_span = get_first_span_by_name(spans, "sdk") + + assert twisted_span.sy + assert not urllib3_span.sy + assert not test_span.sy + + def test_get_301(self) -> None: + with self.tracer.start_as_current_span("test"): + # Don't follow redirects so we capture the 301 span + _ = requests.get( + testenv["twisted_server"] + "/301", + allow_redirects=False, + ) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + urllib3_span = get_first_span_by_name(spans, "urllib3") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert urllib3_span.t == traceId + assert twisted_span.t == traceId + + # Parent relationships + assert urllib3_span.p == test_span.s + assert twisted_span.p == urllib3_span.s + + # No errors on any span + assert not test_span.ec + assert not urllib3_span.ec + assert not twisted_span.ec + + # Server span attributes + assert twisted_span.data["http"]["status"] == 301 + assert twisted_span.data["http"]["url"] == testenv["twisted_server"] + "/301" + assert not twisted_span.data["http"].get("params") + assert twisted_span.data["http"]["method"] == "GET" + assert not twisted_span.stack + + def test_get_404(self) -> None: + with self.tracer.start_as_current_span("test"): + _ = requests.get(testenv["twisted_server"] + "/404") + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + urllib3_span = get_first_span_by_name(spans, "urllib3") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert urllib3_span.t == traceId + assert twisted_span.t == traceId + + # Parent relationships + assert urllib3_span.p == test_span.s + assert twisted_span.p == urllib3_span.s + + # 404 is a client error — no span should be marked errored + assert not test_span.ec + assert not urllib3_span.ec + assert not twisted_span.ec + + # Server span attributes + assert twisted_span.data["http"]["status"] == 404 + assert twisted_span.data["http"]["url"] == testenv["twisted_server"] + "/404" + assert twisted_span.data["http"]["method"] == "GET" + assert not twisted_span.stack + + def test_get_500(self) -> None: + with self.tracer.start_as_current_span("test"): + _ = requests.get(testenv["twisted_server"] + "/500") + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + urllib3_span = get_first_span_by_name(spans, "urllib3") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId across all spans + traceId = test_span.t + assert urllib3_span.t == traceId + assert twisted_span.t == traceId + + # Parent relationships + assert urllib3_span.p == test_span.s + assert twisted_span.p == urllib3_span.s + + # 500 must mark both server and upstream urllib3 span as errored + assert not test_span.ec + assert urllib3_span.ec == 1 + assert twisted_span.ec == 1 + + # Server span attributes + assert twisted_span.data["http"]["status"] == 500 + assert twisted_span.data["http"]["url"] == testenv["twisted_server"] + "/500" + assert twisted_span.data["http"]["method"] == "GET" + assert not twisted_span.stack + assert twisted_span.data["http"]["error"] == "Internal Server Error" + + def test_get_with_params_to_scrub(self) -> None: + with self.tracer.start_as_current_span("test"): + _ = requests.get( + testenv["twisted_server"] + "/", + params={"secret": "yeah"}, + ) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) == 3 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + test_span = get_first_span_by_name(spans, "sdk") + + # Same traceId + assert twisted_span.t == test_span.t + + # Server span attributes — secret query param must be scrubbed + assert twisted_span.data["http"]["status"] == 200 + assert twisted_span.data["http"]["url"] == testenv["twisted_server"] + "/" + assert twisted_span.data["http"]["params"] == "secret=" + assert twisted_span.data["http"]["method"] == "GET" + assert not twisted_span.stack + + def test_request_header_capture(self) -> None: + agent.options.extra_http_headers = ["X-Capture-This", "X-Capture-That"] + + with self.tracer.start_as_current_span("test"): + _ = requests.get( + testenv["twisted_server"] + "/", + params={"secret": "iloveyou"}, + headers={"X-Capture-This": "this", "X-Capture-That": "that"}, + ) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + + twisted_span = get_first_span_by_name(spans, "twisted-server") + + # Incoming request headers must be captured on the server span + assert "X-Capture-This" in twisted_span.data["http"]["header"] + assert twisted_span.data["http"]["header"]["X-Capture-This"] == "this" + assert "X-Capture-That" in twisted_span.data["http"]["header"] + assert twisted_span.data["http"]["header"]["X-Capture-That"] == "that" + + def test_response_header_capture(self) -> None: + agent.options.extra_http_headers = ["X-Capture-This-Too", "X-Capture-That-Too"] + + with self.tracer.start_as_current_span("test"): + _ = requests.get( + testenv["twisted_server"] + "/response_headers", + params={"secret": "itsasecret"}, + ) + + time.sleep(0.5) + spans = self.recorder.queued_spans() + + twisted_span = get_first_span_by_name(spans, "twisted-server") + + # Response headers set by the handler must be captured on the server span + assert "X-Capture-This-Too" in twisted_span.data["http"]["header"] + assert twisted_span.data["http"]["header"]["X-Capture-This-Too"] == "this too" + assert "X-Capture-That-Too" in twisted_span.data["http"]["header"] + assert twisted_span.data["http"]["header"]["X-Capture-That-Too"] == "that too" + + def test_no_tracing_context(self) -> None: + """Requests without an active parent span still produce a root twisted-server span.""" + # No start_as_current_span wrapper — simulates an uninstrumented caller + response = requests.get(testenv["twisted_server"] + "/") + + time.sleep(0.5) + spans = self.recorder.queued_spans() + assert len(spans) >= 1 + + twisted_span = get_first_span_by_name(spans, "twisted-server") + + # Server span attributes + assert twisted_span.data["http"]["status"] == 200 + # No parent — this is a root span + assert not twisted_span.p + + # Correlation headers still injected even without a parent + assert "X-INSTANA-T" in response.headers + assert "X-INSTANA-S" in response.headers + assert "Server-Timing" in response.headers + + def test_fetch_propagates_span(self) -> None: + """GET /fetch?url=... triggers an outbound Agent.request inside the Twisted + reactor. Because the server span is attached to the contextvars via + context.attach(), the twisted-client instrumentation finds it as the + current span and produces a full 5-span trace chain: + sdk → urllib3 → twisted-server (/fetch) → twisted-client → twisted-server (/) + """ + with self.tracer.start_as_current_span("test"): + response = requests.get( + testenv["twisted_server"] + "/fetch", + params={"url": testenv["twisted_server"] + "/"}, + ) + + time.sleep(0.5) + assert response.status_code == 200 + + spans = self.recorder.queued_spans() + # sdk + urllib3 (outer) + twisted-server (fetch handler) + # + twisted-client (outbound) + twisted-server (root /) + assert len(spans) == 5 + + test_span = get_first_span_by_name(spans, "sdk") + urllib3_span = get_first_span_by_name(spans, "urllib3") + client_span = get_first_span_by_name(spans, "twisted-client") + + server_spans = [s for s in spans if s.n == "twisted-server"] + assert len(server_spans) == 2 + fetch_server_span = next( + s for s in server_spans if "/fetch" in s.data["http"]["url"] + ) + root_server_span = next( + s for s in server_spans if "/fetch" not in s.data["http"]["url"] + ) + + # All spans share the same traceId + traceId = test_span.t + assert urllib3_span.t == traceId + assert fetch_server_span.t == traceId + assert client_span.t == traceId + assert root_server_span.t == traceId + + # Full parent chain: sdk → urllib3 → fetch-server → client → root-server + assert urllib3_span.p == test_span.s + assert fetch_server_span.p == urllib3_span.s + assert client_span.p == fetch_server_span.s + assert root_server_span.p == client_span.s + + # No errors on any span + assert not test_span.ec + assert not urllib3_span.ec + assert not fetch_server_span.ec + assert not client_span.ec + assert not root_server_span.ec + + # Span-under-test attributes + assert fetch_server_span.data["http"]["status"] == 200 + assert fetch_server_span.data["http"]["method"] == "GET" + assert client_span.data["http"]["status"] == 200 + assert root_server_span.data["http"]["status"] == 200 diff --git a/tests/requirements.txt b/tests/requirements.txt index 4dc2717e..b9420362 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -36,6 +36,7 @@ spyne>=2.14.0; python_version < "3.12" sqlalchemy>=2.0.0 starlette>=0.38.2; python_version == "3.13" tornado>=6.4.1 +twisted>=24.0.0 tracerite<=1.1.1; python_version < "3.9" uvicorn>=0.13.4 urllib3>=1.26.5