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
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
## Project Overview

`lite-bootstrap` bootstraps a Python microservice with pre-configured observability.
[`CONTEXT.md`](CONTEXT.md) owns the vocabulary — read it before naming a concept in code, a test, or
[`CONTEXT.md`](CONTEXT.md) owns the vocabulary; read it before naming a concept in code, a test, or
an issue. An instrument being **configured** versus a bootstrapper being **ready**, and the two
kinds of **skip**, are defined there and are load-bearing throughout.

## Commands

`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth —
`just --list`, or read it. Every non-obvious recipe carries its intent as a comment.
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth:
run `just --list`, or read it. Every non-obvious recipe carries its intent as a comment.

## Architecture

Expand Down Expand Up @@ -48,8 +48,8 @@ Four rules that are not visible in the code that follows them:
from `__init__.py` if the old name was. A class assignment, not a subclass, so `isinstance` still
holds; `OpentelemetryConfig` is the worked example.
- **A warning reached from config construction or an instrument's `bootstrap()` goes through
`warn_at_caller`.** Neither depth is constant — a `__post_init__` cascade runs as deep as that
config's MRO, and `bootstrap()` sits a frame deeper whenever it calls `super().bootstrap()` — so
`warn_at_caller`.** Neither depth is constant: a `__post_init__` cascade runs as deep as that
config's MRO, and `bootstrap()` sits a frame deeper whenever it calls `super().bootstrap()`. So
the helper walks out to the first frame outside `lite_bootstrap`. The three surviving literal
`stacklevel=` sites lie outside both paths and stay literal; #202 measured them.
- **Sentinels on a user's app get a `_lite_bootstrap_` prefix.** A direct attribute on the app
Expand All @@ -59,9 +59,9 @@ Four rules that are not visible in the code that follows them:
### Type checking

`ty` is the only supported type checker; act on its diagnostics alone. The codebase leans on patterns
Pyright reports as errors — conditional imports for optional dependencies, covariant
Pyright reports as errors (conditional imports for optional dependencies, covariant
`bootstrap_config` narrowing on instrument subclasses, `TypedDict` optional-key access guarded by
`.get()` — so adding it yields noise, not findings.
`.get()`), so adding it yields only noise.

Two suppressions recur and are both correct: `invalid-method-override` on a framework subclass's
`is_configured` classmethod, which narrows its parameter type where `ty` enforces invariance, and
Expand Down
26 changes: 13 additions & 13 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,32 @@ project uses it.

**Bootstrapper**:
The user-facing entry point, one class per framework. It owns exactly one application for the life
of the process — not a builder you can run twice. A second bootstrapper on the same application (or,
of the process; it is not a builder you can run twice. A second bootstrapper on the same application (or,
for Litestar, the same `AppConfig`) warns at construction and raises `ConfigurationError` from
`bootstrap()`.

**Instrument**:
One observability or middleware concern — logging, tracing, metrics, profiling, CORS, Swagger,
One observability or middleware concern: logging, tracing, metrics, profiling, CORS, Swagger, or
health checks. A framework subclass such as `FastAPIPrometheusInstrument` is the *same* instrument
bound to a framework, not a second instrument.
_Avoid_: instrumentation — reserve that for OpenTelemetry's own `opentelemetry-instrumentation-*`
packages and the middleware named after them; integration — that is the docs section of
_Avoid_: instrumentation, which is reserved for OpenTelemetry's own `opentelemetry-instrumentation-*`
packages and the middleware named after them; integration, which is the docs section of
per-framework guides, and Sentry's word for its own `Integration` objects.

**Configured**:
An instrument is configured when the user's config asks for it — `is_configured(config)`, a
An instrument is configured when the user's config asks for it: `is_configured(config)`, a
classmethod evaluated before anything is instantiated. Unconfigured is the normal case, not a fault:
an empty `sentry_dsn` means the user did not want Sentry.
_Avoid_: enabled — `logging_enabled` and `health_checks_enabled` are two config fields among the
_Avoid_: enabled. `logging_enabled` and `health_checks_enabled` are two config fields among the
several inputs `is_configured` reads; most instruments have no enabled flag at all.

**Ready**:
A *bootstrapper* is ready when its framework package is importable — `is_ready()`, checked once in
A *bootstrapper* is ready when its framework package is importable: `is_ready()`, checked once in
`__init__`, raising `BootstrapperNotReadyError` when false. It is about the environment, never about
the config, and it is a different question from whether an instrument is **configured**.
_Avoid_: configured, for this sense. The attribute names follow the split: a bootstrapper carries
`not_ready_message`, an instrument carries `not_configured_reason`. An instrument written against
the old `not_ready_message` still works — `BaseInstrument.__init_subclass__` forwards it.
the old `not_ready_message` still works because `BaseInstrument.__init_subclass__` forwards it.

**Skipped**:
An instrument the bootstrapper decided not to instantiate. Two paths, deliberately different
Expand All @@ -47,23 +47,23 @@ raises `InstrumentDependencyMissingWarning` plus a `logger.warning` and lands in
quiet skip is the one you have to go looking for; the loud one finds you.

**Free**:
Without a web framework — `FreeBootstrapper`, `FreeConfig`, the `free-all` extra. It has no
Without a web framework: `FreeBootstrapper`, `FreeConfig`, the `free-all` extra. It has no
application, so nothing to attach teardown to and no double-bootstrap guard.
_Avoid_: bare "free" for PEP 703 free-threaded CPython, which this project also supports. Write
"free-threaded" in full.

**Rendered line**:
The complete JSON string the logging instrument produces for one event — message, level, metadata
The complete JSON string the logging instrument produces for one event, with message, level, metadata
and traceback together. One event is one rendered line is one physical line of stdout; the three
never diverge.
_Avoid_: formatted message — reserve that for Sentry's own `logentry.formatted` field, which holds
_Avoid_: formatted message, which is reserved for Sentry's own `logentry.formatted` field, which holds
a rendered line only until the seam lifts the message out of it.

**Record filter**:
A `logging.Filter` attached to a named standard-library logger through `logging_record_filters`. It
runs inside `Logger.handle`, so it sees a `LogRecord` before any handler renders it and before
Sentry's patched `callHandlers` turns it into an issue; it may rewrite the record or drop it. The
logger name is exact — a filter on `package` never sees `package.child`.
_Avoid_: processor — a structlog processor runs on an event dict inside the rendering chain, too
logger name is exact: a filter on `package` never sees `package.child`.
_Avoid_: processor. A structlog processor runs on an event dict inside the rendering chain, too
late to change what Sentry captured; `logging_extra_processors` takes those. Also avoid bare
"filter" where a handler filter or `structlog.stdlib.filter_by_level` could be meant.
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)

`lite-bootstrap` wires production observability — OpenTelemetry, Prometheus, Sentry, and structlog — into FastAPI, Litestar, FastStream, and FastMCP services in a few lines, with no boilerplate.
`lite-bootstrap` wires production observability (OpenTelemetry, Prometheus, Sentry, and structlog) into FastAPI, Litestar, FastStream, and FastMCP services in a few lines.

With `lite-bootstrap`, you receive an application with lightweight built-in support for:

- `sentry`
- `prometheus`
- `opentelemetry`
- `pyroscope` - with OpenTelemetry trace-profile linking
- `pyroscope`, with OpenTelemetry trace-profile linking
- `structlog`
- `cors`
- `swagger` - with additional offline version support
- `swagger`, including an offline version
- `health-checks`

Those instruments can be bootstrapped for:
Expand All @@ -44,32 +44,33 @@ Those instruments can be bootstrapped for:
A few constraints that aren't obvious from the API:

- **One bootstrapper per application instance.** Constructing two `FastAPIBootstrapper`s around the same `fastapi.FastAPI` (or two `FastMcpBootstrapper`s around the same `FastMCP`) is not supported. The second bootstrapper warns at construction, and its `bootstrap()` raises `ConfigurationError`.
- **One `OpenTelemetryInstrument` per process.** `bootstrap()` calls `opentelemetry.trace.set_tracer_provider(...)`, which the OTel SDK enforces as set-once — subsequent calls log a warning and have no effect. `teardown()` flushes spans and closes exporters but can't reset the process-global pointer.
- **One `OpenTelemetryInstrument` per process.** `bootstrap()` calls `opentelemetry.trace.set_tracer_provider(...)`, which the OTel SDK enforces as set-once: later calls log a warning and have no effect. `teardown()` flushes spans and closes exporters but can't reset the process-global pointer.
- **`teardown()` is idempotent.** `BaseBootstrapper.teardown()` short-circuits if not bootstrapped; per-instrument teardown methods are safe to call multiple times.
- **Partial teardown failures are aggregated.** If an instrument's teardown raises, the bootstrapper continues with the rest of the instruments and raises `TeardownError` at the end with all collected failures.

---

Usage examples:

- with Litestar - [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template)
- with FastAPI - [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template)
- with Litestar: [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template)
- with FastAPI: [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template)

## Acknowledgements

`lite-bootstrap` is inspired by [`microbootstrap`](https://github.com/community-of-python/microbootstrap) — a single package that wires up the common observability stack (sentry, prometheus, opentelemetry, logging, cors, swagger, health-checks) for FastAPI / Litestar / FastStream services and for plain scripts.
`lite-bootstrap` is inspired by [`microbootstrap`](https://github.com/community-of-python/microbootstrap), a single package that wires up the common observability stack (sentry, prometheus, opentelemetry, logging, cors, swagger, health-checks) for FastAPI / Litestar / FastStream services and for plain scripts.

The following ideas were borrowed:

- the overall surface — a `Bootstrapper` per framework that composes a set of instruments,
- the lifecycle model — each instrument has `bootstrap()` / `teardown()` / `is_ready()` and is skipped when its optional dependency is not installed,
- the overall surface: a `Bootstrapper` per framework that composes a set of instruments,
- the lifecycle model: each instrument has `bootstrap()` / `teardown()` / `is_ready()` and is skipped when its optional dependency is not installed,
- the catalog of supported instruments and supported frameworks.

The following intentionally differ:

- **Configuration**: `lite-bootstrap` uses frozen `dataclass` configs (no `pydantic` / `pydantic-settings` runtime dependency), which is what makes it "lite". `microbootstrap` configures everything through `pydantic-settings` models.
- **Granular extras**: `lite-bootstrap` has exactly **one** mandatory runtime dependency, the pure-Python `typing-extensions`; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`, `fastmcp`) is its own extra, plus an opt-in `orjson` speedup for logging, per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. You install only what you actually use. It also runs on free-threaded CPython 3.14t: every extra whose dependencies are pure Python installs there, and the ones that don't are blocked upstream — `orjson` and `pyroscope` have no free-threaded wheels, and `otl` needs `grpcio`, so use `otl-http` instead. `microbootstrap` bundles the full observability stack (opentelemetry, sentry-sdk, structlog, pyroscope-io, rich, pydantic-settings, …) as base dependencies and only splits framework packages into extras.
- **Scope**: `lite-bootstrap` is deliberately narrow — only instrument wiring. It does not include a Granian server runner or a console writer.
- Configuration. `lite-bootstrap` uses frozen `dataclass` configs with no `pydantic` / `pydantic-settings` runtime dependency, which is what makes it "lite". `microbootstrap` configures everything through `pydantic-settings` models.
- Extras. `lite-bootstrap` has one mandatory runtime dependency, the pure-Python `typing-extensions`. Every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`, `fastmcp`) is its own extra, plus an opt-in `orjson` speedup for logging, per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. `microbootstrap` bundles the full observability stack (opentelemetry, sentry-sdk, structlog, pyroscope-io, rich, pydantic-settings, …) as base dependencies and only splits framework packages into extras.
- Free-threading. `lite-bootstrap` runs on free-threaded CPython 3.14t. Every extra whose dependencies are pure Python installs there. The rest are blocked upstream: `orjson` and `pyroscope` have no free-threaded wheels, and `otl` needs `grpcio`, so use `otl-http` instead.
- Scope. `lite-bootstrap` only wires instruments. It does not include a Granian server runner or a console writer.

## 📚 [Documentation](https://lite-bootstrap.modern-python.org)

Expand All @@ -80,4 +81,4 @@ The following intentionally differ:
## Part of `modern-python`

Browse the full list of templates and libraries in
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
[`modern-python`](https://github.com/modern-python); the org profile has the categorized index.
6 changes: 3 additions & 3 deletions docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# FastMCP teardown attaches through a Provider lifespan

FastMCP is the one supported framework with no `on_shutdown`-shaped hook: `FastMCP.lifespan` is a
bound method on the `AggregateProvider` mixin, so assigning to it succeeds and does nothing — the
transport runners read the private `_lifespan`, set only at construction — which leaves
`add_provider()` as the single public post-construction hook the server's ASGI lifespan actually
bound method on the `AggregateProvider` mixin, so assigning to it succeeds and does nothing (the
transport runners read the private `_lifespan`, set only at construction). That leaves
`add_provider()` as the only public post-construction hook the server's ASGI lifespan actually
invokes. `FastMcpBootstrapper` therefore registers a `_TeardownProvider` whose `lifespan` runs
teardown on the exit branch, accepting that `Provider` is FastMCP's extension abstraction for tools,
resources and prompts and that using one for a shutdown callback is thin.
Expand Down
22 changes: 11 additions & 11 deletions docs/adr/0002-cross-cutting-logic-stays-put.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
# Cross-cutting logic stays where it is: per-instrument, and in one method

Two re-organizations have been proposed and rejected for the same reason — each moves complexity
rather than concentrating it, and the matrix is inherently O(instruments × frameworks) whichever axis
it is cut along.
Two re-organizations have been proposed and rejected for the same reason: each moves complexity
around without reducing it, and the matrix is O(instruments × frameworks) whichever axis it is cut
along.

The instrument × framework matrix keeps its **per-instrument axis**: base instruments own the hoisted
The instrument × framework matrix keeps its per-instrument axis: base instruments own the hoisted
logic and each framework is a thin `bootstrap()` subclass, rather than per-framework adapters driving
generic instruments. The bindings genuinely differ — FastAPI imperative (`add_middleware`,
`include_router`), Litestar declarative before the app is built, FastStream attaching to a broker,
FastMCP via `custom_route` — so a uniform `add_route`/`add_middleware` interface would leak; and
generic instruments. The bindings differ (FastAPI imperative with `add_middleware` and
`include_router`, Litestar declarative before the app is built, FastStream attaching to a broker,
FastMCP via `custom_route`), so a uniform `add_route`/`add_middleware` interface would leak; and
framework-locality already exists, because all of a framework's subclasses live in its one
bootstrapper file. Reconsider if the per-cell bindings start converging, or if a new framework shares
an existing one's attach mechanism exactly.

`_build_excluded_urls` likewise keeps the whole trace-exclusion policy in **one method** that reads
its siblings' paths through `getattr(..., None)` — those sibling configs are genuinely optional, so
the defensive read is correct rather than a smell. A per-instrument contribution mechanism would
`_build_excluded_urls` likewise keeps the whole trace-exclusion policy in one method that reads
its siblings' paths through `getattr(..., None)`. Those sibling configs are optional, so the
defensive read is correct. A per-instrument contribution mechanism would
spread the policy across `PrometheusConfig`, `HealthChecksConfig` and OpenTelemetry while still
needing `opentelemetry_generate_health_check_spans`, so the coupling would not even disappear. The
real risk — renaming `prometheus_metrics_path` and silently stopping the exclusion — is answered by a
real risk, renaming `prometheus_metrics_path` and silently stopping the exclusion, is covered by a
test pinning each sibling path in the built set. Reconsider when a user-supplied instrument needs
paths the policy cannot know about.
Loading
Loading