Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ def __post_init__(self) -> None:


def _narrow_app(config: "FastAPIConfig") -> "fastapi.FastAPI":
assert not isinstance(config.application, UnsetType)
if isinstance(config.application, UnsetType):
msg = "FastAPIConfig.application is UNSET; __post_init__ did not run"
raise TypeError(msg)
return config.application


Expand Down Expand Up @@ -198,6 +200,17 @@ def __init__(self, bootstrap_config: FastAPIConfig) -> None:
super().__init__(bootstrap_config)

application = _narrow_app(self.bootstrap_config)
# FastAPI's lifespan_context is opaque after wrap; tag the app instance directly
# rather than squatting in Starlette's user-facing application.state namespace.
if getattr(application, "_lite_bootstrap_lifespan_attached", False):
warnings.warn(
"FastAPI application already has a lite-bootstrap lifespan wrapper attached; "
"skipping re-wrap. This FastAPIBootstrapper's teardown will not be invoked on "
"ASGI shutdown — construct one FastAPIBootstrapper per application.",
stacklevel=2,
)
return
application._lite_bootstrap_lifespan_attached = True # noqa: SLF001 # ty: ignore[unresolved-attribute]
old_lifespan_manager = application.router.lifespan_context
application.router.lifespan_context = _merge_lifespan_context(
old_lifespan_manager,
Expand Down
9 changes: 9 additions & 0 deletions lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import dataclasses
import time
import typing
import warnings

from lite_bootstrap import import_checker
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
Expand Down Expand Up @@ -151,6 +152,14 @@ def is_ready(self) -> bool:

def __init__(self, bootstrap_config: FastMcpConfig) -> None:
super().__init__(bootstrap_config)
if any(isinstance(p, _TeardownProvider) for p in self.bootstrap_config.application.providers):
warnings.warn(
"FastMCP application already has a _TeardownProvider attached; skipping re-attachment. "
"This FastMcpBootstrapper's teardown will not be invoked on ASGI shutdown — "
"construct one FastMcpBootstrapper per application.",
stacklevel=2,
)
return
self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown))

def _prepare_application(self) -> "FastMCP[typing.Any]":
Expand Down
13 changes: 13 additions & 0 deletions lite_bootstrap/bootstrappers/faststream_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,27 @@ async def _define_health_status(self) -> bool:
@dataclasses.dataclass(kw_only=True)
class FastStreamLoggingInstrument(LoggingInstrument):
bootstrap_config: FastStreamConfig
_prior_broker_params_storage: typing.Any = dataclasses.field(default=None, init=False, repr=False, compare=False)
_broker_logger_replaced: bool = dataclasses.field(default=False, init=False, repr=False, compare=False)

def bootstrap(self) -> None:
super().bootstrap()
broker = self.bootstrap_config.application.broker
if broker is not None and import_checker.is_structlog_installed and import_checker.is_faststream_installed:
logger = structlog.get_logger("faststream")
logger.setLevel(self.bootstrap_config.faststream_log_level)
self._prior_broker_params_storage = broker.config.logger.params_storage
broker.config.logger.params_storage = ManualLoggerStorage(logger)
self._broker_logger_replaced = True

def teardown(self) -> None:
if self._broker_logger_replaced:
broker = self.bootstrap_config.application.broker
if broker is not None:
broker.config.logger.params_storage = self._prior_broker_params_storage
self._broker_logger_replaced = False
self._prior_broker_params_storage = None
super().teardown()


@dataclasses.dataclass(kw_only=True)
Expand Down
20 changes: 13 additions & 7 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import contextlib
import dataclasses
import pathlib
import typing
import weakref

from lite_bootstrap import import_checker
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
Expand Down Expand Up @@ -76,9 +78,10 @@ class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None:
self._tracer_provider = tracer_provider
self._excluded_urls = ",".join(excluded_urls)
# Cache keyed by id(next_app); Litestar's ASGI app instances are stable for
# the middleware lifetime, so id-reuse-after-GC isn't a concern.
self._otel_apps: dict[int, ASGIApp] = {}
# WeakKeyDictionary so wrapper apps are evicted when Litestar drops the
# next_app reference (hot reload, plugin add/remove, AppConfig rebuild).
# Apps that don't support weak references are simply not cached.
self._otel_apps: weakref.WeakKeyDictionary[ASGIApp, ASGIApp] = weakref.WeakKeyDictionary()

