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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,4 @@ dist/
.python-version
.venv
uv.lock
plan.md
site/
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ BaseBootstrapper (abc.ABC)

### Key design decisions

Recent design context, bugs, and convention rationale: see `planning/specs/2026-*-bug-audit-*.md` (audits + retros).
Recent design context, bugs, and convention rationale: see the bug-audit findings in `planning/audits/` and the post-work reflections in `planning/retros/` (the audit arcs themselves are bundled under `planning/changes/archive/`).

- **Optional dependencies**: Each instrument checks for its optional package via `import_checker.py` (`importlib.util.find_spec`). Instruments are skipped silently if the package is absent. Optional packages are imported inside `if import_checker.is_X_installed:` blocks; static analyzers that don't model this guard will report spurious "possibly unbound" diagnostics — the project uses `ty` which handles the pattern correctly.
- **Instrument skip ordering**: `BaseBootstrapper.__init__` runs `instrument_type.is_configured(config)` first (silent skip if the user's config indicates the instrument shouldn't run — populates `bootstrapper.skipped_instruments: list[tuple[type, str]]`); then `check_dependencies()` (emits `InstrumentDependencyMissingWarning` only for configured-but-dep-missing — the genuine deployment surprise); then instantiates. One `logger.info` summary line at the end lists configured + skipped instruments via `BaseBootstrapper.build_summary()`; that method is also publicly callable for post-construction debugging. Uses stdlib `logging` so it composes cleanly with the user's logging setup and with pytest's `caplog`.
Expand All @@ -61,15 +61,15 @@ One file per instrument under `lite_bootstrap/instruments/`, one per framework u

See `[project.optional-dependencies]` in `pyproject.toml` for the full extras matrix.

## Planning artifacts
## Workflow

