diff --git a/src/instana/instrumentation/werkzeug.py b/src/instana/instrumentation/werkzeug.py index 5e4e96bc..366a3b33 100644 --- a/src/instana/instrumentation/werkzeug.py +++ b/src/instana/instrumentation/werkzeug.py @@ -100,6 +100,34 @@ def run_simple_with_instana( return wrapped(*args, **kwargs) + @wrapt.patch_function_wrapper("werkzeug.serving", "BaseWSGIServer.__init__") + def base_wsgi_server_init_with_instana( + wrapped: Callable, + instance: Any, + args: tuple, + kwargs: dict[str, Any], + ) -> Any: + """ + Patch werkzeug.serving.BaseWSGIServer.__init__ to wrap WSGI applications. + + Covers frameworks like Odoo that instantiate BaseWSGIServer (or its + subclasses such as ThreadedWSGIServer) directly without going through + run_simple. The app is wrapped after super().__init__ so that any + subclass setup that reads self.app also sees the instrumented version. + """ + wrapped(*args, **kwargs) + try: + if _is_flask_app(instance.app): + logger.debug("Skipping BaseWSGIServer instrumentation for Flask app") + return + if not isinstance(instance.app, InstanaWSGIMiddleware): + instance.app = InstanaWSGIMiddleware( + instance.app, status_as_string=False + ) + logger.debug("BaseWSGIServer app wrapped") + except Exception: + logger.debug("Failed to wrap BaseWSGIServer app", exc_info=True) + logger.debug("Instrumenting werkzeug") except ImportError: diff --git a/tests/frameworks/test_werkzeug.py b/tests/frameworks/test_werkzeug.py index f7865c59..de9b6250 100644 --- a/tests/frameworks/test_werkzeug.py +++ b/tests/frameworks/test_werkzeug.py @@ -601,6 +601,68 @@ def __call__(self, environ, start_response): assert call_args[2] is flask_app assert not isinstance(call_args[2], InstanaWSGIMiddleware) + def test_base_wsgi_server_direct_instantiation(self) -> None: + """Test instrumentation when BaseWSGIServer is instantiated directly (e.g. Odoo). + + Odoo's ThreadedWSGIServerReloadable extends werkzeug.serving.ThreadedWSGIServer + which extends BaseWSGIServer, bypassing run_simple entirely. This test verifies + that the BaseWSGIServer.__init__ patch wraps the app in that case. + """ + import socket + from werkzeug.serving import BaseWSGIServer + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + server = BaseWSGIServer("127.0.0.1", port, simple_wsgi_app) + try: + assert isinstance(server.app, InstanaWSGIMiddleware) + assert server.app.app is simple_wsgi_app + finally: + server.server_close() + + def test_base_wsgi_server_skips_flask_app(self) -> None: + """Test that BaseWSGIServer patch skips Flask apps.""" + import socket + from werkzeug.serving import BaseWSGIServer + + class Flask: + def __call__(self, environ, start_response): + return simple_wsgi_app(environ, start_response) + + Flask.__module__ = "flask.app" + flask_app = Flask() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + server = BaseWSGIServer("127.0.0.1", port, flask_app) + try: + assert server.app is flask_app + assert not isinstance(server.app, InstanaWSGIMiddleware) + finally: + server.server_close() + + def test_base_wsgi_server_not_double_wrapped(self) -> None: + """Test that an already-wrapped app is not wrapped again.""" + import socket + from werkzeug.serving import BaseWSGIServer + + pre_wrapped = InstanaWSGIMiddleware(simple_wsgi_app, status_as_string=False) + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + server = BaseWSGIServer("127.0.0.1", port, pre_wrapped) + try: + assert server.app is pre_wrapped + assert not isinstance(server.app.app, InstanaWSGIMiddleware) + finally: + server.server_close() + def test_parse_status_code_handles_valid_and_invalid_values() -> None: """Test safe parsing of WSGI status strings."""