Skip to content

Commit 61586e6

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

5 files changed

Lines changed: 748 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: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
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+
10+
try:
11+
from contextlib import contextmanager
12+
from typing import (
13+
TYPE_CHECKING,
14+
Any,
15+
Callable,
16+
Dict,
17+
Generator,
18+
Iterable,
19+
List,
20+
Optional,
21+
Tuple,
22+
)
23+
24+
from opentelemetry import context, trace
25+
from opentelemetry.semconv.trace import SpanAttributes
26+
27+
from werkzeug.wrappers import Request
28+
29+
from instana.log import logger
30+
from instana.propagators.format import Format
31+
from instana.singletons import agent, get_tracer
32+
from instana.util.secrets import strip_secrets_from_query
33+
from instana.util.traceutils import extract_custom_headers
34+
35+
if TYPE_CHECKING:
36+
from instana.span.span import InstanaSpan
37+
38+
class InstanaWerkzeugMiddleware:
39+
"""
40+
Instana WSGI middleware for Werkzeug applications.
41+
42+
This middleware automatically traces Werkzeug-based applications by:
43+
- Extracting trace context from incoming requests
44+
- Creating spans for each request
45+
- Capturing HTTP attributes (method, path, query, headers)
46+
- Injecting trace context into responses
47+
- Handling errors and exceptions
48+
49+
Usage:
50+
from werkzeug.wrappers import Request, Response
51+
from instana.instrumentation.werkzeug import InstanaWerkzeugMiddleware
52+
53+
@Request.application
54+
def application(request):
55+
return Response('Hello World!')
56+
57+
# Wrap with Instana middleware
58+
app = InstanaWerkzeugMiddleware(application)
59+
"""
60+
61+
def __init__(self, app: Callable) -> None:
62+
"""
63+
Initialize the Instana Werkzeug middleware.
64+
65+
Args:
66+
app: The WSGI application to wrap
67+
"""
68+
self.app = app
69+
70+
def __call__(
71+
self, env: Dict[str, Any], start_response: Callable
72+
) -> Iterable[bytes]:
73+
"""
74+
WSGI application interface.
75+
76+
Args:
77+
env: WSGI environment dictionary
78+
start_response: WSGI start_response callable
79+
80+
Returns:
81+
Iterable response body
82+
"""
83+
try:
84+
span, token = _create_span_with_context(env)
85+
wrapped_start_response = _build_start_response(span, start_response)
86+
except Exception as exc:
87+
# Instrumentation failed - fail silently, run original app
88+
logger.debug("werkzeug instrumentation setup failed", exc_info=exc)
89+
return self.app(env, start_response)
90+
91+
try:
92+
iterable = self.app(env, wrapped_start_response)
93+
return _end_span_after_iterating(iterable, span, token)
94+
except Exception as exc:
95+
# App exception - record in span and propagate
96+
try:
97+
if span and span.is_recording():
98+
span.record_exception(exc)
99+
span.end()
100+
if token:
101+
context.detach(token)
102+
except Exception:
103+
logger.debug("werkzeug span cleanup failed", exc_info=True)
104+
raise
105+
106+
def _create_span_with_context(environ: Dict[str, Any]) -> Tuple["InstanaSpan", Any]:
107+
"""
108+
Create and configure a span with context for the request.
109+
110+
Args:
111+
environ: WSGI environment dictionary
112+
113+
Returns:
114+
Tuple of (span, context_token)
115+
"""
116+
tracer = get_tracer()
117+
parent_context = tracer.extract(Format.HTTP_HEADERS, environ)
118+
span = tracer.start_span("wsgi", context=parent_context)
119+
120+
ctx = trace.set_span_in_context(span)
121+
token = context.attach(ctx)
122+
123+
extract_custom_headers(span, environ, format=True)
124+
_set_request_attributes(span, environ)
125+
126+
return span, token
127+
128+
def _build_start_response(
129+
span: "InstanaSpan",
130+
start_response: Callable,
131+
) -> Callable:
132+
"""Create an instrumented start_response callable."""
133+
134+
def new_start_response(
135+
status: str,
136+
headers: List[Tuple[str, str]],
137+
exc_info: Optional[Tuple[Any, Any, Any]] = None,
138+
) -> Callable:
139+
"""Modified start_response with trace context injection."""
140+
try:
141+
extract_custom_headers(span, headers)
142+
tracer = get_tracer()
143+
tracer.inject(
144+
span.context,
145+
Format.HTTP_HEADERS,
146+
headers,
147+
)
148+
149+
status_code = _parse_status_code(status)
150+
if status_code is not None:
151+
if status_code >= 500:
152+
span.mark_as_errored()
153+
span.set_attribute(
154+
SpanAttributes.HTTP_STATUS_CODE,
155+
status_code,
156+
)
157+
158+
return start_response(
159+
status,
160+
_normalize_headers(headers),
161+
exc_info,
162+
)
163+
except Exception:
164+
logger.debug("Error in Werkzeug start_response wrapper", exc_info=True)
165+
return start_response(status, headers, exc_info)
166+
167+
return new_start_response
168+
169+
def _normalize_headers(
170+
headers: List[Tuple[str, Any]],
171+
) -> List[Tuple[str, str]]:
172+
"""
173+
Ensure all header values are strings for WSGI compliance.
174+
175+
Args:
176+
headers: List of (name, value) tuples
177+
178+
Returns:
179+
List of (name, str_value) tuples
180+
"""
181+
return [
182+
(name, value if isinstance(value, str) else str(value))
183+
for name, value in headers
184+
]
185+
186+
def _parse_status_code(status: str) -> Optional[int]:
187+
"""Safely parse the HTTP status code from a WSGI status string."""
188+
try:
189+
return int(status.split()[0])
190+
except (AttributeError, IndexError, TypeError, ValueError):
191+
return None
192+
193+
def _end_span_after_iterating(
194+
iterable: Iterable[bytes],
195+
span: "InstanaSpan",
196+
token: Any,
197+
) -> Iterable[bytes]:
198+
"""
199+
Generator that yields from the iterable and ensures span cleanup.
200+
201+
Args:
202+
iterable: The response iterable from the application
203+
span: The active span
204+
token: The context token
205+
206+
Yields:
207+
Response chunks from the iterable
208+
"""
209+
try:
210+
yield from iterable
211+
finally:
212+
# Ensure iterable cleanup (important for generators)
213+
if hasattr(iterable, "close"):
214+
try:
215+
iterable.close() # type: ignore
216+
except Exception:
217+
logger.debug("Error closing iterable", exc_info=True)
218+
219+
# End span and detach token after iteration completes
220+
if span and span.is_recording():
221+
span.end()
222+
if token:
223+
context.detach(token) # type: ignore
224+
225+
def _scrub_query_params(query_string: str) -> Optional[str]:
226+
"""
227+
Scrub secrets from query string parameters.
228+
229+
Args:
230+
query_string: The query string to scrub
231+
232+
Returns:
233+
Scrubbed query string or None if agent not available
234+
"""
235+
if agent is not None:
236+
return strip_secrets_from_query(
237+
query_string,
238+
agent.options.secrets_matcher, # type: ignore
239+
agent.options.secrets_list, # type: ignore
240+
)
241+
return None
242+
243+
def _set_request_attributes(span: "InstanaSpan", environ: Dict[str, Any]) -> None:
244+
"""
245+
Extract and set HTTP attributes from the WSGI environ.
246+
247+
Args:
248+
span: The active span
249+
environ: WSGI environment dictionary
250+
"""
251+
try:
252+
# Set HTTP method
253+
if "REQUEST_METHOD" in environ:
254+
span.set_attribute(
255+
SpanAttributes.HTTP_METHOD, environ["REQUEST_METHOD"]
256+
)
257+
258+
# Set HTTP path
259+
if "PATH_INFO" in environ:
260+
span.set_attribute("http.path", environ["PATH_INFO"])
261+
262+
# Set HTTP query parameters (with secrets scrubbed)
263+
if environ.get("QUERY_STRING"):
264+
scrubbed_params = _scrub_query_params(environ["QUERY_STRING"])
265+
if scrubbed_params is not None:
266+
span.set_attribute("http.params", scrubbed_params)
267+
268+
# Set HTTP host
269+
if "HTTP_HOST" in environ:
270+
span.set_attribute(
271+
SpanAttributes.HTTP_HOST,
272+
environ["HTTP_HOST"],
273+
)
274+
275+
# Set HTTP URL (without query string to avoid exposing secrets)
276+
if "wsgi.url_scheme" in environ:
277+
scheme = environ["wsgi.url_scheme"]
278+
host = environ.get("HTTP_HOST", "")
279+
script_name = environ.get("SCRIPT_NAME", "")
280+
path = environ.get("PATH_INFO", "")
281+
282+
url = f"{scheme}://{host}{script_name}{path}"
283+
span.set_attribute(SpanAttributes.HTTP_URL, url)
284+
285+
except Exception:
286+
logger.debug("Error setting request attributes", exc_info=True)
287+
288+
# Context manager for manual instrumentation
289+
@contextmanager
290+
def trace_werkzeug_request(
291+
request: Request,
292+
) -> Generator["InstanaSpan", None, None]:
293+
"""
294+
Context manager for manually tracing Werkzeug requests.
295+
296+
Usage:
297+
@Request.application
298+
def application(request):
299+
with trace_werkzeug_request(request) as span:
300+
# Your application logic
301+
span.set_attribute("custom.attribute", "value")
302+
return Response('Hello World!')
303+
304+
Args:
305+
request: Werkzeug Request object
306+
307+
Yields:
308+
The active span for the request
309+
"""
310+
tracer = get_tracer()
311+
span = None
312+
token = None
313+
314+
try:
315+
# Extract context from request environ (preserves multi-value headers)
316+
parent_context = tracer.extract(
317+
Format.HTTP_HEADERS,
318+
request.environ,
319+
)
320+
321+
# Start span
322+
span = tracer.start_span("wsgi", context=parent_context)
323+
ctx = trace.set_span_in_context(span)
324+
token = context.attach(ctx)
325+
326+
# Set request attributes
327+
span.set_attribute(SpanAttributes.HTTP_METHOD, request.method)
328+
span.set_attribute(SpanAttributes.HTTP_URL, request.url)
329+
span.set_attribute(SpanAttributes.HTTP_HOST, request.host)
330+
331+
if request.query_string:
332+
scrubbed_params = _scrub_query_params(
333+
request.query_string.decode("utf-8")
334+
)
335+
if scrubbed_params is not None:
336+
span.set_attribute("http.params", scrubbed_params)
337+
338+
yield span
339+
except Exception as exc:
340+
if span and span.is_recording():
341+
span.record_exception(exc)
342+
raise
343+
finally:
344+
if span and span.is_recording():
345+
span.end()
346+
if token:
347+
context.detach(token)
348+
349+
logger.debug("Instrumenting werkzeug")
350+
351+
except ImportError:
352+
pass
353+
354+
# Made with Bob
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# (c) Copyright IBM Corp. 2026

0 commit comments

Comments
 (0)