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
23 changes: 23 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,26 @@ jobs:
- run: uv python pin ${{ matrix.python-version }}
- run: just install
- run: just test . --cov=. --cov-report xml

free-threaded:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- python-version: "3.13t"
extras: "logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics"
- python-version: "3.14t"
extras: "logging,sentry,fastapi,litestar,faststream,fastapi-metrics,litestar-metrics,faststream-metrics"
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install ${{ matrix.python-version }}
- run: uv venv --python ${{ matrix.python-version }}
- name: Install core + ft-ready extras (orjson/otl/pyroscope/fastmcp excluded)
run: uv pip install ".[${{ matrix.extras }}]"
- name: Free-threaded smoke test
run: .venv/bin/python scripts/ft_smoke.py
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ dist/
.venv
uv.lock
site/
.superpowers/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Capability index (all of `architecture/`):
| Config model — `BaseConfig`, multiple-inheritance composition, `from_dict`/`from_object`, `UNSET`, `__post_init__` cascade | `architecture/config-model.md` |
| Instruments — `BaseInstrument` lifecycle, catalog, optional-dep guard, non-frozen rationale, cross-instrument integrations (Logging↔Sentry, OTel↔Logging, Pyroscope↔OTel), OTel single-instance | `architecture/instruments.md` |
| Bootstrappers — hierarchy, skip ordering, registry + idempotent teardown, summary logging, teardown-attach seam, app-tagging sentinels | `architecture/bootstrappers.md` |
| Free-threading — nogil support matrix, orjson fallback, single-threaded-init invariant | `architecture/free-threading.md` |

Recent design context, bugs, and rationale: bug-audit findings in
`planning/audits/`, post-work reflections in `planning/retros/`, and the audit
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The following ideas were borrowed:
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` ships only `orjson` as a runtime dependency; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`) is its own extra, with per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. You install only what you actually use. `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.
- **Granular extras**: `lite-bootstrap` has **no** mandatory runtime dependencies; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`) 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.13t/3.14t) — see [`architecture/free-threading.md`](architecture/free-threading.md). `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.

## 📚 [Documentation](https://lite-bootstrap.modern-python.org)
Expand Down
8 changes: 8 additions & 0 deletions architecture/bootstrappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ The guard is uniform: the same marker and warning apply to all four app-bearing
frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's
`on_shutdown`) return the callback.

## Single-threaded init (free-threading)

`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; their
cached state (`is_bootstrapped`, the teardown-attach marker) carries no locks and
is not safe to drive concurrently on one bootstrapper. This is intentional: under
free-threaded CPython the parallelism is in request handling, not bootstrap. See
[`free-threading.md`](free-threading.md).

## App-tagging sentinel convention

When a bootstrapper must tag a user-supplied framework app (FastAPI, FastMCP,
Expand Down
51 changes: 51 additions & 0 deletions architecture/free-threading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Free-threaded Python (nogil)

`lite-bootstrap` runs on free-threaded CPython (PEP 703): 3.14t (officially
supported per PEP 779) and 3.13t (experimental). The library is pure Python;
what blocks a given surface on ft is always a native dependency somewhere in
its extra, not `lite-bootstrap` itself.

## Support matrix

| Surface | 3.13t | 3.14t | Notes |
|---|---|---|---|
| core, `logging`, `sentry` | ✅ | ✅ | pure Python; `logging` uses the stdlib-json serializer fallback when `orjson` is absent |
| `fastapi`/`faststream` (+ `-sentry`/`-logging`/`-metrics`) | ✅ | ✅ | pure Python + `pydantic-core` ft wheels |
| `litestar` (+ `litestar-metrics`) | ❌ | ✅ | `msgspec` gates `Py_GIL_DISABLED` to Python 3.14+ — its `_core.c` contains `#error "Py_GIL_DISABLED is only supported in Python 3.14+"` (v0.21.1), so the source build fails on 3.13t. See [`planning/deferred.md`](../planning/deferred.md) |
| `fastmcp` (+ `fastmcp-metrics`) | ❌ | ❌ | 3.13t: `cffi` (via `fastmcp`→`cryptography`) refuses to build free-threaded. 3.14t: `fastmcp` pulls bare `opentelemetry-api` without `opentelemetry-sdk`, so `import lite_bootstrap` still fails. See [`planning/deferred.md`](../planning/deferred.md) |
| `orjson` (opt-in speedup) | ❌ | ❌ | no ft wheels, build refuses ft ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530)). Omit it on ft; the serializer falls back to stdlib json |
| `otl` (gRPC exporter) | ❌ | ❌ | needs `grpcio`, no ft wheels ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). An HTTP-exporter path is deferred (`planning/deferred.md`) |
| `pyroscope` | ❌ | ❌ | `pyroscope-io` is abi3-only, unmaintained, no ft wheels ([`planning/deferred.md`](../planning/deferred.md)) |

