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: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dist/
.python-version
.venv
uv.lock
plan.md
69 changes: 69 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
just install # Update lock file and sync all extras + lint group
just lint # Format and lint (eof-fixer, ruff format, ruff check --fix, ty check)
just lint-ci # CI lint in check-only mode (no auto-fix)
just test # Run pytest with coverage
just test -- -k "test_name" # Run a single test
just test-branch # Run tests with branch coverage
```

All commands use `uv run` — do not invoke tools directly (e.g., use `uv run pytest`, not `pytest`).

## Architecture

**lite-bootstrap** bootstraps Python microservices with pre-configured observability instruments.

### Core pattern

```
BaseConfig (dataclass, frozen, kw_only)
└── Framework configs compose multiple instrument configs via multiple inheritance

BaseInstrument (abstract)
└── Instrument subclasses: lifecycle via bootstrap() / teardown() / is_ready()

BaseBootstrapper (abstract)
├── FastAPIBootstrapper
├── LitestarBootstrapper
├── FastStreamBootstrapper
└── FreeBootstrapper
```

### Key design decisions

- **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.
- **Frozen dataclass configs**: All configs are `@dataclasses.dataclass(kw_only=True, frozen=True)`. `from_dict()` and `from_object()` filter unknown keys before constructing.
- **Instrument registry**: `BaseBootstrapper` holds a list of instrument instances; it calls `bootstrap()` on each in order and `teardown()` in reverse during shutdown.
- **Logging ↔ Sentry integration**: `logging_instrument.py` injects structlog context into Sentry events. `sentry_instrument.py` chains `before_send` callbacks via `wrap_before_send_callbacks()`. The `skip_sentry` flag in log context suppresses events.
- **OTel ↔ Logging integration**: The logging instrument injects span/trace IDs from the active OpenTelemetry context into every log record.

### Module layout

- `lite_bootstrap/bootstrappers/` — framework-specific bootstrappers and their config classes
- `lite_bootstrap/instruments/` — individual instrument implementations (one file per tool)
- `lite_bootstrap/helpers/` — utility functions (`fastapi_helpers.py` serves offline Swagger UI assets)
- `lite_bootstrap/import_checker.py` — detects installed optional packages
- `lite_bootstrap/types.py` — shared TypeVars

### Optional dependency groups

Install via `pip install lite-bootstrap[<group>]` or `uv add lite-bootstrap[<group>]`:

| Group | Contents |
|-------|----------|
| `fastapi-all` | fastapi + sentry + otl + logging + metrics |
| `litestar-all` | litestar + sentry + otl + logging |
| `faststream-all` | faststream + sentry + otl + logging |
| `free-all` | sentry + otl + logging |

## Code style

- Line length: 120 characters (ruff enforced)
- Ruff ALL rules enabled; notable ignores: D1 (missing docstrings), S101 (assert), TCH (type-checking imports), FBT (boolean args)
- Type annotations required; checked with `ty`
12 changes: 6 additions & 6 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@

*Another example of usage with FastAPI - [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template)*

## 1. Install `lite-bootstrapp[fastapi-all]`:
## 1. Install `lite-bootstrap[fastapi-all]`:

=== "uv"

```bash
uv add lite-bootstrapp[fastapi-all]
uv add lite-bootstrap[fastapi-all]
```

=== "pip"

```bash
pip install lite-bootstrapp[fastapi-all]
pip install lite-bootstrap[fastapi-all]
```

=== "poetry"

```bash
poetry add lite-bootstrapp[fastapi-all]
poetry add lite-bootstrap[fastapi-all]
```

Read more about available extras [here](../../../introduction/installation):
Expand Down
8 changes: 4 additions & 4 deletions docs/integrations/faststream.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
# Usage with `FastStream`

## 1. Install `lite-bootstrapp[faststream-all]`:
## 1. Install `lite-bootstrap[faststream-all]`:

=== "uv"

```bash
uv add lite-bootstrapp[faststream-all]
uv add lite-bootstrap[faststream-all]
```

=== "pip"

```bash
pip install lite-bootstrapp[faststream-all]
pip install lite-bootstrap[faststream-all]
```

=== "poetry"

```bash
poetry add lite-bootstrapp[faststream-all]
poetry add lite-bootstrap[faststream-all]
```

Read more about available extras [here](../../../introduction/installation):
Expand Down
12 changes: 6 additions & 6 deletions docs/integrations/free.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
# Usage without frameworks

## 1. Install `lite-bootstrapp[free-all]`:
## 1. Install `lite-bootstrap[free-all]`:

=== "uv"

```bash
uv add lite-bootstrapp[free-all]
uv add lite-bootstrap[free-all]
```

=== "pip"

```bash
pip install lite-bootstrapp[free-all]
pip install lite-bootstrap[free-all]
```

=== "poetry"

```bash
poetry add lite-bootstrapp[free-all]
poetry add lite-bootstrap[free-all]
```

Read more about available extras [here](../../../introduction/installation):
Expand Down
6 changes: 3 additions & 3 deletions docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@
=== "uv"

```bash
uv add lite-bootstrapp[litestar-all]
uv add lite-bootstrap[litestar-all]
```

=== "pip"

```bash
pip install lite-bootstrapp[litestar-all]
pip install lite-bootstrap[litestar-all]
```

=== "poetry"

```bash
poetry add lite-bootstrapp[litestar-all]
poetry add lite-bootstrap[litestar-all]
```

Read more about available extras [here](../../../introduction/installation):
Expand Down
4 changes: 3 additions & 1 deletion docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ Additional parameters can also be supplied through the settings object:
- `sentry_attach_stacktrace` - if True, stack traces are automatically attached to all messages logged
- `sentry_integrations` - list of integrations to enable
- `sentry_tags` - key/value string pairs that are both indexed and searchable
- `sentry_additional_params** - additional params, which will be passed to `sentry_sdk.init`
- `sentry_additional_params` - additional params, which will be passed to `sentry_sdk.init`
- `sentry_default_integrations` - whether to use sentry's default integrations (default: `True`)
- `sentry_before_send` - optional callback chained after the built-in structlog enricher, passed to `sentry_sdk.init(before_send=...)`

