Skip to content

Commit 68742fb

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

10 files changed

Lines changed: 1746 additions & 93 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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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 Any, Callable
14+
15+
import wrapt
16+
from opentelemetry import context
17+
18+
from instana.log import logger
19+
from instana.util.wsgi_utils import (
20+
build_start_response,
21+
create_span_with_context,
22+
end_span_after_iterating,
23+
)
24+
25+
# Autowrapt patching for automatic instrumentation
26+
class _TracedWSGIApp:
27+
"""Wrapper that traces WSGI applications."""
28+
29+
def __init__(self, app: Callable) -> None:
30+
self.app = app
31+
32+
def __call__(self, environ: dict[str, Any], start_response: Callable) -> Any:
33+
try:
34+
span, token = create_span_with_context(environ)
35+
wrapped_start_response = build_start_response(span, start_response)
36+
except Exception:
37+
logger.debug("werkzeug setup failed", exc_info=True)
38+
return self.app(environ, start_response)
39+
40+
try:
41+
iterable = self.app(environ, wrapped_start_response)
42+
return end_span_after_iterating(iterable, span, token)
43+
except Exception as exc:
44+
try:
45+
if span and span.is_recording():
46+
span.record_exception(exc)
47+
span.end()
48+
if token:
49+
context.detach(token)
50+
except Exception:
51+
logger.debug("werkzeug cleanup failed", exc_info=True)
52+
raise
53+
54+
def _is_flask_app(app: Any) -> bool:
55+
"""
56+
Check if the application is a Flask app.
57+
58+
Flask apps have their own instrumentation, so we skip wrapping them
59+
to avoid double instrumentation (2 spans per request).
60+
61+
Args:
62+
app: The WSGI application to check
63+
64+
Returns:
65+
True if app is a Flask application, False otherwise
66+
"""
67+
try:
68+
# Check if it's a Flask app by class name
69+
if hasattr(app, "__class__"):
70+
class_name = app.__class__.__name__
71+
module_name = getattr(app.__class__, "__module__", "")
72+
73+
# Direct Flask app check
74+
if class_name == "Flask" and "flask" in module_name:
75+
return True
76+
77+
# Check for Flask app wrapped in middleware
78+
if hasattr(app, "wsgi_app"):
79+
return _is_flask_app(app.wsgi_app)
80+
81+
return False
82+
except Exception:
83+
logger.debug("Error checking if app is Flask", exc_info=True)
84+
return False
85+
86+
@wrapt.patch_function_wrapper("werkzeug.serving", "run_simple")
87+
def run_simple_with_instana(
88+
wrapped: Callable,
89+
instance: Any,
90+
args: tuple,
91+
kwargs: dict[str, Any],
92+
) -> Any:
93+
"""
94+
Patch werkzeug.serving.run_simple to wrap WSGI applications.
95+
96+
Skips Flask applications as they have their own instrumentation.
97+
"""
98+
try:
99+
# run_simple(hostname, port, application, ...)
100+
if len(args) >= 3:
101+
hostname, port, application = args[0], args[1], args[2]
102+
103+
# Skip Flask apps (they have their own instrumentation)
104+
if _is_flask_app(application):
105+
logger.debug(
106+
f"Skipping Werkzeug instrumentation for Flask app at {hostname}:{port}"
107+
)
108+
return wrapped(*args, **kwargs)
109+
110+
# Wrap non-Flask WSGI apps
111+
instrumented_app = _TracedWSGIApp(application)
112+
logger.debug(f"Werkzeug app wrapped: {hostname}:{port}")
113+
args = (hostname, port, instrumented_app) + args[3:]
114+
elif "application" in kwargs:
115+
application = kwargs["application"]
116+
117+
# Skip Flask apps (they have their own instrumentation)
118+
if _is_flask_app(application):
119+
logger.debug(
120+
"Skipping Werkzeug instrumentation for Flask app (kwargs)"
121+
)
122+
return wrapped(*args, **kwargs)
123+
124+
# Wrap non-Flask WSGI apps
125+
instrumented_app = _TracedWSGIApp(application)
126+
kwargs["application"] = instrumented_app
127+
logger.debug("Werkzeug app wrapped (kwargs)")
128+
except Exception:
129+
logger.debug("Failed to wrap Werkzeug app", exc_info=True)
130+
131+
return wrapped(*args, **kwargs)
132+
133+
logger.debug("Instrumenting werkzeug")
134+
135+
except ImportError:
136+
pass
137+
138+
# Made with Bob

src/instana/instrumentation/wsgi.py

