diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 17cc660..f3ab54a 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -61,5 +61,13 @@ def bootstrap(self) -> ApplicationT: def teardown(self) -> None: self.is_bootstrapped = False - for one_instrument in self.instruments: - one_instrument.teardown() + errors: list[BaseException] = [] + for one_instrument in reversed(self.instruments): + try: + one_instrument.teardown() + except Exception as e: # noqa: BLE001, PERF203 + logger.warning(f"Error tearing down {type(one_instrument).__name__}: {e}") + errors.append(e) + if errors: + msg = f"{len(errors)} instrument(s) failed during teardown" + raise RuntimeError(msg) from errors[0] diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index ebf62b4..2096a27 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -54,6 +54,10 @@ class FastAPIConfig( prometheus_expose_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) def __post_init__(self) -> None: + if not import_checker.is_fastapi_installed: + msg = "fastapi is not installed" + raise RuntimeError(msg) + if not self.application: object.__setattr__( self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index e5e49e0..9c3ef30 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -104,6 +104,7 @@ def bootstrap(self) -> None: attributes={k: v for k, v in attributes.items() if v}, ) tracer_provider = TracerProvider(resource=resource) + set_tracer_provider(tracer_provider) if import_checker.is_pyroscope_installed and getattr(self.bootstrap_config, "pyroscope_endpoint", None): tracer_provider.add_span_processor(PyroscopeSpanProcessor()) if self.bootstrap_config.opentelemetry_log_traces: @@ -125,7 +126,6 @@ def bootstrap(self) -> None: ) else: one_instrumentor.instrument(tracer_provider=tracer_provider) - set_tracer_provider(tracer_provider) def teardown(self) -> None: for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 6c153b7..cb9d147 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest import structlog from structlog.testing import capture_logs @@ -47,6 +49,25 @@ def test_free_bootstrap_logging_not_ready() -> None: ] +def test_teardown_error_isolation(free_bootstrapper_config: FreeBootstrapperConfig) -> None: + bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) + bootstrapper.bootstrap() + + # Replace instruments with mocks: first raises, second succeeds. + bad = MagicMock() + bad.teardown.side_effect = RuntimeError("boom") + good = MagicMock() + bootstrapper.instruments = [bad, good] + + with capture_logs() as cap_logs, pytest.raises(RuntimeError, match="1 instrument"): + bootstrapper.teardown() + + # Both instruments attempted teardown despite the error (LIFO: good first, bad second). + good.teardown.assert_called_once() + bad.teardown.assert_called_once() + assert any("boom" in entry.get("event", "") for entry in cap_logs) + + @pytest.mark.parametrize( "package_name", [