From c8b6b5a9f207be1a15b2f46e4e879308f40436f4 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 21:28:34 +0300 Subject: [PATCH] docs: plainer prose in README, guides, ADRs and context files --- AGENTS.md | 14 +++++----- CONTEXT.md | 26 +++++++++--------- README.md | 27 ++++++++++--------- ...-fastmcp-teardown-via-provider-lifespan.md | 6 ++--- .../adr/0002-cross-cutting-logic-stays-put.md | 22 +++++++-------- .../0003-teardown-marker-accepted-limits.md | 13 +++++---- ...004-native-dependency-extras-are-opt-in.md | 6 ++--- .../0005-core-declares-typing-extensions.md | 6 ++--- ...structlog-sentry-seam-stays-a-heuristic.md | 6 ++--- docs/adr/0007-no-django-bootstrapper.md | 4 +-- .../0008-otel-sampling-is-a-sampler-object.md | 8 +++--- docs/integrations/fastmcp.md | 2 +- docs/integrations/litestar.md | 21 +++++++-------- docs/introduction/configuration.md | 8 +++--- docs/introduction/quickstart.md | 20 +++++++------- 15 files changed, 94 insertions(+), 95 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e8a7db..d8e8c54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 18998a5..789d992 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 @@ -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. diff --git a/README.md b/README.md index 98ce9e7..b83563d 100644 --- a/README.md +++ b/README.md @@ -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: @@ -44,7 +44,7 @@ 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. @@ -52,24 +52,25 @@ A few constraints that aren't obvious from the API: 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) @@ -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. diff --git a/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md b/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md index a889a83..1928ff8 100644 --- a/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md +++ b/docs/adr/0001-fastmcp-teardown-via-provider-lifespan.md @@ -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. diff --git a/docs/adr/0002-cross-cutting-logic-stays-put.md b/docs/adr/0002-cross-cutting-logic-stays-put.md index e92a502..fcc522a 100644 --- a/docs/adr/0002-cross-cutting-logic-stays-put.md +++ b/docs/adr/0002-cross-cutting-logic-stays-put.md @@ -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. diff --git a/docs/adr/0003-teardown-marker-accepted-limits.md b/docs/adr/0003-teardown-marker-accepted-limits.md index 05cedf2..bffb959 100644 --- a/docs/adr/0003-teardown-marker-accepted-limits.md +++ b/docs/adr/0003-teardown-marker-accepted-limits.md @@ -2,19 +2,18 @@ `BaseBootstrapper._attach_teardown_once` detects a second bootstrapper by tagging the attach target with `_lite_bootstrap_teardown_attached`, so the "attached" bit lives exactly as long as the -application it describes — rather than in a class-level registry or `WeakSet`, which would mean -process-global mutable state and would contradict the `_lite_bootstrap_*` tagging convention the +application it describes. A class-level registry or `WeakSet` would mean process-global mutable state and would contradict the `_lite_bootstrap_*` tagging convention the codebase already follows. The target is tagged only after `attach()` returns, and since #167 hitting the marker raises `ConfigurationError` from `bootstrap()` rather than warning and leaving a half-wired application. -Two consequences are accepted rather than designed around: +Two consequences are accepted: -- **FastMCP detects via the marker, not by scanning `app.providers`.** If that list is cleared after +- FastMCP detects a second bootstrapper via the marker, not by scanning `app.providers`. If that list is cleared after the first bootstrap, the second bootstrapper is refused instead of re-attaching. No supported flow mutates it, and uniformity across the four app-bearing bootstrappers is worth more than the sliver of state-accuracy the structural check gave. -- **Litestar tags the shared `AppConfig`**, because the app does not exist until - `Litestar.from_config()`. Two configs sharing one `AppConfig` collide — but that pattern is already - broken independently, since instrument bootstrap mutates the shared config's `cors_config`, +- Litestar tags the shared `AppConfig`, because the app does not exist until + `Litestar.from_config()`. Two configs sharing one `AppConfig` collide, but that pattern is already + broken anyway, since instrument bootstrap mutates the shared config's `cors_config`, `route_handlers` and `openapi_config`. diff --git a/docs/adr/0004-native-dependency-extras-are-opt-in.md b/docs/adr/0004-native-dependency-extras-are-opt-in.md index 66b40e1..ed41363 100644 --- a/docs/adr/0004-native-dependency-extras-are-opt-in.md +++ b/docs/adr/0004-native-dependency-extras-are-opt-in.md @@ -4,13 +4,13 @@ Free-threaded CPython has wheels for neither `orjson` nor `grpcio`, and PEP 508 marker for "GIL enabled", so neither can be required conditionally. Both are therefore opt-in: `orjson` is its own extra (`logging` is `structlog` only) with the logging serializer falling back to the stdlib `json` accelerator, and OTLP over HTTP is a sibling `otl-http` extra beside a gRPC-only -`otl`. The fallback is a documented performance change, not a correctness one — roughly 2-5x slower, -with non-JSON-native values in log `extra` rendering via `repr`; the GIL fast path is byte-for-byte +`otl`. The fallback is a documented performance change and does not affect correctness: it is roughly +2-5x slower, with non-JSON-native values in log `extra` rendering via `repr`; the GIL fast path is byte-for-byte unchanged when `[orjson]` is installed. Both refuse the combinatorial alternative: `*-ft` twins of every logging-bearing and `*-all` extra, or `fastapi-otl-http`-style framework variants. A free-threaded service composes `[fastapi, otl-http]` -itself. Replacing `orjson` with msgspec, ujson or rapidjson was also rejected — a permanent mandatory +itself. Replacing `orjson` with msgspec, ujson or rapidjson was also rejected: it would add a permanent mandatory dependency, with a different encoder API and output shape, to paper over a temporary gap ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530) tracks ft wheels); if that resolves, `orjson` could return to core and the fallback branch retire. diff --git a/docs/adr/0005-core-declares-typing-extensions.md b/docs/adr/0005-core-declares-typing-extensions.md index 3429c66..49745ff 100644 --- a/docs/adr/0005-core-declares-typing-extensions.md +++ b/docs/adr/0005-core-declares-typing-extensions.md @@ -1,12 +1,12 @@ # Core declares `typing-extensions` -A genuinely zero-dependency core was implemented, tested and abandoned. `HealthCheckTypedDict` is a +A zero-dependency core was implemented, tested and abandoned. `HealthCheckTypedDict` is a FastAPI response model, and pydantic refuses a stdlib `typing.TypedDict` model on Python < 3.12 -(`PydanticUserError`) — a constraint that lives in pydantic's behaviour on an old interpreter, not in +(`PydanticUserError`). The constraint lives in pydantic's behaviour on an old interpreter, not in a `TypedDict` you can build in isolation, so the attempt passed locally on 3.12 and failed the CI matrix on 3.10 and 3.11. `1.3.0` had already shipped claiming a zero-dependency core and raised `ModuleNotFoundError` on a bare install, masked everywhere because every extra pulls `typing_extensions` transitively. It is pure Python, so core stays free-threading-friendly, and the dependency can be dropped once the -floor reaches 3.12 — where `typing.TypedDict` is acceptable to pydantic and `Self` is in `typing`. +floor reaches 3.12, where `typing.TypedDict` is acceptable to pydantic and `Self` is in `typing`. diff --git a/docs/adr/0006-structlog-sentry-seam-stays-a-heuristic.md b/docs/adr/0006-structlog-sentry-seam-stays-a-heuristic.md index 91e8e41..b089e42 100644 --- a/docs/adr/0006-structlog-sentry-seam-stays-a-heuristic.md +++ b/docs/adr/0006-structlog-sentry-seam-stays-a-heuristic.md @@ -2,17 +2,17 @@ `StructuredLogPayload` in `logging_factory.py` owns both the parse of a rendered log line and the meta-key vocabulary (`STRUCTLOG_META_KEYS`), leaving `sentry_instrument.py` only the orchestration. -The problem solved was ownership, not the heuristic: the meta-key set was duplicated across the +The problem solved was ownership: the meta-key set was duplicated across the producer's processor chain and the consumer's strip list and owned by neither, so renaming a meta-key silently degraded Sentry enrichment. The producer still emits flat JSON and the consumer still -recognises it by `startswith("{")` — an explicit marker key would pollute user-visible stdout for +recognises it by `startswith("{")`. An explicit marker key would pollute user-visible stdout for every logging user to serve one consumer, and a neutral third module both instruments import would abstract a sharing that does not exist. The trap worth knowing before "fixing" it: structlog's own idiomatic `ProcessorFormatter.wrap_for_formatter` is incompatible with this seam. It defers rendering to handler-flush time, so `record.msg` is an `EventDict`, `sentry_sdk` builds `logentry.formatted` from -`record.getMessage()`, and the resulting Python repr opens with `{` — which the parser accepts, fails +`record.getMessage()`, and the resulting Python repr opens with `{`, which the parser accepts, fails to decode, and returns `None` from, silently dropping both `skip_sentry` and `contexts.structlog`. The seam requires the line to be rendered before it becomes a `LogRecord`. diff --git a/docs/adr/0007-no-django-bootstrapper.md b/docs/adr/0007-no-django-bootstrapper.md index e25c677..e044a6c 100644 --- a/docs/adr/0007-no-django-bootstrapper.md +++ b/docs/adr/0007-no-django-bootstrapper.md @@ -1,7 +1,7 @@ # No Django bootstrapper -Every bootstrapper keeps one contract — the user constructs the application, passes it in, and gets -the same object back — and Django's observability is conventionally owned by `settings.py` +Every bootstrapper keeps one contract: the user constructs the application, passes it in, and gets +the same object back. Django's observability is conventionally owned by `settings.py` (`MIDDLEWARE` ordering, installed apps), which runs before any object a bootstrapper could be handed. ADR-0001 is the calibration: FastMCP was the hardest framework to fit and still had an application object to attach to. Attaching to a constructed `ASGIHandler` is the shape that would fit, and today diff --git a/docs/adr/0008-otel-sampling-is-a-sampler-object.md b/docs/adr/0008-otel-sampling-is-a-sampler-object.md index 1ce2ef1..c468ae9 100644 --- a/docs/adr/0008-otel-sampling-is-a-sampler-object.md +++ b/docs/adr/0008-otel-sampling-is-a-sampler-object.md @@ -1,15 +1,15 @@ # OpenTelemetry sampling takes a `Sampler`, not a sample-rate float -`opentelemetry_sampler` is handed straight to `TracerProvider(sampler=...)`. Left `None` — the -default — the SDK picks its own sampler, which honours `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`, +`opentelemetry_sampler` is handed straight to `TracerProvider(sampler=...)`. Left `None` (the +default), the SDK picks its own sampler, which honours `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`, so both the default behaviour and the existing environment-variable path are unchanged; the pass-through needs no conditional, because `TracerProvider(sampler=None)` is identical to omitting the argument at the declared 1.28 floor and at 1.44 alike. A `float` sample rate mirroring `sentry_traces_sample_rate` was rejected. It buys no dependency -independence — `opentelemetry_instrumentors` already carries `BaseInstrumentor`, and +independence, since `opentelemetry_instrumentors` already carries `BaseInstrumentor` and `sentry_integrations` / `sentry_before_send` carry Sentry types, all as quoted annotations over -`TYPE_CHECKING` imports that never run — and it closes only part of the gap, leaving `ALWAYS_OFF`, a +`TYPE_CHECKING` imports that never run. It also closes only part of the gap, leaving `ALWAYS_OFF`, a bare `TraceIdRatioBased` and vendor samplers unreachable behind an instrument that owns provider construction and offers no `*_additional_params` escape hatch. A float can still be layered on top later, which is what a settings object mapping one env var would want. diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 3316f77..58124f4 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -83,7 +83,7 @@ The metrics path, the health-check path (unless `opentelemetry_generate_health_c `opentelemetry_excluded_urls` and `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` produce no spans. An application already instrumented by `StarletteInstrumentor` is left as is, so no request is traced twice. -Teardown is wired through FastMCP's provider lifecycle — `bootstrapper.teardown()` +Teardown is wired through FastMCP's provider lifecycle: `bootstrapper.teardown()` runs automatically when the FastMCP server's ASGI lifespan shuts down (i.e. when the application that serves `application.http_app()` shuts down). diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 4ea909d..dfe33d4 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -72,12 +72,12 @@ LitestarConfig( ) ``` -Enabled this way, it logs metadata only — `path`, `method`, `content_type`, -`path_params` for requests and `status_code` for responses — and skips +Enabled this way, it logs metadata only (`path`, `method`, `content_type`, +`path_params` for requests and `status_code` for responses) and skips `swagger_path`, `swagger_static_path` (when `swagger_offline_docs` is on), `health_checks_path` and `prometheus_metrics_path`. Those four paths are -excluded whether or not the corresponding instrument is actually configured — -so if you disable health checks but still serve your own route at +excluded whether or not the corresponding instrument is configured, so if +you disable health checks but still serve your own route at `health_checks_path`, that route is not access-logged either. `path` and `path_params` are logged, so a secret embedded in the URL itself @@ -97,10 +97,9 @@ LitestarConfig( ) ``` -A bare `LoggingMiddlewareConfig()` restores Litestar's own defaults wholesale -— including full request/response body logging — so pass explicit -`request_log_fields` / `response_log_fields` rather than relying on the -built-in default. +A bare `LoggingMiddlewareConfig()` restores all of Litestar's own defaults, +including full request/response body logging, so pass explicit +`request_log_fields` / `response_log_fields`. ## Prometheus @@ -131,7 +130,7 @@ Left as-is, that means every handler that reads a request body returns `500: The bootstrapper works around this by filling `request_max_body_size` with Litestar's own 10 MB default whenever your `AppConfig` leaves it unset. This -is not exposed as a `LitestarConfig` field — set it on your own `AppConfig` +is not exposed as a `LitestarConfig` field. Set it on your own `AppConfig` instead, the same way as every other Litestar app-level knob: ```python @@ -143,6 +142,6 @@ LitestarConfig( ) ``` -Pass `request_max_body_size=None` for no limit. Any value you set — including -`None` — is left untouched; the bootstrapper only fills it in when it is +Pass `request_max_body_size=None` for no limit. Any value you set, including +`None`, is left untouched; the bootstrapper only fills it in when it is unset. diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 0dcafd2..fba77e5 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -271,7 +271,7 @@ config = FastAPIConfig( `logging_record_filters` attaches `logging.Filter` instances to loggers by name. A filter sees every record its logger emits, before any handler renders it, and may rewrite the record or return `False` -to drop it — useful for demoting a third-party error you already handle: +to drop it. This lets you demote a third-party error you already handle: ```python import logging @@ -396,9 +396,9 @@ Additional params: When a bootstrapper is constructed, each registered instrument is checked twice: -1. **`is_configured(config)`** (classmethod, runs before instantiation) — returns False if the user's config indicates this instrument shouldn't run (e.g. `sentry_dsn` empty, `logging_enabled=False`, `pyroscope_endpoint` empty). When False, the instrument is **silently skipped** and recorded in `bootstrapper.skipped_instruments: list[tuple[type, str]]` — each entry is the instrument class plus its `not_configured_reason`. +1. `is_configured(config)` (classmethod, runs before instantiation) returns False if the user's config indicates this instrument shouldn't run (e.g. `sentry_dsn` empty, `logging_enabled=False`, `pyroscope_endpoint` empty). When False, the instrument is silently skipped and recorded in `bootstrapper.skipped_instruments: list[tuple[type, str]]`. Each entry is the instrument class plus its `not_configured_reason`. -2. **`dependencies_installed()`** — runs only if `is_configured()` returned True. If the instrument's optional package is missing, an `InstrumentDependencyMissingWarning` is emitted. This is a real "configured but dependency missing" deployment surprise. +2. `dependencies_installed()` runs only if `is_configured()` returned True. If the instrument's optional package is missing, an `InstrumentDependencyMissingWarning` is emitted, since a configured instrument with a missing dependency is usually a deployment mistake. After the loop, the bootstrapper emits one INFO-level summary log listing configured + skipped instruments. Default Python logging suppresses INFO; opt in via `logging.basicConfig(level=logging.INFO)`. @@ -421,4 +421,4 @@ for cls, reason in bootstrapper.skipped_instruments: print(f"{cls.__name__}: {reason}") ``` -To get a human-readable view of the same information at any later point (e.g. for debugging from a REPL or a health endpoint), call `bootstrapper.build_summary()`. It returns the multi-line string that the INFO summary log emits — useful when log levels are filtered or when you want to render the bootstrapper state inline. +To get a human-readable view of the same information at any later point (e.g. for debugging from a REPL or a health endpoint), call `bootstrapper.build_summary()`. It returns the multi-line string that the INFO summary log emits, which helps when log levels are filtered or when you want to render the bootstrapper state inline. diff --git a/docs/introduction/quickstart.md b/docs/introduction/quickstart.md index ba724a2..eb61eac 100644 --- a/docs/introduction/quickstart.md +++ b/docs/introduction/quickstart.md @@ -1,8 +1,8 @@ # Quickstart -This walkthrough takes you from an empty project to a **FastAPI service with -production observability** — metrics, health checks, CORS, Swagger, and -structured logging — wired up in a few lines. The same pattern applies to +This walkthrough takes you from an empty project to a FastAPI service with +metrics, health checks, CORS, Swagger, and structured logging, wired up in a +few lines. The same pattern applies to [Litestar](../integrations/litestar.md), [FastStream](../integrations/faststream.md), [FastMCP](../integrations/fastmcp.md), and [framework-free services](../integrations/free.md). @@ -73,11 +73,11 @@ uvicorn main:app --reload With the config above, your service now exposes: -- **Metrics** at `/metrics` in Prometheus format — point your scraper at it. -- **Health check** at `/health` — wire it to your orchestrator's readiness probe. -- **Swagger UI** at `/docs` — with offline assets, so it renders without a CDN. -- **CORS** headers for the configured origins. -- **Structured logging** via `structlog` — enabled by default (opt out with +- Prometheus metrics at `/metrics`, for your scraper. +- A health check at `/health`, for your orchestrator's readiness probe. +- Swagger UI at `/docs`, with offline assets so it renders without a CDN. +- CORS headers for the configured origins. +- Structured logging via `structlog`, on by default (opt out with `logging_enabled=False`). ## 5. Enable more @@ -115,6 +115,6 @@ for cls, reason in bootstrapper.skipped_instruments: ## Next steps -- [Configuration](configuration.md) — every config field and per-instrument option. -- [Integrations](../integrations/litestar.md) — Litestar, FastStream, FastMCP, +- [Configuration](configuration.md): every config field and per-instrument option. +- [Integrations](../integrations/litestar.md): Litestar, FastStream, FastMCP, and framework-free services.