The CI matrix (`.github/workflows/_checks.yml`, `free-threaded` job) installs
per Python version to match this table exactly: the 3.13t leg's extras stop at
`logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics`; the
3.14t leg adds `litestar,litestar-metrics`. `orjson`, `otl`, `pyroscope`, and
`fastmcp`/`fastmcp-metrics` are excluded from both legs.

## The `orjson` fallback

`orjson` is used only by the logging serializer. It is an opt-in extra
(`lite-bootstrap[orjson]`), preferred when present (GIL-build output unchanged);
otherwise `logging_factory` serializes with the stdlib `json` accelerator using
compact separators and `ensure_ascii=False`, so output stays byte-identical for
JSON-native values. Trade-off on the fallback: ~2-5x slower JSON, and non-JSON-native
types in log `extra` (datetime/UUID) render via repr instead of orjson's native
encoding. Reverts to the native path automatically once `orjson` ships ft wheels.

## Single-threaded-init invariant

`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations. The
cached mutable state (`is_bootstrapped`, `OpenTelemetryInstrument._tracer_provider`,
the `_lite_bootstrap_teardown_attached` marker) is **not** guarded for concurrent
calls on one bootstrapper — by design. Free-threading parallelizes request
handling, where `lite-bootstrap` does not sit. Do not call `bootstrap()`/`teardown()`
on the same bootstrapper from multiple threads.

## Proof

`.github/workflows/_checks.yml` runs `scripts/ft_smoke.py` on 3.13t and 3.14t:
it asserts a free-threaded interpreter, `orjson` absent, the serializer fallback
round-trips, and a FastAPI bootstrap/teardown succeeds. It is a standalone
script rather than a `just test` run because `tests/conftest.py` hard-imports
`opentelemetry`, which the ft leg does not install.
29 changes: 29 additions & 0 deletions architecture/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ 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.

**Guarding dotted `find_spec` checks.** `find_spec` imports a dotted name's
parent package first, so a present-but-incomplete namespace — e.g.
`opentelemetry-api` installed without `opentelemetry-instrumentation` — raised
`ModuleNotFoundError` instead of returning `False`, crashing `import
lite_bootstrap`. `import_checker._safe_find_spec` wraps `find_spec` for dotted
names and treats that exception as absent; `is_fastapi_opentelemetry_installed`
and `is_litestar_opentelemetry_installed` both route through it.

**`is_otlp_grpc_exporter_installed` is a separate guard from
`is_opentelemetry_installed`.** `is_opentelemetry_installed` only means
`opentelemetry-api` resolves — it says nothing about the SDK or the gRPC OTLP
exporter package. `opentelemetry_instrument.py` used to import the gRPC
exporter unconditionally under that coarse guard, crashing `import
lite_bootstrap` in any environment with bare `opentelemetry-api` present (e.g.
`lite-bootstrap[fastmcp]`, which pulls it in transitively without the rest of
the stack). The exporter import — and its use in `bootstrap()` — now sit behind
their own `import_checker.is_otlp_grpc_exporter_installed` guard
(`_safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")`).
The honest trade-off: `check_dependencies()` still gates OpenTelemetry
activation on `is_opentelemetry_installed` alone, so setting
`opentelemetry_endpoint` in an environment where `opentelemetry-api` is present
but the exporter package is not now **silently skips OTLP export** —
`bootstrap()`'s exporter block simply omits the span processor when
`is_otlp_grpc_exporter_installed` is False, with none of the
`InstrumentDependencyMissingWarning` a configured-but-missing dependency
normally gets. Making `check_dependencies()` distinguish api/sdk/exporter
presence, so this case gets the standard warning instead of silence, is
deferred — see `planning/deferred.md`.

## Why instruments are not frozen

All `*Config` classes are frozen, but `*Instrument` classes drop `frozen=True`
Expand Down
8 changes: 8 additions & 0 deletions lite_bootstrap/bootstrappers/faststream_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ def __init__(
include_messages_counters: bool = True,
) -> None: ...

# A constructed instance is passed to broker.add_middleware, which expects a
# faststream BrokerMiddleware — a builder callable. Declared with Any to keep
# lite-bootstrap decoupled from faststream's internal middleware types.
def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401


@typing.runtime_checkable
class FastStreamPrometheusMiddlewareProtocol(typing.Protocol):
Expand All @@ -55,6 +60,9 @@ def __init__(
received_messages_size_buckets: typing.Sequence[float] | None = None,
) -> None: ...

# See FastStreamTelemetryMiddlewareProtocol.__call__.
def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401


def _make_asgi_faststream() -> "AsgiFastStream":
return AsgiFastStream()
Expand Down
21 changes: 18 additions & 3 deletions lite_bootstrap/import_checker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
from importlib.util import find_spec


def _safe_find_spec(module_name: str) -> bool:
"""Return whether a dotted module is importable, treating a missing parent as absent.

find_spec imports the dotted name's parent first; a present-but-incomplete
namespace (e.g. opentelemetry-api installed without opentelemetry-instrumentation)
otherwise raises ModuleNotFoundError, crashing `import lite_bootstrap`.
"""
try:
return find_spec(module_name) is not None
except ModuleNotFoundError:
return False