Lines changed: 18 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -5,114 +5,39 @@
55
Instana WSGI Middleware
66
"""
77

8-
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple
8+
from typing import Any, Callable
99

10-
from opentelemetry import context, trace
11-
from opentelemetry.semconv.trace import SpanAttributes
10+
from opentelemetry import context
1211

13-
from instana.propagators.format import Format
14-
from instana.singletons import agent, get_tracer
15-
from instana.util.secrets import strip_secrets_from_query
16-
from instana.util.traceutils import extract_custom_headers
17-
18-
if TYPE_CHECKING:
19-
from instana.span.span import InstanaSpan
12+
from instana.util.wsgi_utils import (
13+
build_start_response,
14+
create_span_with_context,
15+
end_span_after_iterating,
16+
)
2017

2118

2219
class InstanaWSGIMiddleware(object):
2320
"""Instana WSGI middleware"""
2421

25-
def __init__(self, app: object) -> None:
22+
def __init__(self, app: Callable) -> None:
2623
self.app = app
2724

28-
def __call__(self, environ: Dict[str, Any], start_response: Callable) -> object:
29-
env = environ
30-
tracer = get_tracer()
31-
32-
# Extract context and start span
33-
parent_context = tracer.extract(Format.HTTP_HEADERS, env)
34-
span = tracer.start_span("wsgi", context=parent_context)
35-
36-
# Attach context - this makes the span current
37-
ctx = trace.set_span_in_context(span)
38-
token = context.attach(ctx)
39-
40-
# Extract custom headers from request
41-
extract_custom_headers(span, env, format=True)
42-
43-
# Set request attributes
44-
_set_request_attributes(span, env)
45-
46-
def new_start_response(
47-
status: str,
48-
headers: List[Tuple[object, ...]],
49-
exc_info: Optional[Exception] = None,
50-
) -> object:
51-
"""Modified start response with additional headers."""
52-
extract_custom_headers(span, headers)
53-
54-
tracer.inject(span.context, Format.HTTP_HEADERS, headers)
55-
56-
headers_str = [
57-
(header[0], str(header[1]))
58-
if not isinstance(header[1], str)
59-
else header
60-
for header in headers
61-
]
62-
63-
# Set status code attribute
64-
sc = status.split(" ")[0]
65-
if int(sc) >= 500:
66-
span.mark_as_errored()
67-
68-
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, sc)
69-
70-
return start_response(status, headers_str, exc_info)
71-
25+
def __call__(self, environ: dict[str, Any], start_response: Callable) -> object:
7226
try:
73-
iterable = self.app(environ, new_start_response)
74-
75-
# Wrap the iterable to ensure span ends after iteration completes
76-
return _end_span_after_iterating(iterable, span, token)
27+
span, token = create_span_with_context(environ)
28+
wrapped_start_response = build_start_response(
29+
span, start_response, status_as_string=True
30+
)
31+
except Exception:
32+
return self.app(environ, start_response)
7733

34+
try:
35+
iterable = self.app(environ, wrapped_start_response)
36+
return end_span_after_iterating(iterable, span, token)
7837
except Exception as exc:
79-
# If exception occurs before iteration completes, end span and detach token
8038
if span and span.is_recording():
8139
span.record_exception(exc)
8240
span.end()
8341
if token:
8442
context.detach(token)
8543
raise exc
86-
87-
88-
def _end_span_after_iterating(
89-
iterable: Iterable[object], span: "InstanaSpan", token: object
90-
) -> Iterable[object]:
91-
try:
92-
yield from iterable
93-
finally:
94-
# Ensure iterable cleanup (important for generators)
95-
if hasattr(iterable, "close"):
96-
iterable.close()
97-
98-
# End span and detach token after iteration completes
99-
if span and span.is_recording():
100-
span.end()
101-
if token:
102-
context.detach(token)
103-
104-
105-
def _set_request_attributes(span: "InstanaSpan", env: Dict[str, Any]) -> None:
106-
if "PATH_INFO" in env:
107-
span.set_attribute("http.path", env["PATH_INFO"])
108-
if "QUERY_STRING" in env and len(env["QUERY_STRING"]):
109-
scrubbed_params = strip_secrets_from_query(
110-
env["QUERY_STRING"],
111-
agent.options.secrets_matcher,
112-
agent.options.secrets_list,
113-
)
114-
span.set_attribute("http.params", scrubbed_params)
115-
if "REQUEST_METHOD" in env:
116-
span.set_attribute(SpanAttributes.HTTP_METHOD, env["REQUEST_METHOD"])
117-
if "HTTP_HOST" in env:
118-
span.set_attribute(SpanAttributes.HTTP_HOST, env["HTTP_HOST"])

0 commit comments

Comments
 (0)