Skip to content

Commit c277746

Browse files
committed
feat: Add werkzeug instrumentation and unittests
Signed-off-by: Cagri Yonca <cagri@ibm.com>
1 parent c60f7c9 commit c277746

3 files changed

Lines changed: 623 additions & 0 deletions

File tree

src/instana/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def boot_agent() -> None:
184184
sqlalchemy, # noqa: F401
185185
starlette, # noqa: F401
186186
urllib3, # noqa: F401
187+
werkzeug, # noqa: F401
187188
gevent, # noqa: F401
188189
)
189190
from instana.instrumentation.aiohttp import (
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
# (c) Copyright IBM Corp. 2026
2+
3+
"""
4+
Instana Werkzeug Instrumentation
5+
6+
This module provides automatic instrumentation for Werkzeug-based applications.
7+
Werkzeug is a comprehensive WSGI web application library used by Flask and other frameworks.
8+
9+
This module automatically patches Werkzeug applications when imported via autowrapt.
10+
"""
11+
12+
try:
13+
from typing import (
14+
TYPE_CHECKING,
15+
Any,
16+
Callable,
17+
Iterable,
18+
Optional,
19+
)
20+
21+
import wrapt
22+
from opentelemetry import context, trace
23+
from opentelemetry.semconv.trace import SpanAttributes
24+
25+
from instana.log import logger
26+
from instana.propagators.format import Format
27+
from instana.singletons import agent, get_tracer
28+
from instana.util.secrets import strip_secrets_from_query
29+
from instana.util.traceutils import extract_custom_headers
30+
31+
if TYPE_CHECKING:
32+
from instana.span.span import InstanaSpan
33+
34+
def _create_span_with_context(environ: dict[str, Any]) -> tuple["InstanaSpan", Any]:
35+
"""
36+
Create and configure a span with context for the request.
37+
38+
Args:
39+
environ: WSGI environment dictionary
40+
41+
Returns:
42+
Tuple of (span, context_token)
43+
"""
44+
tracer = get_tracer()
45+
parent_context = tracer.extract(Format.HTTP_HEADERS, environ)
46+
span = tracer.start_span("wsgi", context=parent_context)
47+
48+
ctx = trace.set_span_in_context(span)
49+
token = context.attach(ctx)
50+
51+
extract_custom_headers(span, environ, format=True)
52+
_set_request_attributes(span, environ)
53+
54+
return span, token
55+
56+
def _build_start_response(
57+
span: "InstanaSpan",
58+
start_response: Callable,
59+
) -> Callable:
60+
"""Create an instrumented start_response callable."""
61+
62+
def new_start_response(
63+
status: str,
64+
headers: list[tuple[str, str]],
65+
exc_info: Optional[tuple[Any, Any, Any]] = None,
66+
) -> Callable:
67+
"""Modified start_response with trace context injection."""
68+
try:
69+
extract_custom_headers(span, headers)
70+
tracer = get_tracer()
71+
tracer.inject(
72+
span.context,
73+
Format.HTTP_HEADERS,
74+
headers,
75+
)
76+
77+
status_code = _parse_status_code(status)
78+
if status_code is not None:
79+
if status_code >= 500:
80+
span.mark_as_errored()
81+
span.set_attribute(
82+
SpanAttributes.HTTP_STATUS_CODE,
83+
status_code,
84+
)
85+
86+
return start_response(
87+
status,
88+
_normalize_headers(headers),
89+
exc_info,
90+
)
91+
except Exception:
92+
logger.debug("Error in Werkzeug start_response wrapper", exc_info=True)
93+
return start_response(status, headers, exc_info)
94+
95+
return new_start_response
96+
97+
def _normalize_headers(
98+
headers: list[tuple[str, Any]],
99+
) -> list[tuple[str, str]]:
100+
"""
101+
Ensure all header values are strings for WSGI compliance.
102+
103+
Args:
104+
headers: List of (name, value) tuples
105+
106+
Returns:
107+
List of (name, str_value) tuples
108+
"""
109+
return [
110+
(name, value if isinstance(value, str) else str(value))
111+
for name, value in headers
112+
]
113+
114+
def _parse_status_code(status: str) -> Optional[int]:
115+
"""Safely parse the HTTP status code from a WSGI status string."""
116+
try:
117+
return int(status.split()[0])
118+
except (AttributeError, IndexError, TypeError, ValueError):
119+
return None
120+
121+
def _end_span_after_iterating(
122+
iterable: Iterable[bytes],
123+
span: "InstanaSpan",
124+
token: Any,
125+
) -> Iterable[bytes]:
126+
"""
127+
Generator that yields from the iterable and ensures span cleanup.
128+
129+
Args:
130+
iterable: The response iterable from the application
131+
span: The active span
132+
token: The context token
133+
134+
Yields:
135+
Response chunks from the iterable
136+
"""
137+
try:
138+
yield from iterable
139+
finally:
140+
# Ensure iterable cleanup (important for generators)
141+
if hasattr(iterable, "close"):
142+
try:
143+
iterable.close() # type: ignore
144+
except Exception:
145+
logger.debug("Error closing iterable", exc_info=True)
146+
147+
# End span and detach token after iteration completes
148+
if span and span.is_recording():
149+
span.end()
150+
if token:
151+
context.detach(token) # type: ignore
152+
153+
def _scrub_query_params(query_string: str) -> Optional[str]:
154+
"""
155+
Scrub secrets from query string parameters.
156+
157+
Args:
158+
query_string: The query string to scrub
159+
160+
Returns:
161+
Scrubbed query string or None if agent not available
162+
"""
163+
if agent is not None:
164+
return strip_secrets_from_query(
165+
query_string,
166+
agent.options.secrets_matcher, # type: ignore
167+
agent.options.secrets_list, # type: ignore
168+
)
169+
return None
170+
171+
def _set_request_attributes(span: "InstanaSpan", environ: dict[str, Any]) -> None:
172+
"""
173+
Extract and set HTTP attributes from the WSGI environ.
174+
175+
Args:
176+
span: The active span
177+
environ: WSGI environment dictionary
178+
"""
179+
try:
180+
# Set HTTP method
181+
if "REQUEST_METHOD" in environ:
182+
span.set_attribute(
183+
SpanAttributes.HTTP_METHOD, environ["REQUEST_METHOD"]
184+
)
185+
186+
# Set HTTP path
187+
if "PATH_INFO" in environ:
188+
span.set_attribute("http.path", environ["PATH_INFO"])
189+
190+
# Set HTTP query parameters (with secrets scrubbed)
191+
if environ.get("QUERY_STRING"):
192+
scrubbed_params = _scrub_query_params(environ["QUERY_STRING"])
193+
if scrubbed_params is not None:
194+
span.set_attribute("http.params", scrubbed_params)
195+
196+
# Set HTTP host
197+
if "HTTP_HOST" in environ:
198+
span.set_attribute(
199+
SpanAttributes.HTTP_HOST,
200+
environ["HTTP_HOST"],
201+
)
202+
203+
# Set HTTP URL (without query string to avoid exposing secrets)
204+
if "wsgi.url_scheme" in environ:
205+
scheme = environ["wsgi.url_scheme"]
206+
host = environ.get("HTTP_HOST", "")
207+
script_name = environ.get("SCRIPT_NAME", "")
208+
path = environ.get("PATH_INFO", "")
209+
210+
url = f"{scheme}://{host}{script_name}{path}"
211+
span.set_attribute(SpanAttributes.HTTP_URL, url)
212+
213+
except Exception:
214+
logger.debug("Error setting request attributes", exc_info=True)
215+
216+
# Autowrapt patching for automatic instrumentation
217+
class _TracedWSGIApp:
218+
"""Wrapper that traces WSGI applications."""
219+
220+
def __init__(self, app: Callable) -> None:
221+
self.app = app
222+
223+
def __call__(self, environ: dict[str, Any], start_response: Callable) -> Any:
224+
try:
225+
span, token = _create_span_with_context(environ)
226+
wrapped_start_response = _build_start_response(span, start_response)
227+
except Exception:
228+
logger.debug("werkzeug setup failed", exc_info=True)
229+
return self.app(environ, start_response)
230+
231+
try:
232+
iterable = self.app(environ, wrapped_start_response)
233+
return _end_span_after_iterating(iterable, span, token)
234+
except Exception as exc:
235+
try:
236+
if span and span.is_recording():
237+
span.record_exception(exc)
238+
span.end()
239+
if token:
240+
context.detach(token)
241+
except Exception:
242+
logger.debug("werkzeug cleanup failed", exc_info=True)
243+
raise
244+
245+
@wrapt.patch_function_wrapper("werkzeug.serving", "run_simple")
246+
def run_simple_with_instana(
247+
wrapped: Callable,
248+
instance: Any,
249+
args: tuple,
250+
kwargs: dict[str, Any],
251+
) -> Any:
252+
"""Patch werkzeug.serving.run_simple to wrap WSGI applications."""
253+
try:
254+
# run_simple(hostname, port, application, ...)
255+
if len(args) >= 3:
256+
hostname, port, application = args[0], args[1], args[2]
257+
instrumented_app = _TracedWSGIApp(application)
258+
logger.debug(f"Werkzeug app wrapped: {hostname}:{port}")
259+
args = (hostname, port, instrumented_app) + args[3:]
260+
elif "application" in kwargs:
261+
application = kwargs["application"]
262+
instrumented_app = _TracedWSGIApp(application)
263+
kwargs["application"] = instrumented_app
264+
logger.debug("Werkzeug app wrapped (kwargs)")
265+
except Exception:
266+
logger.debug("Failed to wrap Werkzeug app", exc_info=True)
267+
268+
return wrapped(*args, **kwargs)
269+
270+
logger.debug("Instrumenting werkzeug")
271+
272+
except ImportError:
273+
pass
274+
275+
# Made with Bob

0 commit comments

Comments
 (0)