Skip to content

Commit 4d2bd1e

Browse files
authored
Simplify lifecycle and telemetry ownership for 2.0 (#444)
* Keep FastStream observability alive through shutdown (#434) * Simplify lifecycle and telemetry ownership for 2.0 Implements #434, #435, #436, #438 and #440, and clarifies conflicting configuration arguments. * Roll back OTel acquisitions and preserve borrowed FastAPI instrumentation * Preserve request handling and borrowed tracing during teardown
1 parent 2af92e0 commit 4d2bd1e

22 files changed

Lines changed: 685 additions & 143 deletions

‎docs/adr/0002-cross-cutting-logic-stays-put.md‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,8 @@ framework-locality already exists, because all of a framework's subclasses live
1313
bootstrapper file. Reconsider if the per-cell bindings start converging, or if a new framework shares
1414
an existing one's attach mechanism exactly.
1515

16-
`_build_excluded_urls` likewise keeps the whole trace-exclusion policy in one method that reads
17-
its siblings' paths through `getattr(..., None)`. Those sibling configs are optional, so the
18-
defensive read is correct. A per-instrument contribution mechanism would
19-
spread the policy across `PrometheusConfig`, `HealthChecksConfig` and OpenTelemetry while still
20-
needing `opentelemetry_generate_health_check_spans`, so the coupling would not even disappear. The
21-
real risk, renaming `prometheus_metrics_path` and silently stopping the exclusion, is covered by a
22-
test pinning each sibling path in the built set. Reconsider when a user-supplied instrument needs
23-
paths the policy cannot know about.
16+
OpenTelemetry's `_build_infrastructure_excluded_paths` owns the exclusion policy for
17+
the metrics route and the health route. Framework bindings apply those paths alongside
18+
caller-supplied URL patterns. HTTP-level tests verify the resulting exclusions.
19+
A per-instrument contribution mechanism would distribute the same policy across
20+
configuration classes while retaining their dependency on the health span setting.

‎docs/integrations/faststream.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ application = bootstrapper.bootstrap()
5151

5252
Read more about available configuration options [here](../introduction/configuration.md).
5353

54+
## Lifecycle
55+
56+
The ASGI lifespan runs `bootstrapper.teardown()` after broker shutdown and user lifespan
57+
cleanup. Tracing and logging remain active through both. Startup and shutdown failures still
58+
trigger teardown; cleanup failures are added as notes to the original error.
59+
60+
When using `start()` and `stop()` directly or `async with application`, call
61+
`bootstrapper.teardown()` in an outer `finally` block after the application stops.
62+
5463
## Health and metrics routes
5564

5665
When configured, FastStream health and Prometheus metrics routes accept requests with or without

‎docs/introduction/configuration.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Additional parameters for FastAPI integration:
183183

184184
- `prometheus_instrumentator_additional_params` - passed to `prometheus_fastapi_instrumentator.Instrumentator`
185185
- `prometheus_instrument_additional_params` - passed to `method Instrumentator(...).instrument`
186-
- `prometheus_expose_additional_params` - passed to `method Instrumentator(...).expose`.
186+
- `prometheus_expose_additional_params` - passed to `Instrumentator(...).expose`. Set the endpoint with `prometheus_metrics_path`.
187187

188188
### Prometheus FastMCP
189189

@@ -343,7 +343,7 @@ Additional parameters:
343343

344344
- `pyroscope_sample_rate` - CPU profiling sample rate in Hz (default: `100`).
345345
- `pyroscope_tags` - key/value string pairs attached to all profiles.
346-
- `pyroscope_additional_params` - additional params passed directly to `pyroscope.configure`.
346+
- `pyroscope_additional_params` - overrides the prepared arguments passed to `pyroscope.configure`.
347347

348348
When OpenTelemetry is also enabled, a `PyroscopeSpanProcessor` is automatically added to the tracer provider. It tags root spans with a `pyroscope.profile.id` attribute and sets Pyroscope thread tags so that traces and profiles can be linked in the Grafana UI.
349349

@@ -511,6 +511,8 @@ To bootstrap swagger, you have the following parameters:
511511
- `swagger_offline_docs` - option to turn on offline docs.
512512
- `swagger_additional_params` - Litestar only: extra keyword arguments for `litestar.openapi.OpenAPIConfig`. It cannot repeat `path`, `title`, `version`, `description` or `render_plugins`, which lite-bootstrap already passes.
513513

514+
When lite-bootstrap creates the FastAPI application, set its docs URL with `swagger_path`.
515+
514516
Swagger is on by default at `swagger_path` (default: `/docs`). For Litestar, set `swagger_path` to an empty string to disable it.
515517

516518
## Health checks

‎docs/migration/to-2.x.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,12 @@ spans and logs. Keep exporters open until that work finishes
397397
([#412](https://github.com/modern-python/lite-bootstrap/pull/412),
398398
[#413](https://github.com/modern-python/lite-bootstrap/pull/413)).
399399

400+
FastStream teardown now runs after broker shutdown and user lifespan shutdown,
401+
including when startup or shutdown fails. If you drive FastStream with
402+
`start()` and `stop()` or use `async with application`, explicitly call
403+
`bootstrapper.teardown()` in an outer `finally` block
404+
([#434](https://github.com/modern-python/lite-bootstrap/issues/434)).
405+
400406
Failed bootstrap rolls back successfully started instruments and cannot be reused. Retry with
401407
a fresh application and bootstrapper ([#406](https://github.com/modern-python/lite-bootstrap/pull/406)).
402408

‎lite_bootstrap/bootstrappers/base.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import abc
2+
import contextlib
23
import logging
34
import typing
45
import warnings
6+
from collections.abc import Generator
57

68
from lite_bootstrap.exceptions import (
79
BootstrapperNotReadyError,
@@ -161,6 +163,22 @@ def bootstrap(self) -> ApplicationT:
161163
self.is_bootstrapped = True
162164
return self._application
163165

166+
@contextlib.contextmanager
167+
def _teardown_after_lifecycle(self) -> Generator[None]:
168+
lifecycle_error: BaseException | None = None
169+
try:
170+
yield
171+
except BaseException as error:
172+
lifecycle_error = error
173+
raise
174+
finally:
175+
try:
176+
self.teardown()
177+
except Exception as teardown_error:
178+
if lifecycle_error is None:
179+
raise
180+
lifecycle_error.add_note(f"Error tearing down observability: {teardown_error}")
181+
164182
def teardown(self) -> None:
165183
if not self.is_bootstrapped:
166184
return

‎lite_bootstrap/bootstrappers/fastapi_bootstrapper.py‎

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import dataclasses
33
import time
44
import typing
5+
import weakref
56
from collections.abc import AsyncGenerator, Mapping
67

78
from lite_bootstrap import import_checker
89
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
9-
from lite_bootstrap.exceptions import ConfigurationError
10+
from lite_bootstrap.exceptions import ConfigurationError, collect_teardown_errors
1011
from lite_bootstrap.helpers.fastapi_helpers import enable_offline_docs
1112
from lite_bootstrap.helpers.warn import warn_at_caller
1213
from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument
@@ -92,6 +93,9 @@ def __post_init__(self) -> None:
9293
raise ConfigurationError(msg)
9394

9495
if isinstance(self.application, UnsetType):
96+
if "docs_url" in self.application_kwargs:
97+
msg = "Use swagger_path to configure docs_url when lite-bootstrap creates the FastAPI application"
98+
raise ConfigurationError(msg)
9599
application = fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs)
96100
# FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs
97101
# to set application after construction, so we bypass the freeze here.
@@ -208,34 +212,70 @@ def bootstrap(self) -> None:
208212

209213
@dataclasses.dataclass(kw_only=True)
210214
class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument[FastAPIConfig]):
211-
def bootstrap(self) -> None:
212-
super().bootstrap()
213-
FastAPIInstrumentor.instrument_app(
214-
app=self.bootstrap_config.app,
215-
tracer_provider=self.tracer_provider,
216-
meter_provider=get_meter_provider(),
217-
excluded_urls=",".join(self._build_excluded_url_patterns("FASTAPI")),
218-
exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans,
219-
)
220-
self._install_infrastructure_exclusions()
215+
_owns_app_instrumentation: bool = dataclasses.field(default=False, init=False, repr=False, compare=False)
216+
_restore_infrastructure_exclusions: typing.Callable[[], None] | None = dataclasses.field(
217+
default=None, init=False, repr=False, compare=False
218+
)
221219

222-
def _install_infrastructure_exclusions(self) -> None:
220+
def _bootstrap_telemetry(self) -> None:
221+
super()._bootstrap_telemetry()
222+
app = self.bootstrap_config.app
223+
if not getattr(app, "_is_instrumented_by_opentelemetry", False):
224+
FastAPIInstrumentor.instrument_app(
225+
app=app,
226+
tracer_provider=self.tracer_provider,
227+
meter_provider=get_meter_provider(),
228+
excluded_urls=",".join(self._build_excluded_url_patterns("FASTAPI")),
229+
exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans,
230+
)
231+
self._owns_app_instrumentation = True
232+
self._restore_infrastructure_exclusions = self._install_infrastructure_exclusions()
233+
234+
def _install_infrastructure_exclusions(self) -> typing.Callable[[], None]:
223235
app = self.bootstrap_config.app
224236
build_stack = app.build_middleware_stack
225237
excluded_paths = self._build_infrastructure_excluded_paths()
238+
owned_wrappers = weakref.WeakSet[ExcludeInfrastructureTelemetry]()
226239

227-
def build_middleware_stack() -> "ASGIApp":
228-
stack = build_stack()
240+
def wrap(stack: "ASGIApp | None") -> None:
229241
# FastAPIInstrumentor places its OTel middleware inside an outer error handler.
230242
if isinstance(stack, ServerErrorMiddleware) and isinstance(stack.app, OpenTelemetryMiddleware):
231-
stack.app = ExcludeInfrastructureTelemetry(stack.app, excluded_paths)
232-
return stack
243+
wrapper = ExcludeInfrastructureTelemetry(stack.app, excluded_paths)
244+
owned_wrappers.add(wrapper)
245+
stack.app = wrapper
233246

234-
app.build_middleware_stack = build_middleware_stack # ty: ignore[invalid-assignment]
247+
def build_middleware_stack() -> "ASGIApp":
248+
stack = build_stack()
249+
wrap(stack)
250+
return stack
235251

236-
def teardown(self) -> None:
237-
FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.app)
238-
super().teardown()
252+
def restore() -> None:
253+
if app.build_middleware_stack is build_middleware_stack:
254+
vars(app)["build_middleware_stack"] = build_stack
255+
stack = app.middleware_stack
256+
if (
257+
isinstance(stack, ServerErrorMiddleware)
258+
and isinstance(stack.app, ExcludeInfrastructureTelemetry)
259+
and stack.app in owned_wrappers
260+
):
261+
stack.app = stack.app.app
262+
263+
vars(app)["build_middleware_stack"] = build_middleware_stack
264+
wrap(app.middleware_stack)
265+
return restore
266+
267+
def _teardown_owned_resources(self) -> None:
268+
with collect_teardown_errors() as teardown_errors:
269+
if self._restore_infrastructure_exclusions is not None:
270+
with teardown_errors.capture("FastAPI infrastructure exclusions"):
271+
self._restore_infrastructure_exclusions()
272+
self._restore_infrastructure_exclusions = None
273+
if self._owns_app_instrumentation:
274+
self._owns_app_instrumentation = False
275+
with teardown_errors.capture("FastAPIInstrumentor"):
276+
FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.app)
277+
with teardown_errors.capture("OpenTelemetry"):
278+
super()._teardown_owned_resources()
239279

240280

241281
@dataclasses.dataclass(kw_only=True)
@@ -252,6 +292,9 @@ def dependencies_installed() -> bool:
252292
def bootstrap(self) -> None:
253293
config = self.bootstrap_config
254294
application = config.app
295+
if "endpoint" in config.prometheus_expose_additional_params:
296+
msg = "Use prometheus_metrics_path to configure the Prometheus endpoint"
297+
raise ConfigurationError(msg)
255298
instrumentator_params = dict(config.prometheus_instrumentator_additional_params)
256299
instrumentator_params["excluded_handlers"] = [
257300
*instrumentator_params.get("excluded_handlers", []),
@@ -312,10 +355,8 @@ class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI", FastAPIConfig]):
312355

313356
@contextlib.asynccontextmanager
314357
async def lifespan_manager(self, _: "fastapi.FastAPI") -> AsyncGenerator[dict[str, typing.Any]]:
315-
try:
358+
with self._teardown_after_lifecycle():
316359
yield {}
317-
finally:
318-
self.teardown()
319360

320361
def __init__(self, bootstrap_config: FastAPIConfig) -> None:
321362
super().__init__(bootstrap_config)

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def _postprocess_http_apps(
8383
def http_app(*args: typing.Any, **kwargs: typing.Any) -> "StarletteWithLifespan": # noqa: ANN401
8484
return postprocess(build_http_app(*args, **kwargs))
8585

86-
application.http_app = http_app # ty: ignore[invalid-assignment]
86+
vars(application)["http_app"] = http_app
8787

8888
def restore() -> None:
8989
if previous_override is None:
@@ -259,8 +259,8 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St
259259
setattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, True)
260260
return http_application
261261

262-
def bootstrap(self) -> None:
263-
super().bootstrap()
262+
def _bootstrap_telemetry(self) -> None:
263+
super()._bootstrap_telemetry()
264264
self._restore_http_app = _postprocess_http_apps(self.bootstrap_config.application, self._instrument_http_app)
265265

266266
def teardown(self) -> None:
@@ -406,11 +406,9 @@ def _wrap_lifespan(self, application: "FastMCP[typing.Any]") -> None:
406406

407407
@contextlib.asynccontextmanager
408408
async def lifespan(server: "FastMCP[typing.Any]") -> AsyncGenerator[typing.Any]:
409-
try:
409+
with self._teardown_after_lifecycle():
410410
async with user_lifespan(server) as result:
411411
yield result
412-
finally:
413-
self.teardown()
414412

415413
application._lifespan = lifespan # noqa: SLF001
416414

‎lite_bootstrap/bootstrappers/faststream_bootstrapper.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import asyncio
2+
import contextlib
23
import dataclasses
34
import json
45
import logging
56
import typing
6-
from collections.abc import Mapping, Sequence
7+
from collections.abc import AsyncGenerator, Mapping, Sequence
78
from types import TracebackType
89

910
from lite_bootstrap import import_checker
@@ -219,8 +220,8 @@ def teardown(self) -> None:
219220

220221
@dataclasses.dataclass(kw_only=True)
221222
class FastStreamOpenTelemetryInstrument(OpenTelemetryInstrument[FastStreamConfig]):
222-
def bootstrap(self) -> None:
223-
super().bootstrap()
223+
def _bootstrap_telemetry(self) -> None:
224+
super()._bootstrap_telemetry()
224225
config = self.bootstrap_config
225226
if config.opentelemetry_middleware_cls and config.application.brokers:
226227
for broker, middleware_cls in _middleware_targets(
@@ -280,7 +281,18 @@ def is_ready(self) -> bool:
280281
def __init__(self, bootstrap_config: FastStreamConfig) -> None:
281282
super().__init__(bootstrap_config)
282283
application = self.bootstrap_config.application
283-
self._attach_teardown_once(application, lambda: application.on_shutdown(self.teardown))
284+
self._attach_teardown_once(application, lambda: self._wrap_lifespan(application))
285+
286+
def _wrap_lifespan(self, application: "AsgiFastStream") -> None:
287+
user_lifespan = application.lifespan_context
288+
289+
@contextlib.asynccontextmanager
290+
async def lifespan(*args: typing.Any, **kwargs: typing.Any) -> AsyncGenerator[typing.Any]: # noqa: ANN401
291+
with self._teardown_after_lifecycle():
292+
async with user_lifespan(*args, **kwargs) as result:
293+
yield result
294+
295+
application.lifespan_context = lifespan
284296

285297
def _prepare_application(self) -> "AsgiFastStream":
286298
return self.bootstrap_config.application

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from lite_bootstrap import import_checker
88
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
9+
from lite_bootstrap.exceptions import ConfigurationError
910
from lite_bootstrap.helpers.path import is_valid_path
1011
from lite_bootstrap.helpers.warn import warn_at_caller
1112
from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument
@@ -375,8 +376,8 @@ def _build_excluded_path_patterns(self) -> list[str]:
375376
}
376377
return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls))
377378

378-
def bootstrap(self) -> None:
379-
super().bootstrap()
379+
def _bootstrap_telemetry(self) -> None:
380+
super()._bootstrap_telemetry()
380381
application_config: typing.Final = self.bootstrap_config.application_config
381382
application_config.plugins.append(
382383
OpenTelemetryPlugin(
@@ -457,6 +458,9 @@ def is_configured(cls, bootstrap_config: "LitestarConfig") -> bool:
457458

458459
def bootstrap(self) -> None:
459460
config = self.bootstrap_config
461+
if "path" in config.swagger_additional_params:
462+
msg = "Use swagger_path to configure the OpenAPI path"
463+
raise ConfigurationError(msg)
460464
render_plugins: typing.Final = (
461465
(
462466
SwaggerRenderPlugin(

‎lite_bootstrap/instruments/logging_instrument.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,9 @@ def _attach_record_filters(self) -> None:
192192
for logger_name, record_filters in self.bootstrap_config.logging_record_filters.items():
193193
target_logger = logging.getLogger(logger_name)
194194
for record_filter in record_filters:
195-
target_logger.addFilter(record_filter)
196-
self._attached_filters.append((target_logger, record_filter))
195+
if record_filter not in target_logger.filters:
196+
target_logger.addFilter(record_filter)
197+
self._attached_filters.append((target_logger, record_filter))
197198

198199
def _detach_record_filters(self) -> None:
199200
"""Remove only the filters this instrument attached, leaving any others in place."""

0 commit comments

Comments
 (0)