Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ uv.lock

# Sandbox
sandbox/
.bob/
6 changes: 6 additions & 0 deletions src/instana/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Empty file.
128 changes: 128 additions & 0 deletions src/instana/instrumentation/twisted/client.py
Original file line number Diff line number Diff line change
@@ -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
147 changes: 147 additions & 0 deletions src/instana/instrumentation/twisted/server.py
Original file line number Diff line number Diff line change
@@ -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
)
Comment thread
pvital marked this conversation as resolved.
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
4 changes: 4 additions & 0 deletions src/instana/span/kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"httpx",
"tornado-client",
"tornado-server",
"twisted-client",
"twisted-server",
"urllib3",
"wsgi",
"asgi",
Expand All @@ -31,6 +33,7 @@
"rabbitmq",
"rpc-server",
"tornado-server",
"twisted-server",
"gcps-consumer",
"asgi",
"kafka-consumer",
Expand All @@ -57,6 +60,7 @@
"sqlalchemy",
"s3",
"tornado-client",
"twisted-client",
"urllib3",
"pymongo",
"gcs",
Expand Down
30 changes: 30 additions & 0 deletions tests/apps/twisted_server/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading