diff --git a/.gitignore b/.gitignore index 8cf31db..708f278 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ dist/ .python-version .venv uv.lock +plan.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..770f7a5 --- /dev/null +++ b/CLAUDE.md @@ -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[]` or `uv add lite-bootstrap[]`: + +| 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` diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index fba0bae..737b086 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -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): diff --git a/docs/integrations/faststream.md b/docs/integrations/faststream.md index 19d831a..9455f15 100644 --- a/docs/integrations/faststream.md +++ b/docs/integrations/faststream.md @@ -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): diff --git a/docs/integrations/free.md b/docs/integrations/free.md index 5f74c05..2a75b84 100644 --- a/docs/integrations/free.md +++ b/docs/integrations/free.md @@ -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): diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index ca98070..044e216 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -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): diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 904de41..bf7e8fc 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -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/). diff --git a/docs/introduction/installation.md b/docs/introduction/installation.md index ba2b2bc..a88d2a1 100644 --- a/docs/introduction/installation.md +++ b/docs/introduction/installation.md @@ -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] ``` diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 7f8f4c3..17cc660 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -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 diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index d8c98fc..7fbcc3d 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -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) @@ -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, @@ -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: @@ -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" diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index bc5b2ed..5ac3aa2 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -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: @@ -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()) ) @@ -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" diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index c6b226b..f48ce18 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -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, diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index b40f860..5badb17 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -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 @@ -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 ), diff --git a/tests/conftest.py b/tests/conftest.py index 7a4c390..6fc2dd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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