Read more about sentry_sdk params [here](https://docs.sentry.io/platforms/python/configuration/options/).

Expand Down
6 changes: 3 additions & 3 deletions docs/introduction/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ For example, if you want to bootstrap litestar with structlog and opentelemetry
=== "uv"

```bash
uv add lite-bootstrapp[litestar-logging,litestar-otl]
uv add lite-bootstrap[litestar-logging,litestar-otl]
```

=== "pip"

```bash
pip install lite-bootstrapp[litestar-logging,litestar-otl]
pip install lite-bootstrap[litestar-logging,litestar-otl]
```

=== "poetry"

```bash
poetry add lite-bootstrapp[litestar-logging,litestar-otl]
poetry add lite-bootstrap[litestar-logging,litestar-otl]
```
1 change: 0 additions & 1 deletion lite_bootstrap/bootstrappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@


class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]):
SLOTS = "bootstrap_config", "instruments", "is_bootstrapped"
instruments_types: typing.ClassVar[list[type[BaseInstrument]]]
instruments: list[BaseInstrument]
bootstrap_config: BaseConfig
Expand Down
12 changes: 6 additions & 6 deletions lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
class FastAPIConfig(
CorsConfig, HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, SentryConfig, SwaggerConfig
):
application: "fastapi.FastAPI" = dataclasses.field(default=None) # type: ignore[assignment]
application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment]
application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list)
prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
Expand All @@ -59,12 +59,12 @@ def __post_init__(self) -> None:


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class FastApiCorsInstrument(CorsInstrument):
class FastAPICorsInstrument(CorsInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
self.bootstrap_config.application.add_middleware(
CORSMiddleware, # ty: ignore[invalid-argument-type]
CORSMiddleware,
allow_origins=self.bootstrap_config.cors_allowed_origins,
allow_methods=self.bootstrap_config.cors_allowed_methods,
allow_headers=self.bootstrap_config.cors_allowed_headers,
Expand Down Expand Up @@ -152,7 +152,7 @@ def bootstrap(self) -> None:


@dataclasses.dataclass(kw_only=True, frozen=True)
class FastApiSwaggerInstrument(SwaggerInstrument):
class FastAPISwaggerInstrument(SwaggerInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
Expand All @@ -172,13 +172,13 @@ class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]):
__slots__ = "bootstrap_config", "instruments"

instruments_types: typing.ClassVar = [
FastApiCorsInstrument,
FastAPICorsInstrument,
FastAPIOpenTelemetryInstrument,
FastAPISentryInstrument,
FastAPIHealthChecksInstrument,
FastAPILoggingInstrument,
FastAPIPrometheusInstrument,
FastApiSwaggerInstrument,
FastAPISwaggerInstrument,
]
bootstrap_config: FastAPIConfig
not_ready_message = "fastapi is not installed"
Expand Down
13 changes: 9 additions & 4 deletions lite_bootstrap/bootstrappers/faststream_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ def bootstrap(self) -> None:
async def check_health(_: object) -> "AsgiResponse":
return (
AsgiResponse(
json.dumps(self.render_health_check_data()).encode(), 200, headers={"content-type": "text/plain"}
json.dumps(self.render_health_check_data()).encode(),
200,
headers={"content-type": "application/json"},
)
if await self._define_health_status()
else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "application/json"})
else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "text/plain"})
)