Design docs and implementation plans live under `planning/` at the repo root, not under `docs/` (so they're excluded from the mkdocs site automatically):
Per-feature: brainstorming → spec in `planning/changes/active/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/active/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. On merge, the bundle moves to `planning/changes/archive/` with `status: shipped`, `pr:`, and `outcome:` filled, **and the change promotes its conclusions into the affected `architecture/<capability>.md`** — that hand-edit is what keeps `architecture/` true. See [`planning/README.md`](planning/README.md) for the conventions + index and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.

- `planning/specs/` — design docs / specs (output of brainstorming). Filename: `YYYY-MM-DD-<topic>-design.md`.
- `planning/plans/` — step-by-step implementation plans (output of writing-plans). Filename: `YYYY-MM-DD-<topic>-plan.md` or `YYYY-MM-DD-pr<N>-<slug>.md` for per-PR plans.
- `planning/templates/` — local templates (e.g. `lightweight-plan-template.md`).
**Spec** (`design.md`) captures the *thinking* — why, what the design is, trade-offs, scope. Written before code; rarely revised after merge. **Plan** (`plan.md`) captures the *sequencing* — the ordered checklist an executor walks; references the spec for the "why". **`architecture/`** captures the *invariants* of shipped systems — the living truth, promoted from a change on merge. A plan paragraph that would still read correctly with all task numbers and checkboxes removed is design content and belongs in the spec.

When superpowers skills default to `docs/superpowers/specs/`, use `planning/specs/` here instead.
**Three lanes.** Scale the artifact to the change. **Full** — a `design.md` + `plan.md` bundle — for real design judgment, a new file/module, a public-API change, cross-cutting/multi-file work, or non-trivial test design. **Lightweight** — a single `change.md` — for small-but-real changes (≲30 LOC net, ≤2 files, no new file, no public-API change, a single straightforward test). **Tiny** — no bundle, just a conventional commit — for a typo, dep bump, linter/formatter/CI tweak, a mechanical rename, or a single-line config change. Heavier lane wins on ambiguity; a `change.md` that outgrows its lane splits into `design.md` + `plan.md`.

Design docs and implementation plans live under `planning/` (not under `docs/`, so they're excluded from the mkdocs site automatically). When superpowers skills default to `docs/superpowers/specs/` or `docs/superpowers/plans/`, use the change bundle under `planning/changes/active/` here instead.

## Code style

Expand Down
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ publish:
# Manual invocation from a stale checkout will roll the live site back.
docs-deploy:
uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force

# Strict local docs build (no deploy). Mirrors CI's link/strict checks.
docs-build:
uvx --with-requirements docs/requirements.txt mkdocs build --strict
76 changes: 76 additions & 0 deletions architecture/bootstrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Bootstrappers

A bootstrapper takes one framework config, decides which instruments apply,
and drives their lifecycle. It is the user-facing entry point.

## The hierarchy

`BaseBootstrapper` (`lite_bootstrap/bootstrappers/base.py`) is an `abc.ABC`,
generic over `ApplicationT`. Five concrete bootstrappers exist:

- `FastAPIBootstrapper`
- `LitestarBootstrapper`
- `FastStreamBootstrapper`
- `FastMcpBootstrapper`
- `FreeBootstrapper` — no web framework; just the instruments.

Each declares `instruments_types: ClassVar[list[type[BaseInstrument]]]` (the
instruments it can run) and implements `_prepare_application()`, `is_ready()`,
and the `not_ready_message` property. (`FreeBootstrapperConfig` exists as a
backward-compat alias for `FreeConfig`.)

## Skip ordering at construction

`BaseBootstrapper.__init__` loops over `instruments_types` and decides each
instrument's fate in a fixed order:

1. **`is_configured(config)`** runs first. If False, the user's config indicates
the instrument should not run — it is **silently skipped** and appended to
`skipped_instruments: list[tuple[type[BaseInstrument], str]]` (the class plus
its `not_ready_message`). No warning. This runs before instantiation so a
missing optional dependency can't blow up in a dataclass default before we
even decide the user opted out.
2. **`check_dependencies()`** runs only for configured instruments. If the
optional package is missing, this is a genuine deployment surprise: the
bootstrapper emits an `InstrumentDependencyMissingWarning` (and a
`logger.warning`) and skips the instrument.
3. Otherwise it **instantiates** the instrument and appends it to `instruments`.

`InstrumentDependencyMissingWarning` is a `UserWarning` subclass (under the base
`InstrumentSkippedWarning`); filter it like any warning category.

## Instrument registry and teardown

The bootstrapper holds `instruments` (live instances) and runs them as a
registry:

- `bootstrap()` calls `bootstrap()` on each instrument **in order**, then
returns the prepared application. It is idempotent: if already bootstrapped it
re-runs `_prepare_application()` without re-bootstrapping instruments.
- `teardown()` calls `teardown()` on each instrument **in reverse**. It is
idempotent via the `is_bootstrapped` guard — it returns immediately when not
bootstrapped. Per-instrument teardown errors are collected and re-raised as a
`TeardownError` after all instruments have been attempted, so one failure does
not strand the rest. Cached runtime state in `LoggingInstrument` and
`OpenTelemetryInstrument` is reset inside `try/finally`.

## Summary logging

After the construction loop, `__init__` emits one INFO-level summary line listing
configured + skipped instruments. It uses stdlib `logging` (composes with the
user's logging setup and with pytest's `caplog`); default Python logging
suppresses INFO, so opt in via `logging.basicConfig(level=logging.INFO)`.

The same string is produced by the public `build_summary()` method, callable at
any later point (REPL, health endpoint) regardless of log-level filtering. To
inspect skips programmatically, iterate `bootstrapper.skipped_instruments`.

## App-tagging sentinel convention

When a bootstrapper must tag a user-supplied framework app (FastAPI, FastMCP,
Litestar, FastStream) with internal state, it stores a direct attribute prefixed
`_lite_bootstrap_` rather than squatting in framework namespaces like Starlette's
`application.state`. Example: FastAPI's lifespan double-wrap guard reads
`getattr(application, "_lite_bootstrap_lifespan_attached", False)` (no SLF
violation) and writes
`application._lite_bootstrap_lifespan_attached = True # noqa: SLF001`.
77 changes: 77 additions & 0 deletions architecture/config-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Config model

Configs describe *what the user wants*. They are immutable, declarative, and
carry no runtime state. Every config in the project descends from `BaseConfig`
(`lite_bootstrap/instruments/base.py`).

## BaseConfig

`BaseConfig` is a `@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)`.
It holds the service-identity fields shared by every instrument (`service_name`,
`service_description`, `service_version`, `service_environment`, `service_debug`).

Framework configs compose multiple instrument configs via **multiple
inheritance**. For example `FastAPIConfig` mixes `CorsConfig`,
`OpenTelemetryConfig`, `LoggingConfig`, `SentryConfig`, `PrometheusConfig`,
`SwaggerConfig`, `HealthChecksConfig`, etc. into one frozen dataclass, so a
single config object configures every instrument the framework supports.

All `*Config` classes are frozen for user-facing immutability; only the
`*Instrument` classes are non-frozen (they cache runtime state — see
`architecture/instruments.md`).

## from_dict vs from_object

Two classmethods build a config from external data. They differ in how they
treat `None`:

- `BaseConfig.from_dict(data)` — keeps unknown keys out (`{k: v for k, v in
data.items() if k in field_names}`) but passes through explicit `None`. So
`BaseConfig.from_dict({"service_name": None})` succeeds and **overrides** the
default with `None`.
- `BaseConfig.from_object(obj)` — pulls each field via `getattr(obj, field,
None)` and **filters out** any attribute that is `None` or missing, letting
the dataclass default take over.

The asymmetry is load-bearing: pick `from_dict` when explicit-None override is
the semantic you want; pick `from_object` when missing/None should mean
"fall back to default." It is documented in both docstrings
(`instruments/base.py:25, 31`) and pinned by tests in `tests/test_config.py`.

## UNSET sentinel and FastAPIConfig.application

`lite_bootstrap/types.py` defines `UnsetType` and the singleton `UNSET`
(`typing.Final[UnsetType]`). It distinguishes "user did not supply this" from
"user explicitly passed `None`", which a plain `None` default cannot express.

`FastAPIConfig.application` defaults to `UNSET`. In `__post_init__`, when the
value is still `UnsetType`, the config constructs a fresh `FastAPI()` and writes
it back:

```python
if isinstance(self.application, UnsetType):
application = fastapi.FastAPI(...)
object.__setattr__(self, "application", application)
```

The `object.__setattr__` bypasses the frozen guard — the config stays
immutable to the user, but construction-time computed fields can still be set.
A one-line comment documents the trade-off (user-facing immutability vs.
construction-time mutation) at the bypass site.

## __post_init__ cascade invariant

Several configs override `__post_init__` (e.g. `OpenTelemetryConfig` emits a
security warning, `CorsConfig` validates, `FastAPIConfig` constructs the app).
Because they share an MRO under `FastAPIConfig` / `LitestarConfig` /
`FastStreamConfig` / `FreeConfig`, **every** config `__post_init__` must call
`super().__post_init__()` so the chain runs to completion. A class that returns
early before `super()` silently blocks the rest of the chain.

`BaseConfig.__post_init__` is a deliberate no-op that **terminates** the
cascade; without it the chain would raise `AttributeError` on `object`.

`FastAPIConfig` uses the explicit `super(FastAPIConfig, self).__post_init__()`
form rather than bare `super()`. Under `@dataclass(slots=True)` the decorator
replaces the class object after the body compiles, which breaks the bare-`super()`
`__class__` cell; the explicit form is required.
89 changes: 89 additions & 0 deletions architecture/instruments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Instruments

An instrument is one observability or middleware concern (logging, tracing,
metrics, …). Each lives in its own file under `lite_bootstrap/instruments/`.
A bootstrapper owns a list of instrument instances and drives their lifecycle.

## BaseInstrument

`BaseInstrument[ConfigT]` (`lite_bootstrap/instruments/base.py`) is a generic,
**non-frozen** `@dataclasses.dataclass(kw_only=True, slots=True)` holding a
single `bootstrap_config: ConfigT`. Subclasses implement:

- `bootstrap()` / `teardown()` — lifecycle hooks, called in order / reverse by
the bootstrapper.
- `is_configured(cls, bootstrap_config) -> bool` (classmethod) — return False
when the user's config means this instrument should not run. Default: always
True. Drives the silent-skip path.
- `check_dependencies() -> bool` (staticmethod) — return False when the
optional package is absent. Default: always True.
- class attributes `not_ready_message` and `missing_dependency_message` —
human-readable reasons surfaced in skip reporting and warnings.

## Instrument catalog

One file per instrument:

- `logging_instrument.py` — structlog setup (`LoggingInstrument`), skipped when
`logging_enabled=False`.
- `opentelemetry_instrument.py` — OTel tracer provider + span export.
- `sentry_instrument.py` — Sentry SDK init, skipped when `sentry_dsn` empty.
- `prometheus_instrument.py` — Prometheus metrics; framework variants wrap it.
- `pyroscope_instrument.py` — continuous profiling, skipped when
`pyroscope_endpoint` empty.
- `cors_instrument.py` — CORS headers, requires an origins/regex setting.
- `swagger_instrument.py` — Swagger / offline docs.
- `healthchecks_instrument.py` — health-check route, gated by
`health_checks_enabled`.

`logging_factory.py` was split out of `logging_instrument.py` to keep each file
scoped to one job. It holds `MemoryLoggerFactory`, `_MemoryLoggerFactoryConfig`,
the orjson structlog serializer, and the ASGI `AddressProtocol` /
`RequestProtocol` typing protocols.

## Optional-dependency guard

Optional packages stay optional. `lite_bootstrap/import_checker.py` exposes
booleans computed once at import via `importlib.util.find_spec`
(`is_opentelemetry_installed`, `is_sentry_installed`, `is_fastapi_installed`,
`is_pyroscope_installed`, …). Optional imports sit behind
`if import_checker.is_X_installed:` blocks. Code that references the optional
symbol is only reached after `check_dependencies()` has already returned True,
so the runtime invariant holds even though static analyzers that don't model the
guard may report spurious "possibly unbound" diagnostics. The project uses `ty`,
which handles the pattern correctly.

## Why instruments are not frozen

All `*Config` classes are frozen, but `*Instrument` classes drop `frozen=True`
because two of them cache mutable runtime state: `LoggingInstrument` caches a
`_logger_factory` (`MemoryLoggerFactory | None`) and `OpenTelemetryInstrument`
caches `_tracer_provider`. Python's dataclass rules require the whole hierarchy
to be non-frozen, so `BaseInstrument` is non-frozen too. Both caches are reset
to `None` inside a `try/finally` during `teardown()`, so a raised shutdown
leaves no stale references.

## Cross-instrument integrations

**Logging ↔ Sentry.** `logging_instrument.py` injects structlog context into
Sentry events. `sentry_instrument.py` chains the user's `before_send` after the
built-in structlog enricher via `wrap_before_send_callbacks()`. A `skip_sentry`
flag in the log context suppresses the event; the flag is also stripped from the
Sentry payload (it is in `IGNORED_STRUCTLOG_ATTRIBUTES`).

**OTel ↔ Logging.** The logging instrument injects span/trace IDs from the
active OpenTelemetry context into every log record, so logs and traces correlate.

**Pyroscope ↔ OTel.** When both are enabled, a `PyroscopeSpanProcessor` is added
to the tracer provider so traces and profiles link in Grafana.

## OpenTelemetry single-instance-per-process

`OpenTelemetryInstrument.bootstrap()` calls
`opentelemetry.trace.set_tracer_provider(...)`, which the OTel SDK enforces as
**set-once**. A second instance's call is ignored (the SDK logs "Overriding of
current TracerProvider is not allowed"). `teardown()` calls `shutdown()` on the
cached provider — flushing batched spans and closing exporters — and resets
`_tracer_provider = None`, but it **cannot** reset the process-global pointer.
Construct exactly one `OpenTelemetryInstrument` per process; do not bootstrap a
second.
Loading