async def handle(
self,
Expand All @@ -87,16 +90,19 @@ async def handle(
send: "Send",
next_app: "ASGIApp",
) -> None:
otel_app = self._otel_apps.get(id(next_app))
otel_app: ASGIApp | None = None
with contextlib.suppress(TypeError):
otel_app = self._otel_apps.get(next_app)
if otel_app is None:
otel_app = OpenTelemetryMiddleware(
otel_app = OpenTelemetryMiddleware( # ty: ignore[invalid-assignment]
app=next_app,
default_span_details=build_litestar_route_details_from_scope,
excluded_urls=self._excluded_urls,
tracer_provider=self._tracer_provider,
)
self._otel_apps[id(next_app)] = otel_app # ty: ignore[invalid-assignment]
await otel_app(scope, receive, send) # ty: ignore[invalid-argument-type]
with contextlib.suppress(TypeError):
self._otel_apps[next_app] = otel_app # ty: ignore[invalid-assignment]
await otel_app(scope, receive, send) # ty: ignore[call-non-callable]


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
Expand Down
35 changes: 26 additions & 9 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import typing

from lite_bootstrap import import_checker
from lite_bootstrap.exceptions import TeardownError
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
from lite_bootstrap.instruments.logging_factory import (
AddressProtocol,
Expand Down Expand Up @@ -164,15 +165,31 @@ def teardown(self) -> None:
"""Reset structlog and root logger.

Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten.

Best-effort cleanup: errors from individual ``handler.close()`` calls and from the memory
logger factory's ``close_handlers()`` are collected and re-raised together via
:class:`~lite_bootstrap.exceptions.TeardownError` after the rest of teardown completes,
so no single misbehaving cleanup step prevents the instrument from releasing the
rest of its resources.
"""
structlog.reset_defaults()
root_logger = logging.getLogger()
for h in root_logger.handlers[:]:
root_logger.removeHandler(h)
h.close()
root_logger.setLevel(logging.WARNING)
if self._logger_factory is not None:
try:
self._logger_factory.close_handlers()
finally:
self._logger_factory = None
errors: list[tuple[str, BaseException]] = []
try:
for h in root_logger.handlers[:]:
root_logger.removeHandler(h)
try:
h.close()
except Exception as e: # noqa: BLE001
errors.append((type(h).__name__, e))
root_logger.setLevel(logging.WARNING)
finally:
if self._logger_factory is not None:
try:
self._logger_factory.close_handlers()
except Exception as e: # noqa: BLE001
errors.append(("MemoryLoggerFactory", e))
finally:
self._logger_factory = None
if errors:
raise TeardownError(errors) from errors[0][1]
24 changes: 22 additions & 2 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,26 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: # pragma: no cover

@dataclasses.dataclass(kw_only=True, slots=True)
class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]):
"""OpenTelemetry tracing instrument.

Lifecycle note: ``bootstrap()`` calls ``opentelemetry.trace.set_tracer_provider``,
which the OTel SDK enforces as **set-once per process** (subsequent calls log
"Overriding of current TracerProvider is not allowed" and have no effect).
``teardown()`` calls ``shutdown()`` on the provider, which flushes batched
spans and closes exporters, but it cannot reset the process-global pointer —
callers of ``opentelemetry.trace.get_tracer_provider()`` after teardown will
still receive the shut-down provider. The supported lifecycle is one
``OpenTelemetryInstrument`` per process; do not bootstrap a second instance.
"""

not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False"
missing_dependency_message = "opentelemetry is not installed"
_tracer_provider: "TracerProvider | None" = dataclasses.field(
default_factory=lambda: None, init=False, repr=False, compare=False
)
_prior_logger_disabled: dict[str, bool] = dataclasses.field(
default_factory=dict, init=False, repr=False, compare=False
)

@classmethod
def is_configured(cls, bootstrap_config: "OpenTelemetryConfig") -> bool:
Expand All @@ -105,8 +120,10 @@ def _build_excluded_urls(self) -> set[str]:
return excluded_urls

def bootstrap(self) -> None:
logging.getLogger("opentelemetry.instrumentation.instrumentor").disabled = True
logging.getLogger("opentelemetry.trace").disabled = True
for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"):
otel_logger = logging.getLogger(logger_name)
self._prior_logger_disabled[logger_name] = otel_logger.disabled
otel_logger.disabled = True
attributes = {
resources.SERVICE_NAME: self.bootstrap_config.opentelemetry_service_name
or self.bootstrap_config.service_name,
Expand Down Expand Up @@ -149,6 +166,9 @@ def teardown(self) -> None:
one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params)
else:
one_instrumentor.uninstrument()
for logger_name, prior in self._prior_logger_disabled.items():
logging.getLogger(logger_name).disabled = prior
self._prior_logger_disabled.clear()
if self._tracer_provider is not None:
try:
self._tracer_provider.shutdown()
Expand Down
8 changes: 5 additions & 3 deletions lite_bootstrap/instruments/pyroscope_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ def check_dependencies() -> bool:
return import_checker.is_pyroscope_installed

def bootstrap(self) -> None:
# is_configured() guarantees pyroscope_endpoint is set; assert documents the precondition
# for type narrowing and for direct callers that bypass the bootstrapper.
assert self.bootstrap_config.pyroscope_endpoint is not None
# is_configured() guarantees pyroscope_endpoint is set when called via the
# bootstrapper. Direct callers bypassing is_configured see an explicit raise.
if self.bootstrap_config.pyroscope_endpoint is None:
msg = "pyroscope_endpoint is unset; PyroscopeInstrument.is_configured() should have returned False"
raise RuntimeError(msg)
namespace = self.bootstrap_config.opentelemetry_namespace
tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags
pyroscope.configure(
Expand Down
10 changes: 10 additions & 0 deletions lite_bootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,13 @@ def bootstrap(self) -> None:
)
tags: dict[str, str] = self.bootstrap_config.sentry_tags or {}
sentry_sdk.set_tags(tags)

def teardown(self) -> None:
"""Flush pending events and reset the SDK to a no-op state.

Calling ``sentry_sdk.init()`` with no DSN disables further event capture. This
cleans up after a bootstrap so the same process can be torn down and re-tested
without leaking the previous DSN/transport into subsequent code.
"""
sentry_sdk.flush(timeout=2)
sentry_sdk.init()
Loading
Loading