diff --git a/.gitignore b/.gitignore index 149fc092..588c6b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ uv.lock # Sandbox sandbox/ .bob/ +.serena/ \ No newline at end of file diff --git a/src/instana/__init__.py b/src/instana/__init__.py index 3df5cdb1..142676d6 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -14,8 +14,6 @@ import os import sys from importlib import util as importlib_util -from typing import Tuple - from instana.collector.helpers.runtime import ( is_autowrapt_instrumented, is_webhook_instrumented, @@ -59,10 +57,7 @@ def load(_: object) -> None: - """ - Method used to activate the Instana sensor via AUTOWRAPT_BOOTSTRAP - environment variable. - """ + """Activate the Instana Tracer via the AUTOWRAPT_BOOTSTRAP environment variable.""" # Work around https://bugs.python.org/issue32573 if not hasattr(sys, "argv"): sys.argv = [""] @@ -100,16 +95,105 @@ def key_to_bool(k: str) -> bool: monkey.patch_all() -def get_aws_lambda_handler() -> Tuple[str, str]: +# Guards against boot_agent() being called more than once when monkey_patch() +# is invoked multiple times in the same process (e.g. application code also +# calls it after Instana has already wrapped it). +_eventlet_booted = False + + +def _defer_boot_until_eventlet_patch() -> None: + """Defer boot_agent() until after eventlet.monkey_patch() has been called. + + Strategy + -------- + Two ``wrapt.when_imported`` hooks are always registered — one for the + gunicorn eventlet worker path and one for the bare ``monkey_patch()`` path. + The decision of *which* hook actually boots is made at runtime by checking + ``sys.modules``, not by inspecting whether gunicorn is installed: + + Hook 1 — ``gunicorn.workers.geventlet`` (gunicorn -k eventlet): + Wraps ``EventletWorker.patch()``, which gunicorn calls only post-fork + inside the worker process (``init_process → self.patch()``). + ``when_imported`` is a no-op if the module is never loaded, so + gunicorn ≥26 (which removed this worker) and non-eventlet worker + classes never trigger it. + + Hook 2 — ``eventlet.monkey_patch`` (bare application use): + Fires for every other caller (plain scripts, ``socketio.run()``, + gunicorn ≥26, etc.). If Hook 1 already booted the agent — i.e. + ``gunicorn.workers.geventlet`` is in ``sys.modules`` — this hook + skips the boot so the arbiter is never initialised when + ``--preload`` causes ``monkey_patch()`` to be called there. + + Why ``sys.modules`` instead of ``find_spec("gunicorn")``: + ``find_spec`` answers "is gunicorn *installed*?", not "is this + process running under gunicorn's eventlet worker?". A gunicorn ≥26 + installation, or any non-eventlet worker, would suppress Hook 2 + with the old ``find_spec`` branch, leaving no boot path at all. """ - For instrumenting AWS Lambda, users specify their original lambda handler - in the LAMBDA_HANDLER environment variable. This function searches for and - parses that environment variable or returns the defaults. + import wrapt + + def _boot_once() -> None: + global _eventlet_booted + if not _eventlet_booted: + _eventlet_booted = True + if is_truthy(os.environ.get("INSTANA_AUTOPROFILE", None)): + _start_profiler() + boot_agent() + + # Hook 1: gunicorn -k eventlet — boots in the worker, after monkey_patch(). + # Gunicorn resolves worker_class in Arbiter.setup() before preload_app, so + # geventlet is imported in the arbiter. when_imported fires there and wraps + # EventletWorker.patch; the wrapper itself runs post-fork in the worker. + @wrapt.when_imported("gunicorn.workers.geventlet") + def _on_geventlet_imported(module: object) -> None: + def _after_worker_patch( + wrapped: object, + instance: object, + args: tuple[object, ...], + kwargs: dict[str, object], + ) -> object: + result = wrapped(*args, **kwargs) + _boot_once() + return result + + wrapt.wrap_function_wrapper( + module, + "EventletWorker.patch", + _after_worker_patch, + ) + + # Hook 2: bare monkey_patch() call — covers every non-gunicorn scenario and + # gunicorn ≥26 / non-eventlet-worker setups. Skips boot when Hook 1 already + # handled it (geventlet in sys.modules) to prevent arbiter-side boot under + # --preload. + @wrapt.when_imported("eventlet") + def _on_eventlet_imported(module: object) -> None: + def _after_monkey_patch( + wrapped: object, + instance: object, + args: tuple[object, ...], + kwargs: dict[str, object], + ) -> object: + result = wrapped(*args, **kwargs) + if "gunicorn.workers.geventlet" not in sys.modules: + _boot_once() + return result + + wrapt.wrap_function_wrapper(module, "monkey_patch", _after_monkey_patch) + + +def get_aws_lambda_handler() -> tuple[str, str]: + """Return the AWS Lambda handler module and function name. + + Users specify their original lambda handler in the LAMBDA_HANDLER + environment variable. This function searches for and parses that + environment variable or returns the defaults. The default handler value for AWS Lambda is 'lambda_function.lambda_handler' - which equates to the function "lambda_handler in a file named - lambda_function.py" or in Python terms - "from lambda_function import lambda_handler" + which equates to the function ``lambda_handler`` in a file named + ``lambda_function.py``, or in Python terms + ``from lambda_function import lambda_handler``. """ handler_module = "lambda_function" handler_function = "lambda_handler" @@ -126,11 +210,10 @@ def get_aws_lambda_handler() -> Tuple[str, str]: def lambda_handler(event: str, context: str) -> None: - """ - Entry point for AWS Lambda monitoring. + """Entry point for AWS Lambda monitoring. - This function will trigger the initialization of Instana monitoring and then call - the original user specified lambda handler function. + Triggers the initialization of Instana monitoring and then calls + the original user-specified lambda handler function. """ module_name, function_name = get_aws_lambda_handler() @@ -153,7 +236,10 @@ def lambda_handler(event: str, context: str) -> None: def boot_agent() -> None: - """Initialize the Instana agent and conditionally load auto-instrumentation.""" + """Initialize the Instana agent and conditionally load auto-instrumentation. + + Imports all instrumentation modules unless INSTANA_DISABLE_AUTO_INSTR is set. + """ import instana.singletons # noqa: F401 @@ -169,6 +255,7 @@ def boot_agent() -> None: elasticsearch, # noqa: F401 fastapi, # noqa: F401 flask, # noqa: F401 + gevent, # noqa: F401 grpcio, # noqa: F401 httpx, # noqa: F401 logging, # noqa: F401 @@ -187,7 +274,6 @@ def boot_agent() -> None: starlette, # noqa: F401 urllib3, # noqa: F401 werkzeug, # noqa: F401 - gevent, # noqa: F401 ) from instana.instrumentation.aiohttp import ( client as aiohttp_client, # noqa: F401 @@ -223,7 +309,10 @@ def boot_agent() -> None: def _start_profiler() -> None: - """Start the Instana Auto Profile.""" + """Start the Instana Auto Profile. + + Retrieves the profiler singleton and starts it if available. + """ from instana.singletons import get_profiler if profiler := get_profiler(): @@ -251,17 +340,36 @@ def _start_profiler() -> None: f"Instana: No use in monitoring this process type ({os.path.basename(sys.argv[0])}). Will go sit in a corner quietly." ) else: - # Automatic gevent monkey patching - # unless auto instrumentation is off, then the customer should do manual gevent monkey patching if ( (is_autowrapt_instrumented() or is_webhook_instrumented()) and "INSTANA_DISABLE_AUTO_INSTR" not in os.environ - and importlib_util.find_spec("gevent") ): - apply_gevent_monkey_patch() + # Automatic gevent monkey patching + # unless auto instrumentation is off, then the customer should do manual gevent monkey patching + if importlib_util.find_spec("gevent"): + apply_gevent_monkey_patch() + + # Eventlet deferred boot: opt-in via INSTANA_EVENTLET_DEFERRED_BOOT=true. + # When set, boot_agent() is deferred until after eventlet.monkey_patch() to + # prevent the ssl.SSLContext RecursionError with gunicorn eventlet workers. + # Without the opt-in we boot immediately, avoiding silent tracing gaps when + # eventlet is installed as a transitive dependency but not actually in use + # (e.g. sync/gthread workers, Celery, plain scripts). + if importlib_util.find_spec("eventlet") and is_truthy( + os.environ.get("INSTANA_EVENTLET_DEFERRED_BOOT", None) + ): + # boot_agent() will be called by the wrapper after monkey_patch; + # do not call it here to avoid a double boot. + _defer_boot_until_eventlet_patch() + return_early = True + else: + return_early = False + else: + return_early = False - # AutoProfile - if "INSTANA_AUTOPROFILE" in os.environ: - _start_profiler() + if not return_early: + # AutoProfile + if is_truthy(os.environ.get("INSTANA_AUTOPROFILE", None)): + _start_profiler() - boot_agent() + boot_agent() diff --git a/tests/clients/test_sqlalchemy.py b/tests/clients/test_sqlalchemy.py index eb931a72..4ec48977 100644 --- a/tests/clients/test_sqlalchemy.py +++ b/tests/clients/test_sqlalchemy.py @@ -14,11 +14,9 @@ from instana.span.span import get_current_span from tests.helpers import testenv -engine = create_engine( - f"postgresql://{testenv['postgresql_user']}:{testenv['postgresql_pw']}@{testenv['postgresql_host']}:{testenv['postgresql_port']}/{testenv['postgresql_db']}" -) +_DB_URL = f"postgresql+psycopg2://{testenv['postgresql_user']}:{testenv['postgresql_pw']}@{testenv['postgresql_host']}:{testenv['postgresql_port']}/{testenv['postgresql_db']}" +_DB_URL_DISPLAY = f"postgresql+psycopg2://{testenv['postgresql_host']}:{testenv['postgresql_port']}/{testenv['postgresql_db']}" -Session = sessionmaker(bind=engine) Base = declarative_base() @@ -35,37 +33,49 @@ def __repr__(self) -> str: return f"" -@pytest.fixture(scope="class") -def db_setup() -> None: +@pytest.fixture(scope="module") +def engine(): + return create_engine(_DB_URL) + + +@pytest.fixture(scope="module") +def db_setup(engine) -> None: tracer = get_tracer() with tracer.start_as_current_span("metadata") as span: Base.metadata.create_all(engine) span.end() -stan_user = StanUser( - name="IAmStan", - fullname="Stan Robot", - password="3X}vP66ADoCFT2g?HPvoem2eJh,zWXgd36Rb/{aRq/>7EYy6@EEH4BP(oeXac@mR", -) -stan_user2 = StanUser( - name="IAmStanToo", - fullname="Stan Robot 2", - password="3X}vP66ADoCFT2g?HPvoem2eJh,zWXgd36Rb/{aRq/>7EYy6@EEH4BP(oeXac@mR", -) +@pytest.fixture +def stan_user(): + return StanUser( + name="IAmStan", + fullname="Stan Robot", + password="3X}vP66ADoCFT2g?HPvoem2eJh,zWXgd36Rb/{aRq/>7EYy6@EEH4BP(oeXac@mR", + ) + -sqlalchemy_url = f"postgresql://{testenv['postgresql_host']}:{testenv['postgresql_port']}/{testenv['postgresql_db']}" +@pytest.fixture +def stan_user2(): + return StanUser( + name="IAmStanToo", + fullname="Stan Robot 2", + password="3X}vP66ADoCFT2g?HPvoem2eJh,zWXgd36Rb/{aRq/>7EYy6@EEH4BP(oeXac@mR", + ) @pytest.mark.usefixtures("db_setup") class TestSQLAlchemy: @pytest.fixture(autouse=True) - def _resource(self) -> Generator[None, None, None]: + def _resource(self, engine, stan_user, stan_user2) -> Generator[None, None, None]: """Clear all spans before a test run""" + self.engine = engine + self.stan_user = stan_user + self.stan_user2 = stan_user2 self.tracer = get_tracer() self.recorder = self.tracer.span_processor self.recorder.clear_spans() - self.session = Session() + self.session = sessionmaker(bind=engine)() yield """Ensure that allow_exit_as_root has the default value""" self.session.close() @@ -73,7 +83,7 @@ def _resource(self) -> Generator[None, None, None]: def test_session_add(self) -> None: with self.tracer.start_as_current_span("test"): - self.session.add(stan_user) + self.session.add(self.stan_user) self.session.commit() spans = self.recorder.queued_spans() @@ -100,7 +110,7 @@ def test_session_add(self) -> None: assert "sqlalchemy" in sql_span.data assert sql_span.data["sqlalchemy"]["eng"] == "postgresql" - assert sqlalchemy_url == sql_span.data["sqlalchemy"]["url"] + assert _DB_URL_DISPLAY == sql_span.data["sqlalchemy"]["url"] assert ( sql_span.data["sqlalchemy"]["sql"] == "INSERT INTO churchofstan (name, fullname, password) VALUES (%(name)s, %(fullname)s, %(password)s) RETURNING churchofstan.id" @@ -113,7 +123,7 @@ def test_session_add(self) -> None: def test_session_add_as_root_exit_span(self) -> None: agent.options.allow_exit_as_root = True - self.session.add(stan_user2) + self.session.add(self.stan_user2) self.session.commit() spans = self.recorder.queued_spans() @@ -136,7 +146,7 @@ def test_session_add_as_root_exit_span(self) -> None: assert "sqlalchemy" in sql_span.data assert sql_span.data["sqlalchemy"]["eng"] == "postgresql" - assert sqlalchemy_url == sql_span.data["sqlalchemy"]["url"] + assert _DB_URL_DISPLAY == sql_span.data["sqlalchemy"]["url"] assert ( sql_span.data["sqlalchemy"]["sql"] == "INSERT INTO churchofstan (name, fullname, password) VALUES (%(name)s, %(fullname)s, %(password)s) RETURNING churchofstan.id" @@ -149,7 +159,7 @@ def test_session_add_as_root_exit_span(self) -> None: def test_transaction(self) -> None: with self.tracer.start_as_current_span("test"): # noqa: SIM117 - with engine.begin() as connection: + with self.engine.begin() as connection: connection.execute(text("select 1")) connection.execute( text( @@ -186,7 +196,7 @@ def test_transaction(self) -> None: assert "sqlalchemy" in sql_span0.data assert sql_span0.data["sqlalchemy"]["eng"] == "postgresql" - assert sqlalchemy_url == sql_span0.data["sqlalchemy"]["url"] + assert _DB_URL_DISPLAY == sql_span0.data["sqlalchemy"]["url"] assert sql_span0.data["sqlalchemy"]["sql"] == "select 1" assert not sql_span0.data["sqlalchemy"]["err"] @@ -200,7 +210,7 @@ def test_transaction(self) -> None: assert "sqlalchemy" in sql_span1.data assert sql_span1.data["sqlalchemy"]["eng"] == "postgresql" - assert sqlalchemy_url == sql_span1.data["sqlalchemy"]["url"] + assert _DB_URL_DISPLAY == sql_span1.data["sqlalchemy"]["url"] assert ( sql_span1.data["sqlalchemy"]["sql"] == "select (name, fullname, password) from churchofstan where name='doesntexist'" @@ -243,7 +253,7 @@ def test_error_logging(self) -> None: assert "sqlalchemy" in sql_span.data assert sql_span.data["sqlalchemy"]["eng"] == "postgresql" - assert sqlalchemy_url == sql_span.data["sqlalchemy"]["url"] + assert _DB_URL_DISPLAY == sql_span.data["sqlalchemy"]["url"] assert ( sql_span.data["sqlalchemy"]["sql"] == "htVwGrCwVThisIsInvalidSQLaw4ijXd88" ) @@ -271,11 +281,8 @@ def test_error_before_tracing(self) -> None: current_span = get_current_span() assert not current_span.is_recording() - invalid_connection_url = "postgresql://user1:pwd1@localhost:9999/mydb1" - with pytest.raises( - OperationalError, - match=r"^(\(psycopg2\.OperationalError\)).*", - ) as context_manager: + invalid_connection_url = "postgresql+psycopg2://user1:pwd1@localhost:9999/mydb1" + with pytest.raises(OperationalError) as context_manager: engine = create_engine(invalid_connection_url) with engine.connect() as connection: (version,) = connection.execute(text("select version()")).fetchone() @@ -284,7 +291,7 @@ def test_error_before_tracing(self) -> None: assert not the_exception.connection_invalidated def test_if_not_tracing(self) -> None: - with engine.begin() as connection: + with self.engine.begin() as connection: connection.execute(text("select 1")) connection.execute( text( @@ -306,7 +313,7 @@ def test_context_restored_after_query(self) -> None: after_cursor_execute used get_current_span() instead of conn.span, which could corrupt the context stack and cause child spans to be dropped.""" with self.tracer.start_as_current_span("test") as parent_span: - with engine.begin() as connection: + with self.engine.begin() as connection: connection.execute(text("select 1")) # After the sqlalchemy span ends, the active span must be back to diff --git a/tests/frameworks/test_eventlet_autotrace.py b/tests/frameworks/test_eventlet_autotrace.py new file mode 100644 index 00000000..d34fd8e3 --- /dev/null +++ b/tests/frameworks/test_eventlet_autotrace.py @@ -0,0 +1,370 @@ +# (c) Copyright IBM Corp. 2026 +from __future__ import annotations + +import os +import sys +import types +from collections.abc import Generator +from unittest.mock import MagicMock, call, patch + +import pytest + +import instana +from instana import _defer_boot_until_eventlet_patch + + +def _make_fake_geventlet_module() -> types.ModuleType: + """Return a minimal fake gunicorn.workers.geventlet module with EventletWorker.patch.""" + + class EventletWorker: + def patch(self) -> None: + pass + + mod = types.ModuleType("gunicorn.workers.geventlet") + mod.EventletWorker = EventletWorker # type: ignore[attr-defined] + return mod + + +def _make_fake_eventlet_module() -> types.ModuleType: + """Return a minimal fake eventlet module with monkey_patch.""" + + mod = types.ModuleType("eventlet") + + def monkey_patch(*args: object, **kwargs: object) -> None: + pass + + mod.monkey_patch = monkey_patch # type: ignore[attr-defined] + return mod + + +# --------------------------------------------------------------------------- +# Helper: register both hooks and return captured callbacks +# --------------------------------------------------------------------------- + +def _register_hooks() -> tuple[list[tuple[str, object]], list[MagicMock]]: + """Call _defer_boot_until_eventlet_patch() and capture the two when_imported callbacks. + + Returns + ------- + callbacks : list of (module_name, callback_fn) pairs — one per registered hook + wrap_calls : list of MagicMock wrap_function_wrapper call recorders (empty at this point) + """ + captured: list[tuple[str, object]] = [] + + def fake_when_imported(name: str): + def decorator(fn: object) -> object: + captured.append((name, fn)) + return fn + return decorator + + with patch("wrapt.when_imported", side_effect=fake_when_imported): + _defer_boot_until_eventlet_patch() + + return captured, [] + + +class TestDeferredBootHookRegistration: + """Both hooks are always registered, regardless of whether gunicorn is installed.""" + + @pytest.fixture(autouse=True) + def _reset(self) -> Generator[None, None, None]: + instana._eventlet_booted = False + yield + instana._eventlet_booted = False + + def test_both_hooks_always_registered(self) -> None: + """when_imported is called for both gunicorn.workers.geventlet and eventlet.""" + callbacks, _ = _register_hooks() + names = [name for name, _ in callbacks] + assert "gunicorn.workers.geventlet" in names + assert "eventlet" in names + + def test_hook_registration_does_not_import_ssl_touching_modules(self) -> None: + """Registering the hooks must not pull urllib3 or requests into sys.modules.""" + ssl_mods = ("urllib3", "requests") + before = {m for m in ssl_mods if m in sys.modules} + + with patch("wrapt.when_imported", return_value=lambda fn: fn): + _defer_boot_until_eventlet_patch() + + after = {m for m in ssl_mods if m in sys.modules} + newly_imported = after - before + assert newly_imported == set(), ( + f"_defer_boot_until_eventlet_patch() imported ssl-touching modules: " + f"{newly_imported}. These must remain unimported until after " + f"eventlet.monkey_patch() to avoid the SSLContext RecursionError." + ) + + +class TestGunicornEventletWorkerHook: + """Hook 1: gunicorn.workers.geventlet — boots in the worker, after monkey_patch().""" + + @pytest.fixture(autouse=True) + def _reset(self) -> Generator[None, None, None]: + instana._eventlet_booted = False + yield + instana._eventlet_booted = False + os.environ.pop("INSTANA_AUTOPROFILE", None) + + def _get_geventlet_callback(self) -> object: + callbacks, _ = _register_hooks() + return next(fn for name, fn in callbacks if name == "gunicorn.workers.geventlet") + + def test_wraps_eventlet_worker_patch(self) -> None: + """When geventlet is imported, wrap_function_wrapper targets EventletWorker.patch.""" + cb = self._get_geventlet_callback() + fake_mod = _make_fake_geventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + args, _ = mock_wrap.call_args + assert args[0] is fake_mod + assert args[1] == "EventletWorker.patch" + + def test_worker_patch_wrapper_boots_agent(self) -> None: + """The wrapper calls boot_agent() after the original patch().""" + cb = self._get_geventlet_callback() + fake_mod = _make_fake_geventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + wrapper = mock_wrap.call_args[0][2] + mock_wrapped = MagicMock(return_value=None) + + with patch("instana.boot_agent") as mock_boot: + wrapper(mock_wrapped, None, (), {}) + mock_wrapped.assert_called_once_with() + mock_boot.assert_called_once() + + def test_worker_patch_wrapper_returns_result(self) -> None: + """The wrapper passes through the return value of the original patch().""" + cb = self._get_geventlet_callback() + fake_mod = _make_fake_geventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + wrapper = mock_wrap.call_args[0][2] + mock_wrapped = MagicMock(return_value="sentinel") + + with patch("instana.boot_agent"): + result = wrapper(mock_wrapped, None, (), {}) + + assert result == "sentinel" + + def test_double_boot_protection(self) -> None: + """EventletWorker.patch() called twice → boot_agent() called only once.""" + cb = self._get_geventlet_callback() + fake_mod = _make_fake_geventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + wrapper = mock_wrap.call_args[0][2] + mock_wrapped = MagicMock(return_value=None) + + with patch("instana.boot_agent") as mock_boot: + wrapper(mock_wrapped, None, (), {}) + wrapper(mock_wrapped, None, (), {}) + mock_boot.assert_called_once() + + def test_autoprofile_started_before_boot_agent(self) -> None: + """INSTANA_AUTOPROFILE=true: profiler starts before boot_agent in worker.""" + os.environ["INSTANA_AUTOPROFILE"] = "true" + cb = self._get_geventlet_callback() + fake_mod = _make_fake_geventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + wrapper = mock_wrap.call_args[0][2] + call_order: list[str] = [] + + with ( + patch("instana._start_profiler", side_effect=lambda: call_order.append("profiler")), + patch("instana.boot_agent", side_effect=lambda: call_order.append("boot")), + ): + wrapper(MagicMock(return_value=None), None, (), {}) + + assert call_order == ["profiler", "boot"] + + def test_gunicorn26_no_crash_when_geventlet_never_imported(self) -> None: + """Gunicorn ≥26 removed geventlet. when_imported never fires → no error.""" + # Simulate: hook is registered but the module is never imported. + # Registering is enough — nothing should raise. + with patch("wrapt.when_imported", return_value=lambda fn: fn): + _defer_boot_until_eventlet_patch() # must not raise + + +class TestBareMonkeyPatchHook: + """Hook 2: eventlet.monkey_patch — bare application and gunicorn ≥26.""" + + @pytest.fixture(autouse=True) + def _reset(self) -> Generator[None, None, None]: + instana._eventlet_booted = False + yield + instana._eventlet_booted = False + os.environ.pop("INSTANA_AUTOPROFILE", None) + # Ensure geventlet is not in sys.modules between tests + sys.modules.pop("gunicorn.workers.geventlet", None) + + def _get_eventlet_callback_and_wrapper(self) -> object: + callbacks, _ = _register_hooks() + cb = next(fn for name, fn in callbacks if name == "eventlet") + fake_mod = _make_fake_eventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + return mock_wrap.call_args[0][2] + + def test_wraps_monkey_patch_on_eventlet_module(self) -> None: + """When eventlet is imported, wrap_function_wrapper targets eventlet.monkey_patch.""" + callbacks, _ = _register_hooks() + cb = next(fn for name, fn in callbacks if name == "eventlet") + fake_mod = _make_fake_eventlet_module() + + with patch("wrapt.wrap_function_wrapper") as mock_wrap: + cb(fake_mod) + + args, _ = mock_wrap.call_args + assert args[0] is fake_mod + assert args[1] == "monkey_patch" + + def test_boots_agent_when_geventlet_not_in_sys_modules(self) -> None: + """Without gunicorn.workers.geventlet in sys.modules, boot_agent() is called.""" + assert "gunicorn.workers.geventlet" not in sys.modules + wrapper = self._get_eventlet_callback_and_wrapper() + mock_wrapped = MagicMock(return_value="patched") + + with patch("instana.boot_agent") as mock_boot: + result = wrapper(mock_wrapped, None, (), {}) + + assert result == "patched" + mock_boot.assert_called_once() + + def test_skips_boot_when_geventlet_in_sys_modules(self) -> None: + """With gunicorn.workers.geventlet present, arbiter-side boot is skipped.""" + sys.modules["gunicorn.workers.geventlet"] = _make_fake_geventlet_module() + wrapper = self._get_eventlet_callback_and_wrapper() + mock_wrapped = MagicMock(return_value=None) + + with patch("instana.boot_agent") as mock_boot: + wrapper(mock_wrapped, None, (), {}) + mock_boot.assert_not_called() + + def test_double_boot_protection(self) -> None: + """monkey_patch() called twice without gunicorn → boot_agent() called only once.""" + assert "gunicorn.workers.geventlet" not in sys.modules + wrapper = self._get_eventlet_callback_and_wrapper() + mock_wrapped = MagicMock(return_value=None) + + with patch("instana.boot_agent") as mock_boot: + wrapper(mock_wrapped, None, (), {}) + wrapper(mock_wrapped, None, (), {}) + mock_boot.assert_called_once() + + def test_autoprofile_started_before_boot_agent(self) -> None: + """INSTANA_AUTOPROFILE=true in fallback path: profiler starts before boot_agent.""" + os.environ["INSTANA_AUTOPROFILE"] = "true" + assert "gunicorn.workers.geventlet" not in sys.modules + wrapper = self._get_eventlet_callback_and_wrapper() + call_order: list[str] = [] + + with ( + patch("instana._start_profiler", side_effect=lambda: call_order.append("profiler")), + patch("instana.boot_agent", side_effect=lambda: call_order.append("boot")), + ): + wrapper(MagicMock(return_value=None), None, (), {}) + + assert call_order == ["profiler", "boot"] + + +class TestEventletDeferredBootModuleTopLevel: + """Module-level opt-in dispatch: INSTANA_EVENTLET_DEFERRED_BOOT flag behaviour.""" + + @pytest.fixture(autouse=True) + def _clean_env(self) -> Generator[None, None, None]: + instana._eventlet_booted = False + yield + instana._eventlet_booted = False + for key in ("INSTANA_EVENTLET_DEFERRED_BOOT", "INSTANA_AUTOPROFILE"): + os.environ.pop(key, None) + + def _run_toplevel_boot( + self, + *, + eventlet_installed: bool, + deferred_flag: str | None, + ) -> tuple[MagicMock, MagicMock]: + """Simulate the module-level dispatch block with controlled environment.""" + env_patch: dict[str, str] = {} + if deferred_flag is not None: + env_patch["INSTANA_EVENTLET_DEFERRED_BOOT"] = deferred_flag + + find_spec_return = object() if eventlet_installed else None + + with ( + patch.dict(os.environ, env_patch, clear=False), + patch("instana.importlib_util.find_spec", return_value=find_spec_return), + patch("instana._defer_boot_until_eventlet_patch") as mock_defer, + patch("instana.boot_agent") as mock_boot, + patch("instana.is_autowrapt_instrumented", return_value=True), + patch("instana.is_webhook_instrumented", return_value=False), + ): + auto_instr_on = "INSTANA_DISABLE_AUTO_INSTR" not in os.environ + if auto_instr_on: + eventlet_spec = instana.importlib_util.find_spec("eventlet") + deferred = bool(eventlet_spec) and instana.is_truthy( + os.environ.get("INSTANA_EVENTLET_DEFERRED_BOOT", None) + ) + if deferred: + instana._defer_boot_until_eventlet_patch() + return_early = True + else: + return_early = False + else: + return_early = False + + if not return_early: + instana.boot_agent() + + return mock_defer, mock_boot + + def test_no_flag_boots_immediately(self) -> None: + """Without INSTANA_EVENTLET_DEFERRED_BOOT, boot_agent() is called immediately.""" + mock_defer, mock_boot = self._run_toplevel_boot( + eventlet_installed=True, + deferred_flag=None, + ) + mock_boot.assert_called_once() + mock_defer.assert_not_called() + + def test_flag_true_no_eventlet_boots_immediately(self) -> None: + """Flag is set but eventlet not installed → normal boot, no deferral.""" + mock_defer, mock_boot = self._run_toplevel_boot( + eventlet_installed=False, + deferred_flag="true", + ) + mock_boot.assert_called_once() + mock_defer.assert_not_called() + + def test_flag_false_with_eventlet_boots_immediately(self) -> None: + """Flag explicitly false with eventlet installed → normal boot.""" + mock_defer, mock_boot = self._run_toplevel_boot( + eventlet_installed=True, + deferred_flag="false", + ) + mock_boot.assert_called_once() + mock_defer.assert_not_called() + + def test_flag_true_with_eventlet_defers_boot(self) -> None: + """Flag true + eventlet installed → _defer_boot_until_eventlet_patch() called, boot_agent() NOT called.""" + mock_defer, mock_boot = self._run_toplevel_boot( + eventlet_installed=True, + deferred_flag="true", + ) + mock_defer.assert_called_once() + mock_boot.assert_not_called()