if self.bootstrap_config.opentelemetry_generate_health_check_spans:
Expand Down Expand Up @@ -101,7 +103,6 @@ def is_ready(self) -> bool:

def bootstrap(self) -> None:
if self.bootstrap_config.opentelemetry_middleware_cls and self.bootstrap_config.application.broker:
self.bootstrap_config.opentelemetry_middleware_cls(tracer_provider=get_tracer_provider())
self.bootstrap_config.application.broker.add_middleware(
self.bootstrap_config.opentelemetry_middleware_cls(tracer_provider=get_tracer_provider())
)
Expand All @@ -112,11 +113,15 @@ class FastStreamSentryInstrument(SentryInstrument):
bootstrap_config: FastStreamConfig


def _make_collector_registry() -> "prometheus_client.CollectorRegistry":
return prometheus_client.CollectorRegistry()


@dataclasses.dataclass(kw_only=True, frozen=True)
class FastStreamPrometheusInstrument(PrometheusInstrument):
bootstrap_config: FastStreamConfig
collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field(
default_factory=prometheus_client.CollectorRegistry, init=False
default_factory=_make_collector_registry, init=False
)
not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_middleware_cls is missing"
missing_dependency_message = "prometheus_client is not installed"
Expand Down
2 changes: 1 addition & 1 deletion lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class LitestarCorsInstrument(CorsInstrument):
def bootstrap(self) -> None:
self.bootstrap_config.application_config.cors_config = CORSConfig(
allow_origins=self.bootstrap_config.cors_allowed_origins,
allow_methods=self.bootstrap_config.cors_allowed_methods, # type: ignore[arg-type]
allow_methods=self.bootstrap_config.cors_allowed_methods, # ty: ignore[invalid-argument-type]
allow_headers=self.bootstrap_config.cors_allowed_headers,
allow_credentials=self.bootstrap_config.cors_allowed_credentials,
allow_origin_regex=self.bootstrap_config.cors_allowed_origin_regex,
Expand Down
3 changes: 2 additions & 1 deletion lite_bootstrap/instruments/sentry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def enrich_sentry_event_from_structlog_log(
return None

if event_name := loaded_formatted_log.get("event"):
event["logentry"]["formatted"] = event_name
event["logentry"]["formatted"] = event_name # ty: ignore[invalid-assignment]
else:
return event

Expand Down Expand Up @@ -114,6 +114,7 @@ def bootstrap(self) -> None:
max_value_length=self.bootstrap_config.sentry_max_value_length,
attach_stacktrace=self.bootstrap_config.sentry_attach_stacktrace,
integrations=self.bootstrap_config.sentry_integrations,
default_integrations=self.bootstrap_config.sentry_default_integrations,
before_send=wrap_before_send_callbacks(
enrich_sentry_event_from_structlog_log, self.bootstrap_config.sentry_before_send
),
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def logging_mock() -> LoggingMock:
@contextlib.contextmanager
def emulate_package_missing(package_name: str) -> typing.Iterator[None]:
old_module = sys.modules[package_name]
sys.modules[package_name] = None # type: ignore[assignment]
sys.modules[package_name] = None # ty: ignore[invalid-assignment]
reload(import_checker)
try:
yield
Expand Down
Loading