is_opentelemetry_installed = find_spec("opentelemetry") is not None
is_sentry_installed = find_spec("sentry_sdk") is not None
is_structlog_installed = find_spec("structlog") is not None
Expand All @@ -9,11 +22,13 @@
is_litestar_installed = find_spec("litestar") is not None
is_faststream_installed = find_spec("faststream") is not None
is_prometheus_fastapi_instrumentator_installed = find_spec("prometheus_fastapi_instrumentator") is not None
is_fastapi_opentelemetry_installed = (
is_opentelemetry_installed and find_spec("opentelemetry.instrumentation.fastapi") is not None
is_fastapi_opentelemetry_installed = is_opentelemetry_installed and _safe_find_spec(
"opentelemetry.instrumentation.fastapi"
)
is_litestar_opentelemetry_installed = (
is_opentelemetry_installed and is_litestar_installed and find_spec("opentelemetry.instrumentation.asgi") is not None
is_opentelemetry_installed and is_litestar_installed and _safe_find_spec("opentelemetry.instrumentation.asgi")
)
is_otlp_grpc_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")
is_pyroscope_installed = find_spec("pyroscope") is not None
is_fastmcp_installed = find_spec("fastmcp") is not None
is_orjson_installed = find_spec("orjson") is not None
25 changes: 20 additions & 5 deletions lite_bootstrap/instruments/logging_factory.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import dataclasses
import json
import logging
import logging.handlers
import sys
import typing

import orjson

from lite_bootstrap import import_checker


if import_checker.is_orjson_installed:
import orjson


ScopeType = typing.MutableMapping[str, typing.Any]


Expand All @@ -31,10 +34,22 @@ class _MemoryLoggerFactoryConfig:
log_stream: typing.Any = sys.stdout


def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
def _dumps_orjson(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
return orjson.dumps(value, **kwargs).decode()


def _dumps_stdlib(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
# Match orjson's compact, UTF-8 output shape so log lines stay byte-identical.
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, **kwargs)


# orjson has no free-threaded wheels and refuses to build on ft; fall back to the
# stdlib json accelerator (always ft-native) when it is absent.
# See architecture/free-threading.md.
_serialize_log_to_string = _dumps_orjson if import_checker.is_orjson_installed else _dumps_stdlib
_json_loads = orjson.loads if import_checker.is_orjson_installed else json.loads


# Meta-keys the producer's structlog processor chain emits at the top level of every
# rendered line (see lite_bootstrap/instruments/logging_instrument.py). They are not
# user-supplied extra and are stripped from StructuredLogPayload.extra. If you add a
Expand Down Expand Up @@ -68,8 +83,8 @@ def parse(cls, formatted: str) -> "StructuredLogPayload | None":
if not formatted.startswith("{"):
return None
try:
loaded = orjson.loads(formatted)
except orjson.JSONDecodeError:
loaded = _json_loads(formatted)
except json.JSONDecodeError: # orjson.JSONDecodeError subclasses this
return None
if not isinstance(loaded, dict): # pragma: no cover - JSON starting with "{" is always an object
return None
Expand Down
6 changes: 3 additions & 3 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
RequestProtocol,
ScopeType,
_MemoryLoggerFactoryConfig,
_serialize_log_with_orjson_to_string,
_serialize_log_to_string,
)


Expand Down Expand Up @@ -117,7 +117,7 @@ def structlog_processors(self) -> list[typing.Any]:
structlog.stdlib.filter_by_level,
*self.structlog_pre_chain_processors,
*self.bootstrap_config.logging_extra_processors,
structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string),
structlog.processors.JSONRenderer(serializer=_serialize_log_to_string),
]

@property
Expand Down Expand Up @@ -152,7 +152,7 @@ def _configure_foreign_loggers(self) -> None:
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
*self.bootstrap_config.logging_extra_processors,
structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string),
structlog.processors.JSONRenderer(serializer=_serialize_log_to_string),
],
logger=root_logger,
)
Expand Down
11 changes: 9 additions & 2 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@

if import_checker.is_opentelemetry_installed:
from opentelemetry.context import Context
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk import resources
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
from opentelemetry.trace import Span, format_span_id, set_tracer_provider

if import_checker.is_otlp_grpc_exporter_installed:
# opentelemetry-api can be present without the grpc otlp exporter package (e.g.
# lite-bootstrap[fastmcp] pulls bare opentelemetry-api transitively); this must
# stay a separate guard from is_opentelemetry_installed above.
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

if import_checker.is_pyroscope_installed:
import pyroscope

Expand Down Expand Up @@ -176,7 +181,9 @@ def bootstrap(self) -> None:
tracer_provider.add_span_processor(PyroscopeSpanProcessor())
if self.bootstrap_config.opentelemetry_log_traces:
tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span)))
if self.bootstrap_config.opentelemetry_endpoint: # pragma: no cover
if (
self.bootstrap_config.opentelemetry_endpoint and import_checker.is_otlp_grpc_exporter_installed
): # pragma: no cover
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
Expand Down
Loading