├── tests ├── __init__.py ├── bootstrappers │ ├── __init__.py │ ├── test_fastapi.py │ ├── test_litestar.py │ ├── test_faststream.py │ └── test_litestar_opentelemetry.py ├── instruments │ ├── __init__.py │ ├── test_pyroscope.py │ ├── test_instrument_box.py │ ├── test_health_checks.py │ ├── test_cors.py │ ├── test_sentry.py │ ├── test_opentelemetry.py │ ├── test_prometheus.py │ ├── test_swagger.py │ └── test_logging.py ├── test_granian_server.py ├── test_settings.py ├── test_instruments_setupper.py ├── conftest.py └── test_helpers.py ├── microbootstrap ├── py.typed ├── config │ ├── __init__.py │ ├── litestar.py │ ├── faststream.py │ └── fastapi.py ├── instruments │ ├── __init__.py │ ├── swagger_instrument.py │ ├── cors_instrument.py │ ├── health_checks_instrument.py │ ├── instrument_box.py │ ├── base.py │ ├── pyroscope_instrument.py │ ├── prometheus_instrument.py │ ├── sentry_instrument.py │ ├── logging_instrument.py │ └── opentelemetry_instrument.py ├── middlewares │ ├── __init__.py │ ├── fastapi.py │ └── litestar.py ├── bootstrappers │ ├── __init__.py │ ├── base.py │ ├── faststream.py │ ├── fastapi.py │ └── litestar.py ├── exceptions.py ├── granian_server.py ├── console_writer.py ├── __init__.py ├── instruments_setupper.py ├── settings.py └── helpers.py ├── .gitignore ├── .github └── workflows │ ├── workflow.yml │ └── release_docs.yml ├── package.json ├── Justfile ├── examples ├── fastapi_app.py ├── litestar_app.py └── faststream_app.py ├── pyproject.toml └── logo.svg /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /microbootstrap/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /microbootstrap/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/bootstrappers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/instruments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /microbootstrap/instruments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /microbootstrap/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /microbootstrap/bootstrappers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | __pycache__/* 4 | *.swp 5 | *.sqlite3 6 | *.map 7 | .vscode 8 | .idea 9 | .DS_Store 10 | .env 11 | .mypy_cache 12 | .pytest_cache 13 | .ruff_cache 14 | .coverage 15 | htmlcov/ 16 | coverage.xml 17 | pytest.xml 18 | dist/ 19 | .python-version 20 | .venv 21 | uv.lock 22 | -------------------------------------------------------------------------------- /tests/test_granian_server.py: -------------------------------------------------------------------------------- 1 | import granian 2 | 3 | from microbootstrap.granian_server import create_granian_server 4 | from microbootstrap.settings import ServerConfig 5 | 6 | 7 | def test_granian_server(minimal_server_config: ServerConfig) -> None: 8 | assert isinstance(create_granian_server("some:app", minimal_server_config), granian.Granian) 9 | -------------------------------------------------------------------------------- /microbootstrap/exceptions.py: -------------------------------------------------------------------------------- 1 | class MicroBootstrapBaseError(Exception): 2 | """Base for all exceptions.""" 3 | 4 | 5 | class ConfigMergeError(MicroBootstrapBaseError): 6 | """Raises when it's impossible to merge configs due to type mismatch.""" 7 | 8 | 9 | class MissingInstrumentError(MicroBootstrapBaseError): 10 | """Raises when attempting to configure instrument, that is not supported yet.""" 11 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | release: 11 | types: 12 | - published 13 | 14 | jobs: 15 | ci: 16 | uses: community-of-python/community-workflow/.github/workflows/preset.yml@main 17 | with: 18 | python-version: '["3.10","3.11","3.12","3.13", "3.14"]' 19 | secrets: inherit 20 | -------------------------------------------------------------------------------- /microbootstrap/config/litestar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import dataclasses 3 | import typing 4 | 5 | from litestar.config.app import AppConfig 6 | from litestar.logging import LoggingConfig 7 | 8 | 9 | if typing.TYPE_CHECKING: 10 | from litestar.types import OnAppInitHandler 11 | 12 | 13 | @dataclasses.dataclass 14 | class LitestarConfig(AppConfig): 15 | on_app_init: typing.Sequence[OnAppInitHandler] | None = None 16 | logging_config: LoggingConfig = dataclasses.field( 17 | default_factory=lambda: LoggingConfig( 18 | # required for foreign logs json formatting 19 | configure_root_logger=False, 20 | ) 21 | ) 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "microbootstrap-docs", 3 | "version": "1.0.0", 4 | "description": "Vuepress documentation for microbootstrap package", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "docs:build": "vuepress build docs", 9 | "docs:clean-dev": "vuepress dev docs --clean-cache", 10 | "docs:dev": "vuepress dev docs", 11 | "docs:update-package": "npx vp-update" 12 | }, 13 | "devDependencies": { 14 | "@vuepress/bundler-vite": "^2.0.0-rc.7", 15 | "@vuepress/theme-default": "^2.0.0-rc.11", 16 | "vue": "^3.4.0", 17 | "vuepress": "^2.0.0-rc.7" 18 | }, 19 | "dependencies": { 20 | "vuepress-theme-hope": "^2.0.0-rc.52" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | default: install lint test 2 | 3 | install: 4 | uv lock --upgrade 5 | uv sync --all-extras --frozen 6 | 7 | lint: 8 | uv run ruff format 9 | uv run ruff check --fix 10 | uv run mypy . 11 | 12 | lint-ci: 13 | uv run ruff format --check 14 | uv run ruff check --no-fix 15 | uv run mypy . 16 | 17 | test *args: 18 | uv run --no-sync pytest {{ args }} 19 | 20 | publish: 21 | rm -rf dist 22 | uv version $GITHUB_REF_NAME 23 | uv build 24 | uv publish --token $PYPI_TOKEN 25 | 26 | run-faststream-example *args: 27 | #!/bin/bash 28 | trap 'echo; docker rm -f microbootstrap-redis' EXIT 29 | docker run --name microbootstrap-redis -p 6379:6379 -d redis 30 | uv run examples/faststream_app.py 31 | -------------------------------------------------------------------------------- /examples/fastapi_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | 4 | from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper 5 | from microbootstrap.granian_server import create_granian_server 6 | from microbootstrap.settings import FastApiSettings 7 | 8 | 9 | if typing.TYPE_CHECKING: 10 | import fastapi 11 | 12 | 13 | class Settings(FastApiSettings): ... 14 | 15 | 16 | settings = Settings() 17 | 18 | 19 | def create_app() -> fastapi.FastAPI: 20 | app = FastApiBootstrapper(settings).bootstrap() 21 | 22 | @app.get("/") 23 | async def hello_world() -> dict[str, str]: 24 | return {"hello": "world"} 25 | 26 | return app 27 | 28 | 29 | if __name__ == "__main__": 30 | create_granian_server("examples.fastapi_app:create_app", settings, factory=True).serve() 31 | -------------------------------------------------------------------------------- /examples/litestar_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import litestar 4 | 5 | from microbootstrap import LitestarSettings 6 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper 7 | from microbootstrap.config.litestar import LitestarConfig 8 | from microbootstrap.granian_server import create_granian_server 9 | 10 | 11 | class Settings(LitestarSettings): ... 12 | 13 | 14 | settings = Settings() 15 | 16 | 17 | @litestar.get("/") 18 | async def hello_world() -> dict[str, str]: 19 | return {"hello": "world"} 20 | 21 | 22 | def create_app() -> litestar.Litestar: 23 | return ( 24 | LitestarBootstrapper(settings).configure_application(LitestarConfig(route_handlers=[hello_world])).bootstrap() 25 | ) 26 | 27 | 28 | if __name__ == "__main__": 29 | create_granian_server("examples.litestar_app:create_app", settings, factory=True).serve() 30 | -------------------------------------------------------------------------------- /.github/workflows/release_docs.yml: -------------------------------------------------------------------------------- 1 | name: Release microbootstrap documentation 2 | 3 | on: workflow_dispatch 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | deploy-gh-pages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | # if your docs needs submodules, uncomment the following line 17 | # submodules: true 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | cache: npm 23 | - name: Install Deps 24 | run: npm ci 25 | - name: Build Docs 26 | env: 27 | NODE_OPTIONS: --max_old_space_size=8192 28 | run: |- 29 | npm run docs:build 30 | > docs/.vuepress/dist/.nojekyll 31 | - name: Deploy Docs 32 | uses: JamesIves/github-pages-deploy-action@v4 33 | with: 34 | folder: docs/.vuepress/dist 35 | -------------------------------------------------------------------------------- /microbootstrap/instruments/swagger_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | 4 | import pydantic 5 | 6 | from microbootstrap.helpers import is_valid_path 7 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 8 | 9 | 10 | class SwaggerConfig(BaseInstrumentConfig): 11 | service_name: str = "micro-service" 12 | service_description: str = "Micro service description" 13 | service_version: str = "1.0.0" 14 | 15 | service_static_path: str = "/static" 16 | swagger_path: str = "/docs" 17 | swagger_offline_docs: bool = False 18 | swagger_extra_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 19 | 20 | 21 | class SwaggerInstrument(Instrument[SwaggerConfig]): 22 | instrument_name = "Swagger" 23 | ready_condition = "Provide valid swagger_path" 24 | 25 | def is_ready(self) -> bool: 26 | return bool(self.instrument_config.swagger_path) and is_valid_path(self.instrument_config.swagger_path) 27 | 28 | @classmethod 29 | def get_config_type(cls) -> type[SwaggerConfig]: 30 | return SwaggerConfig 31 | -------------------------------------------------------------------------------- /microbootstrap/instruments/cors_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pydantic 4 | 5 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 6 | 7 | 8 | class CorsConfig(BaseInstrumentConfig): 9 | cors_allowed_origins: list[str] = pydantic.Field(default_factory=list) 10 | cors_allowed_methods: list[str] = pydantic.Field(default_factory=list) 11 | cors_allowed_headers: list[str] = pydantic.Field(default_factory=list) 12 | cors_exposed_headers: list[str] = pydantic.Field(default_factory=list) 13 | cors_allowed_credentials: bool = False 14 | cors_allowed_origin_regex: str | None = None 15 | cors_max_age: int = 600 16 | 17 | 18 | class CorsInstrument(Instrument[CorsConfig]): 19 | instrument_name = "Cors" 20 | ready_condition = "Provide allowed origins or regex" 21 | 22 | def is_ready(self) -> bool: 23 | return bool(self.instrument_config.cors_allowed_origins) or bool( 24 | self.instrument_config.cors_allowed_origin_regex, 25 | ) 26 | 27 | @classmethod 28 | def get_config_type(cls) -> type[CorsConfig]: 29 | return CorsConfig 30 | -------------------------------------------------------------------------------- /microbootstrap/config/faststream.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import dataclasses 3 | import typing 4 | 5 | 6 | if typing.TYPE_CHECKING: 7 | from fast_depends import Provider 8 | from fast_depends.library.serializer import SerializerProto 9 | from faststream._internal.basic_types import ( 10 | AnyCallable, 11 | Lifespan, 12 | LoggerProto, 13 | ) 14 | from faststream._internal.broker import BrokerUsecase 15 | from faststream._internal.context import ContextRepo 16 | from faststream.asgi.types import ASGIApp 17 | from faststream.specification.base import SpecificationFactory 18 | 19 | 20 | @dataclasses.dataclass 21 | class FastStreamConfig: 22 | broker: BrokerUsecase[typing.Any, typing.Any] | None = None 23 | asgi_routes: typing.Sequence[tuple[str, ASGIApp]] = () 24 | logger: LoggerProto | None = None 25 | provider: Provider | None = None 26 | serializer: SerializerProto | None = None 27 | context: ContextRepo | None = None 28 | lifespan: Lifespan | None = None 29 | on_startup: typing.Sequence[AnyCallable] = () 30 | after_startup: typing.Sequence[AnyCallable] = () 31 | on_shutdown: typing.Sequence[AnyCallable] = () 32 | after_shutdown: typing.Sequence[AnyCallable] = () 33 | specification: SpecificationFactory | None = None 34 | -------------------------------------------------------------------------------- /microbootstrap/granian_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | import typing 4 | 5 | import granian 6 | from granian.constants import Interfaces 7 | from granian.log import LogLevels 8 | 9 | 10 | if typing.TYPE_CHECKING: 11 | from granian.server.common import AbstractServer as GranianServer 12 | 13 | from microbootstrap.settings import ServerConfig 14 | 15 | 16 | GRANIAN_LOG_LEVELS_MAP = { 17 | logging.CRITICAL: LogLevels.critical, 18 | logging.ERROR: LogLevels.error, 19 | logging.WARNING: LogLevels.warning, 20 | logging.WARNING: LogLevels.warn, 21 | logging.INFO: LogLevels.info, 22 | logging.DEBUG: LogLevels.debug, 23 | } 24 | 25 | 26 | # TODO: create bootstrappers for application servers. granian/uvicorn # noqa: TD002 27 | def create_granian_server( 28 | target: str, 29 | settings: ServerConfig, 30 | **granian_options: typing.Any, # noqa: ANN401 31 | ) -> GranianServer[typing.Any]: 32 | return granian.Granian( 33 | target=target, 34 | address=settings.server_host, 35 | port=settings.server_port, 36 | interface=Interfaces.ASGI, 37 | workers=settings.server_workers_count, 38 | log_level=GRANIAN_LOG_LEVELS_MAP[getattr(settings, "logging_log_level", logging.INFO)], 39 | reload=settings.server_reload, 40 | **granian_options, 41 | ) 42 | -------------------------------------------------------------------------------- /microbootstrap/console_writer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import dataclasses 3 | import typing 4 | 5 | from rich.console import Console 6 | from rich.rule import Rule 7 | from rich.table import Table 8 | 9 | 10 | @dataclasses.dataclass 11 | class ConsoleWriter: 12 | writer_enabled: bool = True 13 | rich_console: Console = dataclasses.field(init=False, default_factory=Console) 14 | rich_table: Table = dataclasses.field(init=False) 15 | 16 | def __post_init__(self) -> None: 17 | self.rich_table = Table(show_header=False, header_style="cyan") 18 | self.rich_table.add_column("Item", style="cyan") 19 | self.rich_table.add_column("Status") 20 | self.rich_table.add_column("Reason", style="yellow") 21 | 22 | def write_instrument_status( 23 | self, 24 | instrument_name: str, 25 | is_enabled: bool, 26 | disable_reason: str | None = None, 27 | ) -> None: 28 | is_enabled_value: typing.Final = "[green]Enabled[/green]" if is_enabled else "[red]Disabled[/red]" 29 | self.rich_table.add_row(rf"{instrument_name}", is_enabled_value, disable_reason or "") 30 | 31 | def print_bootstrap_table(self) -> None: 32 | if self.writer_enabled: 33 | self.rich_console.print(Rule("[yellow]Bootstrapping application[/yellow]", align="left")) 34 | self.rich_console.print(self.rich_table) 35 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import pytest 4 | 5 | import microbootstrap.settings 6 | 7 | 8 | pytestmark = [pytest.mark.usefixtures("reset_reloaded_settings_module")] 9 | 10 | 11 | @pytest.mark.parametrize("alias", ["SERVICE_NAME", "MY_SERVICE_SERVICE_NAME"]) 12 | def test_settings_service_name_aliases(monkeypatch: pytest.MonkeyPatch, alias: str) -> None: 13 | monkeypatch.setenv("ENVIRONMENT_PREFIX", "MY_SERVICE_") 14 | monkeypatch.setenv(alias, "my service") 15 | importlib.reload(microbootstrap.settings) 16 | 17 | settings = microbootstrap.settings.BaseServiceSettings() 18 | assert settings.service_name == "my service" 19 | 20 | 21 | def test_settings_service_name_default() -> None: 22 | settings = microbootstrap.settings.BaseServiceSettings() 23 | assert settings.service_name == "micro-service" 24 | 25 | 26 | @pytest.mark.parametrize("alias", ["CI_COMMIT_TAG", "MY_SERVICE_SERVICE_VERSION"]) 27 | def test_settings_service_version_aliases(monkeypatch: pytest.MonkeyPatch, alias: str) -> None: 28 | monkeypatch.setenv("ENVIRONMENT_PREFIX", "MY_SERVICE_") 29 | monkeypatch.setenv(alias, "1.2.3") 30 | importlib.reload(microbootstrap.settings) 31 | 32 | settings = microbootstrap.settings.BaseServiceSettings() 33 | assert settings.service_version == "1.2.3" 34 | 35 | 36 | def test_settings_service_version_default() -> None: 37 | settings = microbootstrap.settings.BaseServiceSettings() 38 | assert settings.service_version == "1.0.0" 39 | -------------------------------------------------------------------------------- /microbootstrap/instruments/health_checks_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing_extensions 4 | 5 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 6 | 7 | 8 | class HealthCheckTypedDict(typing_extensions.TypedDict, total=False): 9 | service_version: str | None 10 | service_name: str | None 11 | health_status: bool 12 | 13 | 14 | class HealthChecksConfig(BaseInstrumentConfig): 15 | service_name: str = "micro-service" 16 | service_version: str = "1.0.0" 17 | 18 | health_checks_enabled: bool = True 19 | health_checks_path: str = "/health/" 20 | health_checks_include_in_schema: bool = False 21 | 22 | # Cross-instrument parameter, comes from opentelemetry 23 | opentelemetry_generate_health_check_spans: bool = True 24 | 25 | 26 | class HealthChecksInstrument(Instrument[HealthChecksConfig]): 27 | instrument_name = "Health checks" 28 | ready_condition = "Set health_checks_enabled to True" 29 | 30 | def render_health_check_data(self) -> HealthCheckTypedDict: 31 | return { 32 | "service_version": self.instrument_config.service_version, 33 | "service_name": self.instrument_config.service_name, 34 | "health_status": True, 35 | } 36 | 37 | def is_ready(self) -> bool: 38 | return self.instrument_config.health_checks_enabled 39 | 40 | @classmethod 41 | def get_config_type(cls) -> type[HealthChecksConfig]: 42 | return HealthChecksConfig 43 | -------------------------------------------------------------------------------- /examples/faststream_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | from typing import TYPE_CHECKING 4 | 5 | from faststream.redis import RedisBroker 6 | 7 | from microbootstrap.bootstrappers.faststream import FastStreamBootstrapper 8 | from microbootstrap.config.faststream import FastStreamConfig 9 | from microbootstrap.granian_server import create_granian_server 10 | from microbootstrap.settings import FastStreamSettings 11 | 12 | 13 | if TYPE_CHECKING: 14 | from faststream.asgi import AsgiFastStream 15 | 16 | 17 | class Settings(FastStreamSettings): ... 18 | 19 | 20 | settings: typing.Final = Settings() 21 | 22 | 23 | def create_app() -> AsgiFastStream: 24 | broker = RedisBroker() 25 | 26 | @broker.subscriber("first") 27 | @broker.publisher("second") 28 | def _(message: str) -> str: 29 | print(message) # noqa: T201 30 | return "Hi from first handler!" 31 | 32 | @broker.subscriber("second") 33 | def _(message: str) -> None: 34 | print(message) # noqa: T201 35 | 36 | application: typing.Final = ( 37 | FastStreamBootstrapper(settings).configure_application(FastStreamConfig(broker=broker)).bootstrap() 38 | ) 39 | 40 | @application.after_startup 41 | async def send_first_message() -> None: 42 | await broker.connect() 43 | await broker.publish("Hi from startup!", "first") 44 | 45 | return application 46 | 47 | 48 | if __name__ == "__main__": 49 | create_granian_server("examples.faststream_app:create_app", settings, factory=True).serve() 50 | -------------------------------------------------------------------------------- /microbootstrap/middlewares/fastapi.py: -------------------------------------------------------------------------------- 1 | import time 2 | import typing 3 | 4 | import fastapi 5 | from fastapi import status 6 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 7 | 8 | from microbootstrap.helpers import optimize_exclude_paths 9 | from microbootstrap.instruments.logging_instrument import fill_log_message 10 | 11 | 12 | def build_fastapi_logging_middleware( 13 | exclude_endpoints: typing.Iterable[str], 14 | ) -> type[BaseHTTPMiddleware]: 15 | endpoints_to_ignore: typing.Collection[str] = optimize_exclude_paths(exclude_endpoints) 16 | 17 | class FastAPILoggingMiddleware(BaseHTTPMiddleware): 18 | async def dispatch( 19 | self, 20 | request: fastapi.Request, 21 | call_next: RequestResponseEndpoint, 22 | ) -> fastapi.Response: 23 | request_path: typing.Final = request.url.path.removesuffix("/") 24 | 25 | if request_path in endpoints_to_ignore: 26 | return await call_next(request) 27 | 28 | start_time: typing.Final = time.perf_counter_ns() 29 | try: 30 | response = await call_next(request) 31 | except Exception: # noqa: BLE001 32 | response = fastapi.Response(status_code=500) 33 | 34 | fill_log_message( 35 | "exception" if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR else "info", 36 | request, 37 | response.status_code, 38 | start_time, 39 | ) 40 | return response 41 | 42 | return FastAPILoggingMiddleware 43 | -------------------------------------------------------------------------------- /microbootstrap/__init__.py: -------------------------------------------------------------------------------- 1 | from microbootstrap.instruments.cors_instrument import CorsConfig 2 | from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig 3 | from microbootstrap.instruments.logging_instrument import LoggingConfig 4 | from microbootstrap.instruments.opentelemetry_instrument import ( 5 | FastStreamOpentelemetryConfig, 6 | FastStreamTelemetryMiddlewareProtocol, 7 | OpentelemetryConfig, 8 | ) 9 | from microbootstrap.instruments.prometheus_instrument import ( 10 | FastApiPrometheusConfig, 11 | FastStreamPrometheusConfig, 12 | FastStreamPrometheusMiddlewareProtocol, 13 | LitestarPrometheusConfig, 14 | ) 15 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeConfig 16 | from microbootstrap.instruments.sentry_instrument import SentryConfig 17 | from microbootstrap.instruments.swagger_instrument import SwaggerConfig 18 | from microbootstrap.settings import ( 19 | FastApiSettings, 20 | FastStreamSettings, 21 | InstrumentsSetupperSettings, 22 | LitestarSettings, 23 | ) 24 | 25 | 26 | __all__ = ( 27 | "CorsConfig", 28 | "FastApiPrometheusConfig", 29 | "FastApiSettings", 30 | "FastStreamOpentelemetryConfig", 31 | "FastStreamPrometheusConfig", 32 | "FastStreamPrometheusMiddlewareProtocol", 33 | "FastStreamSettings", 34 | "FastStreamTelemetryMiddlewareProtocol", 35 | "HealthChecksConfig", 36 | "InstrumentsSetupperSettings", 37 | "LitestarPrometheusConfig", 38 | "LitestarSettings", 39 | "LoggingConfig", 40 | "OpentelemetryConfig", 41 | "PyroscopeConfig", 42 | "SentryConfig", 43 | "SwaggerConfig", 44 | ) 45 | -------------------------------------------------------------------------------- /microbootstrap/middlewares/litestar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import time 3 | import typing 4 | 5 | import litestar 6 | import litestar.types 7 | from litestar.middleware.base import MiddlewareProtocol 8 | from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR 9 | 10 | from microbootstrap.helpers import optimize_exclude_paths 11 | from microbootstrap.instruments.logging_instrument import fill_log_message 12 | 13 | 14 | def build_litestar_logging_middleware( 15 | exclude_endpoints: typing.Iterable[str], 16 | ) -> type[MiddlewareProtocol]: 17 | endpoints_to_ignore: typing.Collection[str] = optimize_exclude_paths(exclude_endpoints) 18 | 19 | class LitestarLoggingMiddleware(MiddlewareProtocol): 20 | def __init__(self, app: litestar.types.ASGIApp) -> None: 21 | self.app = app 22 | 23 | async def __call__( 24 | self, 25 | request_scope: litestar.types.Scope, 26 | receive: litestar.types.Receive, 27 | send_function: litestar.types.Send, 28 | ) -> None: 29 | request: typing.Final[litestar.Request] = litestar.Request(request_scope) # type: ignore[type-arg] 30 | 31 | request_path = request.url.path.removesuffix("/") 32 | 33 | if request_path in endpoints_to_ignore: 34 | await self.app(request_scope, receive, send_function) 35 | return 36 | 37 | start_time: typing.Final[int] = time.perf_counter_ns() 38 | 39 | async def log_message_wrapper(message: litestar.types.Message) -> None: 40 | if message["type"] == "http.response.start": 41 | status = message["status"] 42 | log_level: str = "info" if status < HTTP_500_INTERNAL_SERVER_ERROR else "exception" 43 | fill_log_message(log_level, request, status, start_time) 44 | 45 | await send_function(message) 46 | 47 | await self.app(request_scope, receive, log_message_wrapper) 48 | 49 | return LitestarLoggingMiddleware 50 | -------------------------------------------------------------------------------- /microbootstrap/instruments/instrument_box.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from microbootstrap import exceptions 5 | from microbootstrap.instruments.base import Instrument, InstrumentConfigT 6 | from microbootstrap.settings import SettingsT 7 | 8 | 9 | @dataclasses.dataclass 10 | class InstrumentBox: 11 | __instruments__: list[type[Instrument[typing.Any]]] = dataclasses.field(default_factory=list) 12 | __initialized_instruments__: list[Instrument[typing.Any]] = dataclasses.field(default_factory=list) 13 | 14 | def initialize(self, settings: SettingsT) -> None: 15 | settings_dump = settings.model_dump() 16 | self.__initialized_instruments__ = [ 17 | instrument_type(instrument_type.get_config_type()(**settings_dump)) 18 | for instrument_type in self.__instruments__ 19 | ] 20 | 21 | def configure_instrument( 22 | self, 23 | instrument_config: InstrumentConfigT, 24 | ) -> None: 25 | for instrument in self.__initialized_instruments__: 26 | if isinstance(instrument_config, instrument.get_config_type()): 27 | instrument.configure_instrument(instrument_config) 28 | return 29 | 30 | raise exceptions.MissingInstrumentError( 31 | f"Instrument for config {instrument_config.__class__.__name__} is not supported yet.", 32 | ) 33 | 34 | def extend_instruments( 35 | self, 36 | instrument_class: type[Instrument[InstrumentConfigT]], 37 | ) -> type[Instrument[InstrumentConfigT]]: 38 | """Extend list of instruments, excluding one whose config is already in use.""" 39 | self.__instruments__ = list( 40 | filter( 41 | lambda instrument: instrument.get_config_type() is not instrument_class.get_config_type(), 42 | self.__instruments__, 43 | ), 44 | ) 45 | self.__instruments__.append(instrument_class) 46 | return instrument_class 47 | 48 | @property 49 | def instruments(self) -> list[Instrument[typing.Any]]: 50 | return self.__initialized_instruments__ 51 | -------------------------------------------------------------------------------- /microbootstrap/instruments/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import abc 3 | import dataclasses 4 | import typing 5 | 6 | import pydantic 7 | 8 | from microbootstrap.helpers import merge_pydantic_configs 9 | 10 | 11 | if typing.TYPE_CHECKING: 12 | from microbootstrap.console_writer import ConsoleWriter 13 | 14 | 15 | InstrumentConfigT = typing.TypeVar("InstrumentConfigT", bound="BaseInstrumentConfig") 16 | ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any) 17 | 18 | 19 | class BaseInstrumentConfig(pydantic.BaseModel): 20 | model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) 21 | 22 | 23 | @dataclasses.dataclass 24 | class Instrument(abc.ABC, typing.Generic[InstrumentConfigT]): 25 | instrument_config: InstrumentConfigT 26 | instrument_name: typing.ClassVar[str] 27 | ready_condition: typing.ClassVar[str] 28 | 29 | def configure_instrument( 30 | self, 31 | incoming_config: InstrumentConfigT, 32 | ) -> None: 33 | self.instrument_config = merge_pydantic_configs(self.instrument_config, incoming_config) 34 | 35 | def write_status(self, console_writer: ConsoleWriter) -> None: 36 | console_writer.write_instrument_status( 37 | self.instrument_name, 38 | is_enabled=self.is_ready(), 39 | disable_reason=None if self.is_ready() else self.ready_condition, 40 | ) 41 | 42 | @abc.abstractmethod 43 | def is_ready(self) -> bool: ... 44 | 45 | @classmethod 46 | @abc.abstractmethod 47 | def get_config_type(cls) -> type[InstrumentConfigT]: 48 | raise NotImplementedError 49 | 50 | def bootstrap(self) -> None: 51 | return None 52 | 53 | def teardown(self) -> None: 54 | return None 55 | 56 | def bootstrap_before(self) -> dict[str, typing.Any]: 57 | """Add some framework-related parameters to final bootstrap result before application creation.""" 58 | return {} 59 | 60 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 61 | """Add some framework-related parameters to final bootstrap result after application creation.""" 62 | return application 63 | -------------------------------------------------------------------------------- /tests/test_instruments_setupper.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from unittest import mock 3 | 4 | import faker 5 | import pytest 6 | 7 | from microbootstrap.instruments.sentry_instrument import SentryConfig 8 | from microbootstrap.instruments_setupper import InstrumentsSetupper 9 | from microbootstrap.settings import InstrumentsSetupperSettings 10 | 11 | 12 | def test_instruments_setupper_initializes_instruments() -> None: 13 | settings: typing.Final = InstrumentsSetupperSettings() 14 | assert InstrumentsSetupper(settings).instrument_box.instruments 15 | 16 | 17 | def test_instruments_setupper_applies_new_config(monkeypatch: pytest.MonkeyPatch, faker: faker.Faker) -> None: 18 | monkeypatch.setattr("sentry_sdk.init", sentry_sdk_init_mock := mock.Mock()) 19 | sentry_dsn: typing.Final = faker.pystr() 20 | current_setupper: typing.Final = InstrumentsSetupper(InstrumentsSetupperSettings()).configure_instruments( 21 | SentryConfig(sentry_dsn=sentry_dsn) 22 | ) 23 | 24 | with current_setupper: 25 | pass 26 | 27 | assert len(sentry_sdk_init_mock.mock_calls) == 1 28 | assert sentry_sdk_init_mock.mock_calls[0].kwargs.get("dsn") == sentry_dsn 29 | 30 | 31 | def test_instruments_setupper_causes_instruments_lifespan() -> None: 32 | current_setupper: typing.Final = InstrumentsSetupper(InstrumentsSetupperSettings()) 33 | instruments_count: typing.Final = len(current_setupper.instrument_box.instruments) 34 | current_setupper.instrument_box.__initialized_instruments__ = [mock.Mock() for _ in range(instruments_count)] 35 | 36 | with current_setupper: 37 | pass 38 | 39 | all_mock_calls: typing.Final = [ 40 | one_mocked_instrument.mock_calls # type: ignore[attr-defined] 41 | for one_mocked_instrument in current_setupper.instrument_box.instruments 42 | ] 43 | expected_successful_instrument_calls: typing.Final = [ 44 | mock.call.is_ready(), 45 | mock.call.bootstrap(), 46 | mock.call.write_status(current_setupper.console_writer), 47 | mock.call.is_ready(), 48 | mock.call.teardown(), 49 | ] 50 | assert all_mock_calls == [expected_successful_instrument_calls] * instruments_count 51 | -------------------------------------------------------------------------------- /microbootstrap/instruments/pyroscope_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | 4 | import pydantic 5 | 6 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 7 | 8 | 9 | try: 10 | import pyroscope # type: ignore[import-untyped] 11 | except ImportError: # pragma: no cover 12 | pyroscope = None # Not supported on Windows 13 | 14 | 15 | class PyroscopeConfig(BaseInstrumentConfig): 16 | service_name: str = "micro-service" 17 | opentelemetry_service_name: str | None = None 18 | opentelemetry_namespace: str | None = None 19 | 20 | pyroscope_endpoint: pydantic.HttpUrl | None = None 21 | pyroscope_sample_rate: int = 100 22 | pyroscope_auth_token: str | None = None 23 | pyroscope_tags: dict[str, str] = pydantic.Field(default_factory=dict) 24 | pyroscope_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 25 | 26 | 27 | class PyroscopeInstrument(Instrument[PyroscopeConfig]): 28 | instrument_name = "Pyroscope" 29 | ready_condition = "Provide pyroscope_endpoint" 30 | 31 | def is_ready(self) -> bool: 32 | return all([self.instrument_config.pyroscope_endpoint, pyroscope]) 33 | 34 | def teardown(self) -> None: 35 | pyroscope.shutdown() 36 | 37 | def bootstrap(self) -> None: 38 | pyroscope.configure( 39 | application_name=self.instrument_config.opentelemetry_service_name or self.instrument_config.service_name, 40 | server_address=str(self.instrument_config.pyroscope_endpoint), 41 | auth_token=self.instrument_config.pyroscope_auth_token or "", 42 | sample_rate=self.instrument_config.pyroscope_sample_rate, 43 | tags=( 44 | {"service_namespace": self.instrument_config.opentelemetry_namespace} 45 | if self.instrument_config.opentelemetry_namespace 46 | else {} 47 | ) 48 | | self.instrument_config.pyroscope_tags, 49 | **self.instrument_config.pyroscope_additional_params, 50 | ) 51 | 52 | @classmethod 53 | def get_config_type(cls) -> type[PyroscopeConfig]: 54 | return PyroscopeConfig 55 | -------------------------------------------------------------------------------- /tests/bootstrappers/test_fastapi.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from unittest.mock import MagicMock 3 | 4 | from fastapi import status 5 | from fastapi.testclient import TestClient 6 | 7 | from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper 8 | from microbootstrap.config.fastapi import FastApiConfig 9 | from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig 10 | from microbootstrap.settings import FastApiSettings 11 | 12 | 13 | def test_fastapi_configure_instrument() -> None: 14 | test_metrics_path: typing.Final = "/test-metrics-path" 15 | 16 | application: typing.Final = ( 17 | FastApiBootstrapper(FastApiSettings()) 18 | .configure_instrument( 19 | FastApiPrometheusConfig(prometheus_metrics_path=test_metrics_path), 20 | ) 21 | .bootstrap() 22 | ) 23 | 24 | response: typing.Final = TestClient(app=application).get(test_metrics_path) 25 | assert response.status_code == status.HTTP_200_OK 26 | 27 | 28 | def test_fastapi_configure_instruments() -> None: 29 | test_metrics_path: typing.Final = "/test-metrics-path" 30 | application: typing.Final = ( 31 | FastApiBootstrapper(FastApiSettings()) 32 | .configure_instruments( 33 | FastApiPrometheusConfig(prometheus_metrics_path=test_metrics_path), 34 | ) 35 | .bootstrap() 36 | ) 37 | 38 | response: typing.Final = TestClient(app=application).get(test_metrics_path) 39 | assert response.status_code == status.HTTP_200_OK 40 | 41 | 42 | def test_fastapi_configure_application() -> None: 43 | test_title: typing.Final = "new-title" 44 | 45 | application: typing.Final = ( 46 | FastApiBootstrapper(FastApiSettings()).configure_application(FastApiConfig(title=test_title)).bootstrap() 47 | ) 48 | 49 | assert application.title == test_title 50 | 51 | 52 | def test_fastapi_configure_application_lifespan(magic_mock: MagicMock) -> None: 53 | application: typing.Final = ( 54 | FastApiBootstrapper(FastApiSettings()).configure_application(FastApiConfig(lifespan=magic_mock)).bootstrap() 55 | ) 56 | 57 | with TestClient(app=application): 58 | assert magic_mock.called 59 | -------------------------------------------------------------------------------- /tests/instruments/test_pyroscope.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from unittest import mock 3 | from unittest.mock import Mock 4 | 5 | import fastapi 6 | import pydantic 7 | import pytest 8 | from fastapi.testclient import TestClient as FastAPITestClient 9 | 10 | from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument 11 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig 12 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument 13 | 14 | 15 | try: 16 | import pyroscope # type: ignore[import-untyped] # noqa: F401 17 | except ImportError: # pragma: no cover 18 | pytest.skip("pyroscope is not installed", allow_module_level=True) 19 | 20 | 21 | class TestPyroscopeInstrument: 22 | @pytest.fixture 23 | def minimal_pyroscope_config(self) -> PyroscopeConfig: 24 | return PyroscopeConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040")) 25 | 26 | def test_ok(self, minimal_pyroscope_config: PyroscopeConfig) -> None: 27 | instrument = PyroscopeInstrument(minimal_pyroscope_config) 28 | assert instrument.is_ready() 29 | instrument.bootstrap() 30 | instrument.teardown() 31 | 32 | def test_not_ready(self) -> None: 33 | instrument = PyroscopeInstrument(PyroscopeConfig(pyroscope_endpoint=None)) 34 | assert not instrument.is_ready() 35 | 36 | def test_opentelemetry_includes_pyroscope_2( 37 | self, monkeypatch: pytest.MonkeyPatch, minimal_opentelemetry_config: OpentelemetryConfig 38 | ) -> None: 39 | monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock()) 40 | monkeypatch.setattr("pyroscope.add_thread_tag", add_thread_tag_mock := Mock()) 41 | monkeypatch.setattr("pyroscope.remove_thread_tag", remove_thread_tag_mock := Mock()) 42 | 43 | minimal_opentelemetry_config.pyroscope_endpoint = pydantic.HttpUrl("http://localhost:4040") 44 | 45 | opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config) 46 | opentelemetry_instrument.bootstrap() 47 | fastapi_application: typing.Final = opentelemetry_instrument.bootstrap_after(fastapi.FastAPI()) 48 | 49 | @fastapi_application.get("/test-handler") 50 | async def test_handler() -> None: ... 51 | 52 | FastAPITestClient(app=fastapi_application).get("/test-handler") 53 | assert ( 54 | add_thread_tag_mock.mock_calls 55 | == remove_thread_tag_mock.mock_calls 56 | == [mock.call(mock.ANY, "span_id", mock.ANY), mock.call(mock.ANY, "span_name", "GET /test-handler")] 57 | ) 58 | -------------------------------------------------------------------------------- /microbootstrap/instruments/prometheus_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | 4 | import pydantic 5 | 6 | from microbootstrap.helpers import is_valid_path 7 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 8 | 9 | 10 | if typing.TYPE_CHECKING: 11 | import prometheus_client 12 | 13 | 14 | PrometheusConfigT = typing.TypeVar("PrometheusConfigT", bound="BasePrometheusConfig") 15 | 16 | 17 | class BasePrometheusConfig(BaseInstrumentConfig): 18 | service_name: str = "micro-service" 19 | 20 | prometheus_metrics_path: str = "/metrics" 21 | prometheus_metrics_include_in_schema: bool = False 22 | 23 | 24 | class LitestarPrometheusConfig(BasePrometheusConfig): 25 | prometheus_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 26 | 27 | 28 | class FastApiPrometheusConfig(BasePrometheusConfig): 29 | prometheus_instrumentator_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 30 | prometheus_instrument_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 31 | prometheus_expose_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 32 | prometheus_custom_labels: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 33 | 34 | 35 | @typing.runtime_checkable 36 | class FastStreamPrometheusMiddlewareProtocol(typing.Protocol): 37 | def __init__( 38 | self, 39 | *, 40 | registry: prometheus_client.CollectorRegistry, 41 | app_name: str = ..., 42 | metrics_prefix: str = "faststream", 43 | received_messages_size_buckets: typing.Sequence[float] | None = None, 44 | custom_labels: dict[str, str | typing.Callable[[typing.Any], str]] | None = None, 45 | ) -> None: ... 46 | def __call__( 47 | self, 48 | msg: typing.Any, # noqa: ANN401 49 | /, 50 | *, 51 | context: typing.Any, # noqa: ANN401 52 | ) -> typing.Any: ... # noqa: ANN401 53 | 54 | 55 | class FastStreamPrometheusConfig(BasePrometheusConfig): 56 | prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None 57 | prometheus_custom_labels: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 58 | 59 | 60 | class PrometheusInstrument(Instrument[PrometheusConfigT]): 61 | instrument_name = "Prometheus" 62 | ready_condition = "Provide metrics_path for metrics exposure" 63 | 64 | def is_ready(self) -> bool: 65 | return bool(self.instrument_config.prometheus_metrics_path) and is_valid_path( 66 | self.instrument_config.prometheus_metrics_path, 67 | ) 68 | 69 | @classmethod 70 | def get_config_type(cls) -> type[PrometheusConfigT]: 71 | return BasePrometheusConfig # type: ignore[return-value] 72 | -------------------------------------------------------------------------------- /microbootstrap/config/fastapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import dataclasses 3 | import typing 4 | 5 | from fastapi.datastructures import Default 6 | from fastapi.utils import generate_unique_id 7 | from starlette.responses import JSONResponse 8 | 9 | 10 | if typing.TYPE_CHECKING: 11 | from fastapi import Request, routing 12 | from fastapi.applications import AppType 13 | from fastapi.middleware import Middleware 14 | from fastapi.params import Depends 15 | from starlette.responses import Response 16 | from starlette.routing import BaseRoute 17 | from starlette.types import Lifespan 18 | 19 | 20 | @dataclasses.dataclass 21 | class FastApiConfig: 22 | debug: bool = False 23 | routes: list[BaseRoute] | None = None 24 | title: str = "FastAPI" 25 | summary: str | None = None 26 | description: str = "" 27 | version: str = "0.1.0" 28 | openapi_url: str | None = "/openapi.json" 29 | openapi_tags: list[dict[str, typing.Any]] | None = None 30 | servers: list[dict[str, str | typing.Any]] | None = None 31 | dependencies: typing.Sequence[Depends] | None = None 32 | default_response_class: type[Response] = dataclasses.field(default_factory=lambda: Default(JSONResponse)) 33 | redirect_slashes: bool = True 34 | docs_url: str | None = "/docs" 35 | redoc_url: str | None = "/redoc" 36 | swagger_ui_oauth2_redirect_url: str | None = "/docs/oauth2-redirect" 37 | swagger_ui_init_oauth: dict[str, typing.Any] | None = None 38 | middleware: typing.Sequence[Middleware] | None = None 39 | exception_handlers: ( 40 | dict[ 41 | int | type[Exception], 42 | typing.Callable[[Request, typing.Any], typing.Coroutine[typing.Any, typing.Any, Response]], 43 | ] 44 | | None 45 | ) = None 46 | on_startup: typing.Sequence[typing.Callable[[], typing.Any]] | None = None 47 | on_shutdown: typing.Sequence[typing.Callable[[], typing.Any]] | None = None 48 | lifespan: Lifespan[AppType] | None = None # type: ignore[valid-type] 49 | terms_of_service: str | None = None 50 | contact: dict[str, str | typing.Any] | None = None 51 | license_info: dict[str, str | typing.Any] | None = None 52 | openapi_prefix: str = "" 53 | root_path: str = "" 54 | root_path_in_servers: bool = True 55 | responses: dict[int | str, dict[str, typing.Any]] | None = None 56 | callbacks: list[BaseRoute] | None = None 57 | webhooks: routing.APIRouter | None = None 58 | deprecated: bool | None = None 59 | include_in_schema: bool = True 60 | swagger_ui_parameters: dict[str, typing.Any] | None = None 61 | generate_unique_id_function: typing.Callable[[routing.APIRoute], str] = dataclasses.field( 62 | default_factory=lambda: Default(generate_unique_id), 63 | ) 64 | separate_input_output_schemas: bool = True 65 | -------------------------------------------------------------------------------- /microbootstrap/instruments_setupper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | 4 | from microbootstrap.console_writer import ConsoleWriter 5 | from microbootstrap.instruments.instrument_box import InstrumentBox 6 | from microbootstrap.instruments.logging_instrument import LoggingInstrument 7 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument 8 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument 9 | from microbootstrap.instruments.sentry_instrument import SentryInstrument 10 | 11 | 12 | if typing.TYPE_CHECKING: 13 | import typing_extensions 14 | 15 | from microbootstrap.instruments.base import Instrument, InstrumentConfigT 16 | from microbootstrap.settings import InstrumentsSetupperSettings 17 | 18 | 19 | class InstrumentsSetupper: 20 | console_writer: ConsoleWriter 21 | instrument_box: InstrumentBox 22 | 23 | def __init__(self, settings: InstrumentsSetupperSettings) -> None: 24 | self.settings = settings 25 | self.console_writer = ConsoleWriter(writer_enabled=settings.service_debug) 26 | self.instrument_box.initialize(self.settings) 27 | 28 | def configure_instrument(self, instrument_config: InstrumentConfigT) -> typing_extensions.Self: 29 | self.instrument_box.configure_instrument(instrument_config) 30 | return self 31 | 32 | def configure_instruments( 33 | self, 34 | *instrument_configs: InstrumentConfigT, 35 | ) -> typing_extensions.Self: 36 | for instrument_config in instrument_configs: 37 | self.configure_instrument(instrument_config) 38 | return self 39 | 40 | @classmethod 41 | def use_instrument( 42 | cls, 43 | ) -> typing.Callable[ 44 | [type[Instrument[InstrumentConfigT]]], 45 | type[Instrument[InstrumentConfigT]], 46 | ]: 47 | if not hasattr(cls, "instrument_box"): 48 | cls.instrument_box = InstrumentBox() 49 | return cls.instrument_box.extend_instruments 50 | 51 | def setup(self) -> None: 52 | for instrument in self.instrument_box.instruments: 53 | if instrument.is_ready(): 54 | instrument.bootstrap() 55 | instrument.write_status(self.console_writer) 56 | 57 | def teardown(self) -> None: 58 | for instrument in self.instrument_box.instruments: 59 | if instrument.is_ready(): 60 | instrument.teardown() 61 | 62 | def __enter__(self) -> None: 63 | self.setup() 64 | 65 | def __exit__(self, *args: object) -> None: 66 | self.teardown() 67 | 68 | 69 | InstrumentsSetupper.use_instrument()(LoggingInstrument) 70 | InstrumentsSetupper.use_instrument()(SentryInstrument) 71 | InstrumentsSetupper.use_instrument()(OpentelemetryInstrument) 72 | InstrumentsSetupper.use_instrument()(PyroscopeInstrument) 73 | -------------------------------------------------------------------------------- /tests/instruments/test_instrument_box.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | from microbootstrap.exceptions import MissingInstrumentError 6 | from microbootstrap.instruments.base import Instrument 7 | from microbootstrap.instruments.instrument_box import InstrumentBox 8 | from microbootstrap.instruments.logging_instrument import LoggingInstrument 9 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument 10 | from microbootstrap.instruments.prometheus_instrument import BasePrometheusConfig, PrometheusInstrument 11 | from microbootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument 12 | from microbootstrap.settings import BaseServiceSettings 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "instruments_in_box", 17 | [ 18 | [SentryInstrument, LoggingInstrument], 19 | [OpentelemetryInstrument], 20 | [PrometheusInstrument, LoggingInstrument], 21 | [PrometheusInstrument, LoggingInstrument, OpentelemetryInstrument, SentryInstrument], 22 | ], 23 | ) 24 | def test_instrument_box_initialize( 25 | instruments_in_box: list[type[Instrument[typing.Any]]], 26 | base_settings: BaseServiceSettings, 27 | ) -> None: 28 | instrument_box: typing.Final = InstrumentBox() 29 | instrument_box.__instruments__ = instruments_in_box 30 | instrument_box.initialize(base_settings) 31 | 32 | assert len(instrument_box.instruments) == len(instruments_in_box) 33 | for initialized_instrument in instrument_box.instruments: 34 | assert isinstance(initialized_instrument, tuple(instruments_in_box)) 35 | 36 | 37 | def test_instrument_box_configure_instrument( 38 | base_settings: BaseServiceSettings, 39 | ) -> None: 40 | instrument_box: typing.Final = InstrumentBox() 41 | instrument_box.__instruments__ = [SentryInstrument] 42 | instrument_box.initialize(base_settings) 43 | test_dsn: typing.Final = "my-test-dsn" 44 | instrument_box.configure_instrument(SentryConfig(sentry_dsn=test_dsn)) 45 | 46 | assert len(instrument_box.instruments) == 1 47 | assert isinstance(instrument_box.instruments[0].instrument_config, SentryConfig) 48 | assert instrument_box.instruments[0].instrument_config.sentry_dsn == test_dsn 49 | 50 | 51 | def test_instrument_box_configure_instrument_error( 52 | base_settings: BaseServiceSettings, 53 | ) -> None: 54 | instrument_box: typing.Final = InstrumentBox() 55 | instrument_box.__instruments__ = [SentryInstrument] 56 | instrument_box.initialize(base_settings) 57 | 58 | with pytest.raises(MissingInstrumentError): 59 | instrument_box.configure_instrument(BasePrometheusConfig()) 60 | 61 | 62 | def test_instrument_box_extend_instruments() -> None: 63 | class TestSentryInstrument(SentryInstrument): 64 | pass 65 | 66 | instrument_box: typing.Final = InstrumentBox() 67 | instrument_box.__instruments__ = [SentryInstrument] 68 | instrument_box.extend_instruments(TestSentryInstrument) 69 | assert len(instrument_box.__instruments__) == 1 70 | assert issubclass(instrument_box.__instruments__[0], TestSentryInstrument) 71 | -------------------------------------------------------------------------------- /microbootstrap/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | import typing 4 | 5 | import pydantic 6 | import pydantic_settings 7 | 8 | from microbootstrap import ( 9 | CorsConfig, 10 | FastApiPrometheusConfig, 11 | FastStreamOpentelemetryConfig, 12 | FastStreamPrometheusConfig, 13 | HealthChecksConfig, 14 | LitestarPrometheusConfig, 15 | LoggingConfig, 16 | OpentelemetryConfig, 17 | PyroscopeConfig, 18 | SentryConfig, 19 | SwaggerConfig, 20 | ) 21 | 22 | 23 | SettingsT = typing.TypeVar("SettingsT", bound="BaseServiceSettings") 24 | ENV_PREFIX_VAR_NAME: typing.Final = "ENVIRONMENT_PREFIX" 25 | ENV_PREFIX: typing.Final = os.getenv(ENV_PREFIX_VAR_NAME, "") 26 | 27 | 28 | # TODO: add offline docs and cors support # noqa: TD002 29 | class BaseServiceSettings( 30 | pydantic_settings.BaseSettings, 31 | ): 32 | service_debug: bool = True 33 | service_environment: str | None = None 34 | service_name: str = pydantic.Field( 35 | "micro-service", 36 | validation_alias=pydantic.AliasChoices("SERVICE_NAME", f"{ENV_PREFIX}SERVICE_NAME"), 37 | ) 38 | service_description: str = "Micro service description" 39 | service_version: str = pydantic.Field( 40 | "1.0.0", 41 | validation_alias=pydantic.AliasChoices("CI_COMMIT_TAG", f"{ENV_PREFIX}SERVICE_VERSION"), 42 | ) 43 | 44 | model_config = pydantic_settings.SettingsConfigDict( 45 | env_file=".env", 46 | env_prefix=ENV_PREFIX, 47 | env_file_encoding="utf-8", 48 | populate_by_name=True, 49 | extra="allow", 50 | ) 51 | 52 | 53 | class ServerConfig(pydantic.BaseModel): 54 | server_host: str = "0.0.0.0" # noqa: S104 55 | server_port: int = 8000 56 | server_reload: bool = True 57 | server_workers_count: int = 1 58 | 59 | 60 | class LitestarSettings( # type: ignore[misc] 61 | BaseServiceSettings, 62 | ServerConfig, 63 | LoggingConfig, 64 | OpentelemetryConfig, 65 | SentryConfig, 66 | LitestarPrometheusConfig, 67 | SwaggerConfig, 68 | CorsConfig, 69 | HealthChecksConfig, 70 | PyroscopeConfig, 71 | ): 72 | """Settings for a litestar botstrap.""" 73 | 74 | 75 | class FastApiSettings( # type: ignore[misc] 76 | BaseServiceSettings, 77 | ServerConfig, 78 | LoggingConfig, 79 | OpentelemetryConfig, 80 | SentryConfig, 81 | FastApiPrometheusConfig, 82 | SwaggerConfig, 83 | CorsConfig, 84 | HealthChecksConfig, 85 | PyroscopeConfig, 86 | ): 87 | """Settings for a fastapi botstrap.""" 88 | 89 | 90 | class FastStreamSettings( # type: ignore[misc] 91 | BaseServiceSettings, 92 | ServerConfig, 93 | LoggingConfig, 94 | FastStreamOpentelemetryConfig, 95 | SentryConfig, 96 | FastStreamPrometheusConfig, 97 | HealthChecksConfig, 98 | PyroscopeConfig, 99 | ): 100 | """Settings for a faststream bootstrap.""" 101 | 102 | asyncapi_path: str | None = "/asyncapi" 103 | 104 | 105 | class InstrumentsSetupperSettings( # type: ignore[misc] 106 | BaseServiceSettings, 107 | LoggingConfig, 108 | OpentelemetryConfig, 109 | SentryConfig, 110 | PyroscopeConfig, 111 | ): 112 | """Settings for a vanilla service.""" 113 | -------------------------------------------------------------------------------- /tests/instruments/test_health_checks.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import fastapi 4 | import litestar 5 | from fastapi.testclient import TestClient as FastAPITestClient 6 | from litestar import status_codes 7 | from litestar.testing import TestClient as LitestarTestClient 8 | 9 | from microbootstrap.bootstrappers.fastapi import FastApiHealthChecksInstrument 10 | from microbootstrap.bootstrappers.litestar import LitestarHealthChecksInstrument 11 | from microbootstrap.instruments.health_checks_instrument import ( 12 | HealthChecksConfig, 13 | HealthChecksInstrument, 14 | ) 15 | 16 | 17 | def test_health_checks_is_ready(minimal_health_checks_config: HealthChecksConfig) -> None: 18 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config) 19 | assert health_checks_instrument.is_ready() 20 | 21 | 22 | def test_health_checks_bootstrap_is_not_ready(minimal_health_checks_config: HealthChecksConfig) -> None: 23 | minimal_health_checks_config.health_checks_enabled = False 24 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config) 25 | assert not health_checks_instrument.is_ready() 26 | 27 | 28 | def test_health_checks_bootstrap_after( 29 | default_litestar_app: litestar.Litestar, 30 | minimal_health_checks_config: HealthChecksConfig, 31 | ) -> None: 32 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config) 33 | assert health_checks_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 34 | 35 | 36 | def test_health_checks_teardown( 37 | minimal_health_checks_config: HealthChecksConfig, 38 | ) -> None: 39 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config) 40 | assert health_checks_instrument.teardown() is None # type: ignore[func-returns-value] 41 | 42 | 43 | def test_litestar_health_checks_bootstrap() -> None: 44 | test_health_checks_path: typing.Final = "/test-path/" 45 | heatlh_checks_config: typing.Final = HealthChecksConfig(health_checks_path=test_health_checks_path) 46 | health_checks_instrument: typing.Final = LitestarHealthChecksInstrument(heatlh_checks_config) 47 | 48 | health_checks_instrument.bootstrap() 49 | litestar_application: typing.Final = litestar.Litestar( 50 | **health_checks_instrument.bootstrap_before(), 51 | ) 52 | 53 | with LitestarTestClient(app=litestar_application) as async_client: 54 | response = async_client.get(heatlh_checks_config.health_checks_path) 55 | assert response.status_code == status_codes.HTTP_200_OK 56 | 57 | 58 | def test_fastapi_health_checks_bootstrap() -> None: 59 | test_health_checks_path: typing.Final = "/test-path/" 60 | heatlh_checks_config: typing.Final = HealthChecksConfig(health_checks_path=test_health_checks_path) 61 | health_checks_instrument: typing.Final = FastApiHealthChecksInstrument(heatlh_checks_config) 62 | 63 | health_checks_instrument.bootstrap() 64 | fastapi_application = fastapi.FastAPI( 65 | **health_checks_instrument.bootstrap_before(), 66 | ) 67 | fastapi_application = health_checks_instrument.bootstrap_after(fastapi_application) 68 | 69 | response = FastAPITestClient(app=fastapi_application).get(heatlh_checks_config.health_checks_path) 70 | assert response.status_code == status_codes.HTTP_200_OK 71 | -------------------------------------------------------------------------------- /tests/bootstrappers/test_litestar.py: -------------------------------------------------------------------------------- 1 | import typing # noqa: I001 2 | from unittest.mock import MagicMock 3 | 4 | import litestar 5 | import pytest 6 | from litestar import status_codes 7 | from litestar.middleware.base import MiddlewareProtocol 8 | from litestar.testing import AsyncTestClient 9 | from litestar.types import ASGIApp, Receive, Scope, Send 10 | 11 | from microbootstrap import LitestarSettings, LitestarPrometheusConfig 12 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper 13 | from microbootstrap.config.litestar import LitestarConfig 14 | from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper # noqa: F401 15 | 16 | 17 | @pytest.mark.parametrize("logging_turn_off_middleware", [True, False]) 18 | async def test_litestar_configure_instrument(logging_turn_off_middleware: bool) -> None: 19 | test_metrics_path: typing.Final = "/test-metrics-path" 20 | 21 | application: typing.Final = ( 22 | LitestarBootstrapper( 23 | LitestarSettings(logging_turn_off_middleware=logging_turn_off_middleware, service_debug=False) 24 | ) 25 | .configure_instrument( 26 | LitestarPrometheusConfig(prometheus_metrics_path=test_metrics_path), 27 | ) 28 | .bootstrap() 29 | ) 30 | 31 | async with AsyncTestClient(app=application) as async_client: 32 | response: typing.Final = await async_client.get(test_metrics_path) 33 | assert response.status_code == status_codes.HTTP_200_OK 34 | 35 | 36 | async def test_litestar_configure_instruments() -> None: 37 | test_metrics_path: typing.Final = "/test-metrics-path" 38 | application: typing.Final = ( 39 | LitestarBootstrapper(LitestarSettings()) 40 | .configure_instruments( 41 | LitestarPrometheusConfig(prometheus_metrics_path=test_metrics_path), 42 | ) 43 | .bootstrap() 44 | ) 45 | 46 | async with AsyncTestClient(app=application) as async_client: 47 | response: typing.Final = await async_client.get(test_metrics_path) 48 | assert response.status_code == status_codes.HTTP_200_OK 49 | 50 | 51 | async def test_litestar_configure_application_add_handler() -> None: 52 | test_handler_path: typing.Final = "/test-handler1" 53 | test_response: typing.Final = {"hello": "world"} 54 | 55 | @litestar.get(test_handler_path) 56 | async def test_handler() -> dict[str, str]: 57 | return test_response 58 | 59 | application: typing.Final = ( 60 | LitestarBootstrapper(LitestarSettings()) 61 | .configure_application(LitestarConfig(route_handlers=[test_handler])) 62 | .bootstrap() 63 | ) 64 | 65 | async with AsyncTestClient(app=application) as async_client: 66 | response: typing.Final = await async_client.get(test_handler_path) 67 | assert response.status_code == status_codes.HTTP_200_OK 68 | assert response.json() == test_response 69 | 70 | 71 | async def test_litestar_configure_application_add_middleware(magic_mock: MagicMock) -> None: 72 | test_handler_path: typing.Final = "/test-handler" 73 | 74 | class TestMiddleware(MiddlewareProtocol): 75 | def __init__(self, app: ASGIApp) -> None: 76 | self.app = app 77 | 78 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 79 | magic_mock() 80 | await self.app(scope, receive, send) 81 | 82 | @litestar.get(test_handler_path) 83 | async def test_handler() -> str: 84 | return "Ok" 85 | 86 | application: typing.Final = ( 87 | LitestarBootstrapper(LitestarSettings()) 88 | .configure_application(LitestarConfig(route_handlers=[test_handler], middleware=[TestMiddleware])) 89 | .bootstrap() 90 | ) 91 | 92 | async with AsyncTestClient(app=application) as async_client: 93 | response: typing.Final = await async_client.get(test_handler_path) 94 | assert response.status_code == status_codes.HTTP_200_OK 95 | assert magic_mock.called 96 | -------------------------------------------------------------------------------- /tests/instruments/test_cors.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import fastapi 4 | import litestar 5 | from fastapi.middleware import Middleware 6 | from fastapi.middleware.cors import CORSMiddleware 7 | from litestar.config.cors import CORSConfig as LitestarCorsConfig 8 | 9 | from microbootstrap import CorsConfig 10 | from microbootstrap.bootstrappers.fastapi import FastApiCorsInstrument 11 | from microbootstrap.bootstrappers.litestar import LitestarCorsInstrument 12 | from microbootstrap.instruments.cors_instrument import CorsInstrument 13 | 14 | 15 | def test_cors_is_ready(minimal_cors_config: CorsConfig) -> None: 16 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config) 17 | assert cors_instrument.is_ready() 18 | 19 | 20 | def test_cors_bootstrap_is_not_ready(minimal_cors_config: CorsConfig) -> None: 21 | minimal_cors_config.cors_allowed_origins = [] 22 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config) 23 | assert not cors_instrument.is_ready() 24 | 25 | 26 | def test_cors_bootstrap_after( 27 | default_litestar_app: litestar.Litestar, 28 | minimal_cors_config: CorsConfig, 29 | ) -> None: 30 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config) 31 | assert cors_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 32 | 33 | 34 | def test_cors_teardown( 35 | minimal_cors_config: CorsConfig, 36 | ) -> None: 37 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config) 38 | assert cors_instrument.teardown() is None # type: ignore[func-returns-value] 39 | 40 | 41 | def test_litestar_cors_bootstrap() -> None: 42 | cors_config = CorsConfig( 43 | cors_allowed_origins=["localhost"], 44 | cors_allowed_headers=["my-allowed-header"], 45 | cors_allowed_credentials=True, 46 | cors_allowed_origin_regex="my-regex", 47 | cors_allowed_methods=["*"], 48 | cors_exposed_headers=["my-exposed-header"], 49 | cors_max_age=100, 50 | ) 51 | cors_instrument: typing.Final = LitestarCorsInstrument(cors_config) 52 | 53 | cors_instrument.bootstrap() 54 | bootstrap_result: typing.Final = cors_instrument.bootstrap_before() 55 | assert "cors_config" in bootstrap_result 56 | assert isinstance(bootstrap_result["cors_config"], LitestarCorsConfig) 57 | assert bootstrap_result["cors_config"].allow_origins == cors_config.cors_allowed_origins 58 | assert bootstrap_result["cors_config"].allow_headers == cors_config.cors_allowed_headers 59 | assert bootstrap_result["cors_config"].allow_credentials == cors_config.cors_allowed_credentials 60 | assert bootstrap_result["cors_config"].allow_origin_regex == cors_config.cors_allowed_origin_regex 61 | assert bootstrap_result["cors_config"].allow_methods == cors_config.cors_allowed_methods 62 | assert bootstrap_result["cors_config"].expose_headers == cors_config.cors_exposed_headers 63 | assert bootstrap_result["cors_config"].max_age == cors_config.cors_max_age 64 | 65 | 66 | def test_fastapi_cors_bootstrap() -> None: 67 | cors_config = CorsConfig( 68 | cors_allowed_origins=["localhost"], 69 | cors_allowed_headers=["my-allowed-header"], 70 | cors_allowed_credentials=True, 71 | cors_allowed_origin_regex="my-regex", 72 | cors_allowed_methods=["*"], 73 | cors_exposed_headers=["my-exposed-header"], 74 | cors_max_age=100, 75 | ) 76 | cors_instrument: typing.Final = FastApiCorsInstrument(cors_config) 77 | fastapi_application = cors_instrument.bootstrap_after(fastapi.FastAPI()) 78 | assert len(fastapi_application.user_middleware) == 1 79 | assert isinstance(fastapi_application.user_middleware[0], Middleware) 80 | cors_middleware: typing.Final = fastapi_application.user_middleware[0] 81 | assert cors_middleware.cls is CORSMiddleware # type: ignore[comparison-overlap] 82 | assert cors_middleware.kwargs["allow_origins"] == cors_config.cors_allowed_origins 83 | assert cors_middleware.kwargs["allow_headers"] == cors_config.cors_allowed_headers 84 | assert cors_middleware.kwargs["allow_credentials"] == cors_config.cors_allowed_credentials 85 | assert cors_middleware.kwargs["allow_origin_regex"] == cors_config.cors_allowed_origin_regex 86 | assert cors_middleware.kwargs["allow_methods"] == cors_config.cors_allowed_methods 87 | assert cors_middleware.kwargs["expose_headers"] == cors_config.cors_exposed_headers 88 | assert cors_middleware.kwargs["max_age"] == cors_config.cors_max_age 89 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "microbootstrap" 3 | description = "Package for bootstrapping new micro-services" 4 | readme = "README.md" 5 | requires-python = ">=3.10,<4" 6 | keywords = [ 7 | "python", 8 | "microservice", 9 | "bootstrap", 10 | "opentelemetry", 11 | "logging", 12 | "error-tracing", 13 | "litestar", 14 | "fastapi", 15 | ] 16 | classifiers = [ 17 | "Typing :: Typed", 18 | "Topic :: Software Development :: Build Tools", 19 | "Operating System :: MacOS", 20 | "Operating System :: Microsoft", 21 | "Operating System :: POSIX :: Linux", 22 | "Intended Audience :: Developers", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | ] 32 | dependencies = [ 33 | "eval-type-backport>=0.2", 34 | "opentelemetry-api>=1.30.0", 35 | "opentelemetry-exporter-otlp>=1.15.0", 36 | "opentelemetry-exporter-prometheus-remote-write>=0.46b0", 37 | "opentelemetry-instrumentation>=0.46b0", 38 | "opentelemetry-instrumentation-system-metrics>=0.46b0", 39 | "opentelemetry-sdk>=1.30.0", 40 | "pydantic-settings>=2", 41 | "rich>=13", 42 | "sentry-sdk>=2.7", 43 | "structlog>=24", 44 | "pyroscope-io; platform_system != 'Windows'", 45 | "opentelemetry-distro[otlp]>=0.54b1", 46 | "opentelemetry-instrumentation-aio-pika>=0.54b1", 47 | "opentelemetry-instrumentation-aiohttp-client>=0.54b1", 48 | "opentelemetry-instrumentation-aiokafka>=0.54b1", 49 | "opentelemetry-instrumentation-asyncpg>=0.54b1", 50 | "opentelemetry-instrumentation-httpx>=0.54b1", 51 | "opentelemetry-instrumentation-logging>=0.54b1", 52 | "opentelemetry-instrumentation-redis>=0.54b1", 53 | "opentelemetry-instrumentation-psycopg>=0.54b1", 54 | "opentelemetry-instrumentation-sqlalchemy>=0.54b1", 55 | "opentelemetry-instrumentation-asyncio>=0.54b1", 56 | "opentelemetry-instrumentation-asgi>=0.46b0", 57 | "orjson>=3.10.18", 58 | ] 59 | version = "0" 60 | authors = [{ name = "community-of-python" }] 61 | 62 | [project.optional-dependencies] 63 | fastapi = [ 64 | "fastapi>=0.100", 65 | "fastapi-offline-docs>=1", 66 | "opentelemetry-instrumentation-fastapi>=0.46b0", 67 | "prometheus-fastapi-instrumentator>=6.1", 68 | ] 69 | litestar = [ 70 | "litestar>=2.9", 71 | "litestar-offline-docs>=1", 72 | "prometheus-client>=0.20", 73 | ] 74 | granian = ["granian[reload]>=1"] 75 | faststream = ["faststream~=0.6.2", "prometheus-client>=0.20"] 76 | 77 | [dependency-groups] 78 | dev = [ 79 | "anyio>=4.8.0", 80 | "httpx>=0.28.1", 81 | "mypy>=1.14.1", 82 | "pre-commit>=4.0.1", 83 | "pytest>=8.3.4", 84 | "pytest-cov>=6.0.0", 85 | "pytest-mock>=3.14.0", 86 | "pytest-xdist>=3.6.1", 87 | "redis>=5.2.1", 88 | "ruff>=0.9.1", 89 | "trio>=0.28.0", 90 | "typing-extensions>=4.12.2", 91 | ] 92 | 93 | [build-system] 94 | requires = ["uv_build"] 95 | build-backend = "uv_build" 96 | 97 | [tool.uv.build-backend] 98 | module-name = "microbootstrap" 99 | module-root = "" 100 | 101 | [tool.mypy] 102 | plugins = ["pydantic.mypy"] 103 | files = ["microbootstrap", "tests"] 104 | python_version = "3.10" 105 | strict = true 106 | pretty = true 107 | show_error_codes = true 108 | 109 | [tool.ruff] 110 | target-version = "py310" 111 | line-length = 120 112 | 113 | [tool.ruff.format] 114 | docstring-code-format = true 115 | 116 | [tool.ruff.lint] 117 | select = ["ALL"] 118 | ignore = [ 119 | "EM", 120 | "FBT", 121 | "TRY003", 122 | "FIX002", 123 | "TD003", 124 | "D1", 125 | "D106", 126 | "D203", 127 | "D213", 128 | "G004", 129 | "FA", 130 | "COM812", 131 | "ISC001", 132 | ] 133 | 134 | [tool.ruff.lint.isort] 135 | no-lines-before = ["standard-library", "local-folder"] 136 | lines-after-imports = 2 137 | 138 | [tool.ruff.lint.extend-per-file-ignores] 139 | "tests/*.py" = ["S101", "S311"] 140 | "examples/*.py" = ["INP001"] 141 | 142 | [tool.coverage.report] 143 | exclude_also = ["if typing.TYPE_CHECKING:", 'class \w+\(typing.Protocol\):'] 144 | omit = ["tests/*"] 145 | 146 | [tool.pytest.ini_options] 147 | addopts = '--cov=. -p no:warnings --cov-report term-missing' 148 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import importlib 3 | import typing 4 | from unittest.mock import AsyncMock, MagicMock 5 | 6 | import litestar 7 | import pytest 8 | from prometheus_client import REGISTRY 9 | from sentry_sdk.transport import Transport as SentryTransport 10 | 11 | import microbootstrap.settings 12 | from microbootstrap import ( 13 | FastApiPrometheusConfig, 14 | FastStreamPrometheusConfig, 15 | LitestarPrometheusConfig, 16 | LoggingConfig, 17 | OpentelemetryConfig, 18 | SentryConfig, 19 | ) 20 | from microbootstrap.console_writer import ConsoleWriter 21 | from microbootstrap.instruments import opentelemetry_instrument 22 | from microbootstrap.instruments.cors_instrument import CorsConfig 23 | from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig 24 | from microbootstrap.instruments.prometheus_instrument import BasePrometheusConfig 25 | from microbootstrap.instruments.swagger_instrument import SwaggerConfig 26 | from microbootstrap.settings import BaseServiceSettings, ServerConfig 27 | 28 | 29 | if typing.TYPE_CHECKING: 30 | from sentry_sdk.envelope import Envelope as SentryEnvelope 31 | 32 | 33 | pytestmark = [pytest.mark.anyio] 34 | 35 | 36 | @pytest.fixture(scope="session", autouse=True) 37 | def anyio_backend() -> str: 38 | return "asyncio" 39 | 40 | 41 | @pytest.fixture 42 | def default_litestar_app() -> litestar.Litestar: 43 | return litestar.Litestar() 44 | 45 | 46 | class MockSentryTransport(SentryTransport): 47 | def capture_envelope(self, envelope: SentryEnvelope) -> None: ... 48 | 49 | 50 | @pytest.fixture 51 | def minimal_sentry_config() -> SentryConfig: 52 | return SentryConfig( 53 | sentry_dsn="https://examplePublicKey@o0.ingest.sentry.io/0", 54 | sentry_tags={"test": "test"}, 55 | sentry_additional_params={"transport": MockSentryTransport()}, 56 | ) 57 | 58 | 59 | @pytest.fixture 60 | def minimal_logging_config() -> LoggingConfig: 61 | return LoggingConfig(service_debug=False) 62 | 63 | 64 | @pytest.fixture 65 | def minimal_base_prometheus_config() -> BasePrometheusConfig: 66 | return BasePrometheusConfig() 67 | 68 | 69 | @pytest.fixture 70 | def minimal_fastapi_prometheus_config() -> FastApiPrometheusConfig: 71 | return FastApiPrometheusConfig() 72 | 73 | 74 | @pytest.fixture 75 | def minimal_litestar_prometheus_config() -> LitestarPrometheusConfig: 76 | return LitestarPrometheusConfig() 77 | 78 | 79 | @pytest.fixture 80 | def minimal_faststream_prometheus_config() -> FastStreamPrometheusConfig: 81 | return FastStreamPrometheusConfig() 82 | 83 | 84 | @pytest.fixture 85 | def minimal_swagger_config() -> SwaggerConfig: 86 | return SwaggerConfig() 87 | 88 | 89 | @pytest.fixture 90 | def minimal_cors_config() -> CorsConfig: 91 | return CorsConfig(cors_allowed_origins=["*"]) 92 | 93 | 94 | @pytest.fixture 95 | def minimal_health_checks_config() -> HealthChecksConfig: 96 | return HealthChecksConfig() 97 | 98 | 99 | @pytest.fixture 100 | def minimal_opentelemetry_config() -> OpentelemetryConfig: 101 | return OpentelemetryConfig( 102 | opentelemetry_endpoint="/my-endpoint", 103 | opentelemetry_namespace="namespace", 104 | opentelemetry_container_name="container-name", 105 | opentelemetry_generate_health_check_spans=False, 106 | ) 107 | 108 | 109 | @pytest.fixture 110 | def minimal_server_config() -> ServerConfig: 111 | return ServerConfig() 112 | 113 | 114 | @pytest.fixture 115 | def base_settings() -> BaseServiceSettings: 116 | return BaseServiceSettings() 117 | 118 | 119 | @pytest.fixture 120 | def magic_mock() -> MagicMock: 121 | return MagicMock() 122 | 123 | 124 | @pytest.fixture 125 | def async_mock() -> AsyncMock: 126 | return AsyncMock() 127 | 128 | 129 | @pytest.fixture 130 | def console_writer() -> ConsoleWriter: 131 | return ConsoleWriter(writer_enabled=False) 132 | 133 | 134 | @pytest.fixture 135 | def reset_reloaded_settings_module() -> typing.Iterator[None]: 136 | yield 137 | importlib.reload(microbootstrap.settings) 138 | 139 | 140 | @pytest.fixture(autouse=True) 141 | def patch_out_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: 142 | monkeypatch.setattr(opentelemetry_instrument, "entry_points", MagicMock(retrun_value=[])) 143 | 144 | 145 | @pytest.fixture(autouse=True) 146 | def clean_prometheus_registry() -> None: 147 | REGISTRY._names_to_collectors.clear() # noqa: SLF001 148 | REGISTRY._collector_to_names.clear() # noqa: SLF001 149 | -------------------------------------------------------------------------------- /microbootstrap/helpers.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import re 3 | import typing 4 | from dataclasses import _MISSING_TYPE 5 | 6 | from microbootstrap import exceptions 7 | 8 | 9 | if typing.TYPE_CHECKING: 10 | from dataclasses import _DataclassT 11 | 12 | from pydantic import BaseModel 13 | 14 | 15 | PydanticConfigT = typing.TypeVar("PydanticConfigT", bound="BaseModel") 16 | VALID_PATH_PATTERN: typing.Final = r"^(/[a-zA-Z0-9_-]+)+/?$" 17 | 18 | 19 | def dataclass_to_dict_no_defaults(dataclass_to_convert: "_DataclassT") -> dict[str, typing.Any]: 20 | conversion_result: typing.Final = {} 21 | for dataclass_field in dataclasses.fields(dataclass_to_convert): 22 | value = getattr(dataclass_to_convert, dataclass_field.name) 23 | if isinstance(dataclass_field.default, _MISSING_TYPE): 24 | conversion_result[dataclass_field.name] = value 25 | continue 26 | if dataclass_field.default != value and isinstance(dataclass_field.default_factory, _MISSING_TYPE): 27 | conversion_result[dataclass_field.name] = value 28 | continue 29 | if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[misc] 30 | conversion_result[dataclass_field.name] = value 31 | 32 | return conversion_result 33 | 34 | 35 | def merge_pydantic_configs( 36 | config_to_merge: PydanticConfigT, 37 | config_with_changes: PydanticConfigT, 38 | ) -> PydanticConfigT: 39 | initial_fields: typing.Final = dict(config_to_merge) 40 | changed_fields: typing.Final = { 41 | one_field_name: getattr(config_with_changes, one_field_name) 42 | for one_field_name in config_with_changes.model_fields_set 43 | } 44 | merged_fields: typing.Final = merge_dict_configs(initial_fields, changed_fields) 45 | return config_to_merge.model_copy(update=merged_fields) 46 | 47 | 48 | def merge_dataclasses_configs( 49 | config_to_merge: "_DataclassT", 50 | config_with_changes: "_DataclassT", 51 | ) -> "_DataclassT": 52 | config_class: typing.Final = config_to_merge.__class__ 53 | resulting_dict_config: typing.Final = merge_dict_configs( 54 | dataclass_to_dict_no_defaults(config_to_merge), 55 | dataclass_to_dict_no_defaults(config_with_changes), 56 | ) 57 | return config_class(**resulting_dict_config) 58 | 59 | 60 | def merge_dict_configs( 61 | config_dict: dict[str, typing.Any], 62 | changes_dict: dict[str, typing.Any], 63 | ) -> dict[str, typing.Any]: 64 | for change_key, change_value in changes_dict.items(): 65 | config_value = config_dict.get(change_key) 66 | 67 | if isinstance(config_value, set): 68 | if not isinstance(change_value, set): 69 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}") 70 | config_dict[change_key] = {*config_value, *change_value} 71 | continue 72 | 73 | if isinstance(config_value, tuple): 74 | if not isinstance(change_value, tuple): 75 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}") 76 | config_dict[change_key] = (*config_value, *change_value) 77 | continue 78 | 79 | if isinstance(config_value, list): 80 | if not isinstance(change_value, list): 81 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}") 82 | config_dict[change_key] = [*config_value, *change_value] 83 | continue 84 | 85 | if isinstance(config_value, dict): 86 | if not isinstance(change_value, dict): 87 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}") 88 | config_dict[change_key] = {**config_value, **change_value} 89 | continue 90 | 91 | config_dict[change_key] = change_value 92 | 93 | return config_dict 94 | 95 | 96 | def is_valid_path(maybe_path: str) -> bool: 97 | return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path)) 98 | 99 | 100 | def optimize_exclude_paths( 101 | exclude_endpoints: typing.Iterable[str], 102 | ) -> typing.Collection[str]: 103 | # `in` operator is faster for tuples than for lists 104 | endpoints_to_ignore: typing.Collection[str] = tuple(exclude_endpoints) 105 | 106 | # 10 is just an empirical value, based of measuring the performance 107 | # iterating over a tuple of <10 elements is faster than hashing 108 | if len(endpoints_to_ignore) >= 10: # noqa: PLR2004 109 | endpoints_to_ignore = set(endpoints_to_ignore) 110 | 111 | return endpoints_to_ignore 112 | -------------------------------------------------------------------------------- /microbootstrap/bootstrappers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import abc 3 | import typing 4 | 5 | from microbootstrap.console_writer import ConsoleWriter 6 | from microbootstrap.helpers import dataclass_to_dict_no_defaults, merge_dataclasses_configs, merge_dict_configs 7 | from microbootstrap.instruments.instrument_box import InstrumentBox 8 | from microbootstrap.settings import SettingsT 9 | 10 | 11 | if typing.TYPE_CHECKING: 12 | import typing_extensions 13 | 14 | from microbootstrap.instruments.base import Instrument, InstrumentConfigT 15 | 16 | 17 | class DataclassInstance(typing.Protocol): 18 | __dataclass_fields__: typing.ClassVar[dict[str, typing.Any]] 19 | 20 | 21 | ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any) 22 | DataclassT = typing.TypeVar("DataclassT", bound=DataclassInstance) 23 | 24 | 25 | class ApplicationBootstrapper(abc.ABC, typing.Generic[SettingsT, ApplicationT, DataclassT]): 26 | application_type: type[ApplicationT] 27 | application_config: DataclassT 28 | console_writer: ConsoleWriter 29 | instrument_box: InstrumentBox 30 | 31 | def __init__(self, settings: SettingsT) -> None: 32 | self.settings = settings 33 | self.console_writer = ConsoleWriter(writer_enabled=settings.service_debug) 34 | 35 | if not hasattr(self, "instrument_box"): 36 | self.instrument_box = InstrumentBox() 37 | self.instrument_box.initialize(self.settings) 38 | 39 | def configure_application( 40 | self, 41 | application_config: DataclassT, 42 | ) -> typing_extensions.Self: 43 | self.application_config = merge_dataclasses_configs(self.application_config, application_config) 44 | return self 45 | 46 | def configure_instrument( 47 | self, 48 | instrument_config: InstrumentConfigT, 49 | ) -> typing_extensions.Self: 50 | self.instrument_box.configure_instrument(instrument_config) 51 | return self 52 | 53 | def configure_instruments( 54 | self, 55 | *instrument_configs: InstrumentConfigT, 56 | ) -> typing_extensions.Self: 57 | for instrument_config in instrument_configs: 58 | self.configure_instrument(instrument_config) 59 | return self 60 | 61 | @classmethod 62 | def use_instrument( 63 | cls, 64 | ) -> typing.Callable[ 65 | [type[Instrument[InstrumentConfigT]]], 66 | type[Instrument[InstrumentConfigT]], 67 | ]: 68 | if not hasattr(cls, "instrument_box"): 69 | cls.instrument_box = InstrumentBox() 70 | return cls.instrument_box.extend_instruments 71 | 72 | def bootstrap(self) -> ApplicationT: 73 | resulting_application_config: dict[str, typing.Any] = {} 74 | for instrument in self.instrument_box.instruments: 75 | if instrument.is_ready(): 76 | instrument.bootstrap() 77 | resulting_application_config = merge_dict_configs( 78 | resulting_application_config, 79 | instrument.bootstrap_before(), 80 | ) 81 | instrument.write_status(self.console_writer) 82 | 83 | resulting_application_config = merge_dict_configs( 84 | resulting_application_config, 85 | dataclass_to_dict_no_defaults(self.application_config), 86 | ) 87 | application = self.application_type( 88 | **merge_dict_configs(resulting_application_config, self.bootstrap_before()), 89 | ) 90 | 91 | self.bootstrap_before_instruments_after_app_created(application) 92 | 93 | for instrument in self.instrument_box.instruments: 94 | if instrument.is_ready(): 95 | application = instrument.bootstrap_after(application) 96 | 97 | return self.bootstrap_after(application) 98 | 99 | def bootstrap_before(self) -> dict[str, typing.Any]: 100 | """Add some framework-related parameters to final bootstrap result before application creation.""" 101 | return {} 102 | 103 | def bootstrap_before_instruments_after_app_created(self, application: ApplicationT) -> ApplicationT: 104 | """Add some framework-related parameters to bootstrap result after application creation, but before instruments are applied.""" # noqa: E501 105 | return application 106 | 107 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 108 | """Add some framework-related parameters to final bootstrap result after application creation.""" 109 | return application 110 | 111 | def teardown(self) -> None: 112 | for instrument in self.instrument_box.instruments: 113 | if instrument.is_ready(): 114 | instrument.teardown() 115 | -------------------------------------------------------------------------------- /microbootstrap/instruments/sentry_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import contextlib 3 | import functools 4 | import typing 5 | 6 | import orjson 7 | import pydantic 8 | import sentry_sdk 9 | from sentry_sdk import _types as sentry_types 10 | from sentry_sdk.integrations import Integration # noqa: TC002 11 | 12 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 13 | 14 | 15 | class SentryConfig(BaseInstrumentConfig): 16 | service_environment: str | None = None 17 | 18 | sentry_dsn: str | None = None 19 | sentry_traces_sample_rate: float | None = None 20 | sentry_sample_rate: float = pydantic.Field(default=1.0, le=1.0, ge=0.0) 21 | sentry_max_breadcrumbs: int = 15 22 | sentry_max_value_length: int = 16384 23 | sentry_attach_stacktrace: bool = True 24 | sentry_integrations: list[Integration] = pydantic.Field(default_factory=list) 25 | sentry_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 26 | sentry_tags: dict[str, str] | None = None 27 | sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None 28 | sentry_opentelemetry_trace_url_template: str | None = None 29 | 30 | 31 | IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset({"event", "level", "logger", "tracing", "timestamp"}) 32 | 33 | 34 | def enrich_sentry_event_from_structlog_log(event: sentry_types.Event, _hint: sentry_types.Hint) -> sentry_types.Event: 35 | if ( 36 | (logentry := event.get("logentry")) 37 | and (formatted_message := logentry.get("formatted")) 38 | and (isinstance(formatted_message, str)) 39 | and formatted_message.startswith("{") 40 | and (isinstance(event.get("contexts"), dict)) 41 | ): 42 | try: 43 | loaded_formatted_log = orjson.loads(formatted_message) 44 | except orjson.JSONDecodeError: 45 | return event 46 | if not isinstance(loaded_formatted_log, dict): 47 | return event 48 | 49 | if event_name := loaded_formatted_log.get("event"): 50 | event["logentry"]["formatted"] = event_name # type: ignore[index] 51 | else: 52 | return event 53 | 54 | additional_extra = loaded_formatted_log 55 | for one_attr in IGNORED_STRUCTLOG_ATTRIBUTES: 56 | additional_extra.pop(one_attr, None) 57 | if additional_extra: 58 | event["contexts"]["structlog"] = additional_extra 59 | 60 | return event 61 | 62 | 63 | SENTRY_EXTRA_OTEL_TRACE_ID_KEY: typing.Final = "otelTraceID" 64 | SENTRY_EXTRA_OTEL_TRACE_URL_KEY: typing.Final = "otelTraceURL" 65 | 66 | 67 | def add_trace_url_to_event( 68 | trace_link_template: str, event: sentry_types.Event, _hint: sentry_types.Hint 69 | ) -> sentry_types.Event: 70 | if trace_link_template and (trace_id := event.get("extra", {}).get(SENTRY_EXTRA_OTEL_TRACE_ID_KEY)): 71 | event["extra"][SENTRY_EXTRA_OTEL_TRACE_URL_KEY] = trace_link_template.replace("{trace_id}", str(trace_id)) 72 | return event 73 | 74 | 75 | def wrap_before_send_callbacks(*callbacks: sentry_types.EventProcessor | None) -> sentry_types.EventProcessor: 76 | def run_before_send(event: sentry_types.Event, hint: sentry_types.Hint) -> sentry_types.Event | None: 77 | for callback in callbacks: 78 | if not callback: 79 | continue 80 | temp_event = callback(event, hint) 81 | if temp_event is None: 82 | return None 83 | event = temp_event 84 | return event 85 | 86 | return run_before_send 87 | 88 | 89 | class SentryInstrument(Instrument[SentryConfig]): 90 | instrument_name = "Sentry" 91 | ready_condition = "Provide sentry_dsn" 92 | 93 | def is_ready(self) -> bool: 94 | return bool(self.instrument_config.sentry_dsn) 95 | 96 | def bootstrap(self) -> None: 97 | sentry_sdk.init( 98 | dsn=self.instrument_config.sentry_dsn, 99 | sample_rate=self.instrument_config.sentry_sample_rate, 100 | traces_sample_rate=self.instrument_config.sentry_traces_sample_rate, 101 | environment=self.instrument_config.service_environment, 102 | max_breadcrumbs=self.instrument_config.sentry_max_breadcrumbs, 103 | max_value_length=self.instrument_config.sentry_max_value_length, 104 | attach_stacktrace=self.instrument_config.sentry_attach_stacktrace, 105 | before_send=wrap_before_send_callbacks( 106 | enrich_sentry_event_from_structlog_log, 107 | functools.partial( 108 | add_trace_url_to_event, self.instrument_config.sentry_opentelemetry_trace_url_template 109 | ) 110 | if self.instrument_config.sentry_opentelemetry_trace_url_template 111 | else None, 112 | self.instrument_config.sentry_before_send, 113 | ), 114 | integrations=self.instrument_config.sentry_integrations, 115 | **self.instrument_config.sentry_additional_params, 116 | ) 117 | if self.instrument_config.sentry_tags: 118 | # for sentry<2.1.0 119 | with contextlib.suppress(AttributeError): 120 | sentry_sdk.set_tags(self.instrument_config.sentry_tags) 121 | 122 | @classmethod 123 | def get_config_type(cls) -> type[SentryConfig]: 124 | return SentryConfig 125 | -------------------------------------------------------------------------------- /tests/bootstrappers/test_faststream.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from unittest import mock 3 | from unittest.mock import MagicMock 4 | 5 | import faker 6 | import pytest 7 | from fastapi import status 8 | from fastapi.testclient import TestClient 9 | from faststream.redis import RedisBroker, TestRedisBroker 10 | from faststream.redis.opentelemetry import RedisTelemetryMiddleware 11 | from faststream.redis.prometheus import RedisPrometheusMiddleware 12 | 13 | from microbootstrap.bootstrappers.faststream import FastStreamBootstrapper 14 | from microbootstrap.config.faststream import FastStreamConfig 15 | from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig 16 | from microbootstrap.instruments.logging_instrument import LoggingConfig 17 | from microbootstrap.instruments.opentelemetry_instrument import FastStreamOpentelemetryConfig, OpentelemetryConfig 18 | from microbootstrap.instruments.prometheus_instrument import FastStreamPrometheusConfig 19 | from microbootstrap.settings import FastStreamSettings 20 | 21 | 22 | @pytest.fixture 23 | def broker() -> RedisBroker: 24 | return RedisBroker() 25 | 26 | 27 | async def test_faststream_configure_instrument(broker: RedisBroker) -> None: 28 | test_metrics_path: typing.Final = "/test-metrics-path" 29 | 30 | application: typing.Final = ( 31 | FastStreamBootstrapper(FastStreamSettings()) 32 | .configure_application(FastStreamConfig(broker=broker)) 33 | .configure_instrument( 34 | FastStreamPrometheusConfig( 35 | prometheus_metrics_path=test_metrics_path, prometheus_middleware_cls=RedisPrometheusMiddleware 36 | ), 37 | ) 38 | .bootstrap() 39 | ) 40 | 41 | async with TestRedisBroker(broker): 42 | response: typing.Final = TestClient(app=application).get(test_metrics_path) 43 | assert response.status_code == status.HTTP_200_OK 44 | 45 | 46 | def test_faststream_configure_instruments(broker: RedisBroker) -> None: 47 | test_metrics_path: typing.Final = "/test-metrics-path" 48 | application: typing.Final = ( 49 | FastStreamBootstrapper(FastStreamSettings()) 50 | .configure_application(FastStreamConfig(broker=broker)) 51 | .configure_instruments( 52 | FastStreamPrometheusConfig( 53 | prometheus_metrics_path=test_metrics_path, prometheus_middleware_cls=RedisPrometheusMiddleware 54 | ), 55 | ) 56 | .bootstrap() 57 | ) 58 | 59 | response: typing.Final = TestClient(app=application).get(test_metrics_path) 60 | assert response.status_code == status.HTTP_200_OK 61 | 62 | 63 | def test_faststream_configure_application_lifespan(broker: RedisBroker, magic_mock: MagicMock) -> None: 64 | application: typing.Final = ( 65 | FastStreamBootstrapper(FastStreamSettings()) 66 | .configure_application(FastStreamConfig(broker=broker, lifespan=magic_mock)) 67 | .bootstrap() 68 | ) 69 | 70 | with TestClient(app=application): 71 | assert magic_mock.called 72 | 73 | 74 | class TestFastStreamHealthCheck: 75 | def test_500(self, broker: RedisBroker) -> None: 76 | test_health_path: typing.Final = "/test-health-path" 77 | application: typing.Final = ( 78 | FastStreamBootstrapper(FastStreamSettings()) 79 | .configure_application(FastStreamConfig(broker=broker)) 80 | .configure_instruments(HealthChecksConfig(health_checks_path=test_health_path)) 81 | .bootstrap() 82 | ) 83 | 84 | response: typing.Final = TestClient(app=application).get(test_health_path) 85 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 86 | 87 | async def test_ok(self, broker: RedisBroker) -> None: 88 | test_health_path: typing.Final = "/test-health-path" 89 | application: typing.Final = ( 90 | FastStreamBootstrapper(FastStreamSettings()) 91 | .configure_application(FastStreamConfig(broker=broker)) 92 | .configure_instruments(HealthChecksConfig(health_checks_path=test_health_path)) 93 | .bootstrap() 94 | ) 95 | 96 | async with TestRedisBroker(broker): 97 | response: typing.Final = TestClient(app=application).get(test_health_path) 98 | assert response.status_code == status.HTTP_200_OK 99 | 100 | 101 | async def test_faststream_opentelemetry( 102 | monkeypatch: pytest.MonkeyPatch, 103 | faker: faker.Faker, 104 | broker: RedisBroker, 105 | minimal_opentelemetry_config: OpentelemetryConfig, 106 | ) -> None: 107 | monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", mock.Mock()) 108 | 109 | FastStreamBootstrapper(FastStreamSettings()).configure_application( 110 | FastStreamConfig(broker=broker) 111 | ).configure_instruments( 112 | FastStreamOpentelemetryConfig( 113 | opentelemetry_middleware_cls=RedisTelemetryMiddleware, **minimal_opentelemetry_config.model_dump() 114 | ) 115 | ).bootstrap() 116 | 117 | async with TestRedisBroker(broker): 118 | with mock.patch("opentelemetry.trace.use_span") as mock_capture_event: 119 | await broker.publish(faker.pystr(), channel=faker.pystr()) 120 | assert mock_capture_event.called 121 | 122 | 123 | async def test_faststream_logging(broker: RedisBroker, minimal_logging_config: LoggingConfig) -> None: 124 | FastStreamBootstrapper(FastStreamSettings()).configure_application( 125 | FastStreamConfig(broker=broker) 126 | ).configure_instruments(minimal_logging_config).bootstrap() 127 | -------------------------------------------------------------------------------- /tests/bootstrappers/test_litestar_opentelemetry.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from unittest.mock import Mock, patch 3 | 4 | import litestar 5 | import pytest 6 | from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig 7 | from litestar.status_codes import HTTP_200_OK 8 | from litestar.testing import TestClient 9 | 10 | from microbootstrap import LitestarSettings 11 | from microbootstrap.bootstrappers.litestar import ( 12 | LitestarBootstrapper, 13 | LitestarOpentelemetryInstrument, 14 | LitestarOpenTelemetryInstrumentationMiddleware, 15 | build_litestar_route_details_from_scope, 16 | ) 17 | from microbootstrap.config.litestar import LitestarConfig 18 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ("scope", "expected_span_name", "expected_attributes"), 23 | [ 24 | ( 25 | { 26 | "path": "/users/123", 27 | "path_template": "/users/{user_id}", 28 | "method": "GET", 29 | }, 30 | "GET /users/{user_id}", 31 | {"http.route": "/users/{user_id}"}, 32 | ), 33 | ( 34 | { 35 | "path": "/users/123", 36 | "method": "POST", 37 | }, 38 | "POST /users/123", 39 | {"http.route": "/users/123"}, 40 | ), 41 | ( 42 | { 43 | "path": "/test", 44 | }, 45 | "HTTP /test", 46 | {"http.route": "/test"}, 47 | ), 48 | ( 49 | { 50 | "path": "", 51 | }, 52 | "HTTP", 53 | {"http.route": ""}, 54 | ), 55 | ( 56 | { 57 | "path": " ", 58 | }, 59 | "HTTP", 60 | {"http.route": ""}, 61 | ), 62 | ( 63 | { 64 | "path_template": "", 65 | }, 66 | "HTTP", 67 | {"http.route": ""}, 68 | ), 69 | ( 70 | { 71 | "path_template": " ", 72 | }, 73 | "HTTP", 74 | {"http.route": ""}, 75 | ), 76 | ( 77 | {}, 78 | "HTTP", 79 | {}, 80 | ), 81 | ( 82 | {"method": "GET"}, 83 | "GET", 84 | {}, 85 | ), 86 | ( 87 | { 88 | "path": "/users/123", 89 | "path_template": "/users/{user_id}", 90 | }, 91 | "HTTP /users/{user_id}", 92 | {"http.route": "/users/{user_id}"}, 93 | ), 94 | ], 95 | ) 96 | def test_build_litestar_route_details_from_scope( 97 | scope: dict[str, str], 98 | expected_span_name: str, 99 | expected_attributes: dict[str, str], 100 | ) -> None: 101 | span_name, attributes = build_litestar_route_details_from_scope(scope) # type: ignore[arg-type] 102 | 103 | assert span_name == expected_span_name 104 | assert attributes == expected_attributes 105 | 106 | 107 | def test_litestar_opentelemetry_instrument_uses_custom_middleware( 108 | minimal_opentelemetry_config: OpentelemetryConfig, 109 | ) -> None: 110 | opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) 111 | opentelemetry_instrument.bootstrap() 112 | 113 | bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before() 114 | 115 | assert "middleware" in bootstrap_result 116 | assert len(bootstrap_result["middleware"]) == 1 117 | 118 | middleware_config: typing.Final = bootstrap_result["middleware"][0] 119 | assert middleware_config.middleware == LitestarOpenTelemetryInstrumentationMiddleware 120 | 121 | 122 | @pytest.mark.parametrize( 123 | ("path", "expected_span_name"), 124 | [ 125 | ("/users/123", "GET /users/{user_id}"), 126 | ("/users/", "GET /users/"), 127 | ("/", "GET /"), 128 | ], 129 | ) 130 | def test_litestar_opentelemetry_integration_with_path_templates( 131 | path: str, 132 | expected_span_name: str, 133 | minimal_opentelemetry_config: OpentelemetryConfig, 134 | ) -> None: 135 | @litestar.get("/users/{user_id:int}") 136 | async def get_user(user_id: int) -> dict[str, int]: 137 | return {"user_id": user_id} 138 | 139 | @litestar.get("/users/") 140 | async def list_users() -> dict[str, str]: 141 | return {"message": "list of users"} 142 | 143 | @litestar.get("/") 144 | async def root() -> dict[str, str]: 145 | return {"message": "root"} 146 | 147 | with patch("microbootstrap.bootstrappers.litestar.build_litestar_route_details_from_scope") as mock_function: 148 | mock_function.return_value = (expected_span_name, {"http.route": path}) 149 | 150 | application: typing.Final = ( 151 | LitestarBootstrapper(LitestarSettings()) 152 | .configure_instrument(minimal_opentelemetry_config) 153 | .configure_application(LitestarConfig(route_handlers=[get_user, list_users, root])) 154 | .bootstrap() 155 | ) 156 | 157 | with TestClient(app=application) as client: 158 | response: typing.Final = client.get(path) 159 | assert response.status_code == HTTP_200_OK 160 | assert mock_function.called 161 | 162 | 163 | def test_litestar_opentelemetry_middleware_initialization() -> None: 164 | mock_app: typing.Final = Mock() 165 | 166 | mock_config: typing.Final = Mock(spec=LitestarOpentelemetryConfig) 167 | mock_config.scopes = ["http"] 168 | mock_config.exclude = [] 169 | mock_config.exclude_opt_key = None 170 | mock_config.client_request_hook_handler = None 171 | mock_config.client_response_hook_handler = None 172 | mock_config.exclude_urls_env_key = None 173 | mock_config.meter = None 174 | mock_config.meter_provider = None 175 | mock_config.server_request_hook_handler = None 176 | mock_config.tracer_provider = None 177 | 178 | middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware(app=mock_app, config=mock_config) 179 | 180 | assert middleware.app == mock_app 181 | assert hasattr(middleware, "open_telemetry_middleware") 182 | assert middleware.open_telemetry_middleware is not None 183 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | import pydantic 5 | import pytest 6 | 7 | from microbootstrap import exceptions, helpers 8 | from microbootstrap.helpers import optimize_exclude_paths 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("first_dict", "second_dict", "result", "is_error"), 13 | [ 14 | ({"value": 1}, {"value": 2}, {"value": 2}, False), 15 | ({"value": 1, "value2": 2}, {"value": 1, "value2": 3}, {"value": 1, "value2": 3}, False), 16 | ({"value": 1}, {"value2": 1}, {"value": 1, "value2": 1}, False), 17 | ({"array": [1, 2]}, {"array": [3, 4]}, {"array": [1, 2, 3, 4]}, False), 18 | ({"tuple": (1, 2)}, {"tuple": (2, 3)}, {"tuple": (1, 2, 2, 3)}, False), 19 | ({"set": {1, 2}}, {"set": {2, 3}}, {"set": {1, 2, 3}}, False), 20 | ({"dict": {"value": 1}}, {"dict": {"value": 1}}, {"dict": {"value": 1}}, False), 21 | ({"dict": {"value": 1}}, {"dict": {"value": 2}}, {"dict": {"value": 2}}, False), 22 | ( 23 | {"dict": {"value": 1, "value2": 2, "value4": 4}}, 24 | {"dict": {"value": 5, "value2": 3, "value3": 2}}, 25 | {"dict": {"value": 5, "value2": 3, "value3": 2, "value4": 4}}, 26 | False, 27 | ), 28 | ({"array": [1, 2]}, {"array": {"val": 1}}, {}, True), 29 | ({"tuple": (2, 3)}, {"tuple": [1, 2]}, {}, True), 30 | ({"dict": {"value": 1}}, {"dict": [1, 2]}, {}, True), 31 | ({"set": {1, 2}}, {"set": [1, 2]}, {}, True), 32 | ], 33 | ) 34 | def test_merge_config_dicts( 35 | first_dict: dict[str, typing.Any], 36 | second_dict: dict[str, typing.Any], 37 | result: dict[str, typing.Any], 38 | is_error: bool, 39 | ) -> None: 40 | if is_error: 41 | with pytest.raises(exceptions.ConfigMergeError): 42 | helpers.merge_dict_configs(first_dict, second_dict) 43 | else: 44 | assert result == helpers.merge_dict_configs(first_dict, second_dict) 45 | 46 | 47 | class PydanticConfig(pydantic.BaseModel): 48 | string_field: str 49 | array_field: list[typing.Any] = pydantic.Field(default_factory=list) 50 | dict_field: dict[str, typing.Any] = pydantic.Field(default_factory=dict) 51 | 52 | 53 | @dataclasses.dataclass 54 | class InnerDataclass: 55 | string_field: str 56 | 57 | 58 | @pytest.mark.parametrize( 59 | ("first_model", "second_model", "result"), 60 | [ 61 | ( 62 | PydanticConfig(string_field="value1"), 63 | PydanticConfig(string_field="value2"), 64 | PydanticConfig(string_field="value2"), 65 | ), 66 | ( 67 | PydanticConfig(string_field="value1", array_field=[1]), 68 | PydanticConfig(string_field="value2", array_field=[2]), 69 | PydanticConfig(string_field="value2", array_field=[1, 2]), 70 | ), 71 | ( 72 | PydanticConfig(string_field="value1", dict_field={"value1": 1}), 73 | PydanticConfig(string_field="value2", dict_field={"value2": 2}), 74 | PydanticConfig(string_field="value2", dict_field={"value1": 1, "value2": 2}), 75 | ), 76 | ( 77 | PydanticConfig(string_field="value1", array_field=[1, 2], dict_field={"value1": 1, "value3": 3}), 78 | PydanticConfig(string_field="value2", array_field=[1, 3], dict_field={"value2": 2, "value3": 4}), 79 | PydanticConfig( 80 | string_field="value2", 81 | array_field=[1, 2, 1, 3], 82 | dict_field={"value1": 1, "value2": 2, "value3": 4}, 83 | ), 84 | ), 85 | ( 86 | PydanticConfig(string_field="value1", array_field=[1, 2], dict_field={"value1": 1, "value3": 3}), 87 | PydanticConfig( 88 | string_field="value2", 89 | array_field=[InnerDataclass(string_field="hi")], 90 | dict_field={"value1": 1, "value2": 2, "value3": InnerDataclass(string_field="there")}, 91 | ), 92 | PydanticConfig( 93 | string_field="value2", 94 | array_field=[1, 2, InnerDataclass(string_field="hi")], 95 | dict_field={"value1": 1, "value2": 2, "value3": InnerDataclass(string_field="there")}, 96 | ), 97 | ), 98 | ], 99 | ) 100 | def test_merge_pydantic_configs( 101 | first_model: PydanticConfig, 102 | second_model: PydanticConfig, 103 | result: PydanticConfig, 104 | ) -> None: 105 | assert result == helpers.merge_pydantic_configs(first_model, second_model) 106 | 107 | 108 | @dataclasses.dataclass 109 | class DataclassConfig: 110 | string_field: str 111 | array_field: list[typing.Any] = dataclasses.field(default_factory=list) 112 | dict_field: dict[str, typing.Any] = dataclasses.field(default_factory=dict) 113 | 114 | 115 | @pytest.mark.parametrize( 116 | ("first_class", "second_class", "result"), 117 | [ 118 | ( 119 | DataclassConfig(string_field="value1"), 120 | DataclassConfig(string_field="value2"), 121 | DataclassConfig(string_field="value2"), 122 | ), 123 | ( 124 | DataclassConfig(string_field="value1", array_field=[1]), 125 | DataclassConfig(string_field="value2", array_field=[2]), 126 | DataclassConfig(string_field="value2", array_field=[1, 2]), 127 | ), 128 | ( 129 | DataclassConfig(string_field="value1", dict_field={"value1": 1}), 130 | DataclassConfig(string_field="value2", dict_field={"value2": 2}), 131 | DataclassConfig(string_field="value2", dict_field={"value1": 1, "value2": 2}), 132 | ), 133 | ( 134 | DataclassConfig(string_field="value1", array_field=[1, 2], dict_field={"value1": 1, "value3": 3}), 135 | DataclassConfig(string_field="value2", array_field=[1, 3], dict_field={"value2": 2, "value3": 4}), 136 | DataclassConfig( 137 | string_field="value2", 138 | array_field=[1, 2, 1, 3], 139 | dict_field={"value1": 1, "value2": 2, "value3": 4}, 140 | ), 141 | ), 142 | ], 143 | ) 144 | def test_merge_dataclasses_configs( 145 | first_class: DataclassConfig, 146 | second_class: DataclassConfig, 147 | result: DataclassConfig, 148 | ) -> None: 149 | assert result == helpers.merge_dataclasses_configs(first_class, second_class) 150 | 151 | 152 | @pytest.mark.parametrize( 153 | "exclude_paths", 154 | [ 155 | ["path"], 156 | ["path"] * 11, 157 | ], 158 | ) 159 | def test_optimize_exclude_paths(exclude_paths: list[str]) -> None: 160 | optimize_exclude_paths(exclude_paths) 161 | -------------------------------------------------------------------------------- /tests/instruments/test_sentry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import copy 3 | import typing 4 | from unittest import mock 5 | 6 | import litestar 7 | import pytest 8 | from litestar.testing import TestClient as LitestarTestClient 9 | 10 | from microbootstrap.bootstrappers.litestar import LitestarSentryInstrument 11 | from microbootstrap.instruments.sentry_instrument import ( 12 | SENTRY_EXTRA_OTEL_TRACE_ID_KEY, 13 | SENTRY_EXTRA_OTEL_TRACE_URL_KEY, 14 | SentryInstrument, 15 | add_trace_url_to_event, 16 | enrich_sentry_event_from_structlog_log, 17 | ) 18 | 19 | 20 | if typing.TYPE_CHECKING: 21 | import faker 22 | from sentry_sdk import _types as sentry_types 23 | 24 | from microbootstrap import SentryConfig 25 | 26 | 27 | def test_sentry_is_ready(minimal_sentry_config: SentryConfig) -> None: 28 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config) 29 | assert sentry_instrument.is_ready() 30 | 31 | 32 | def test_sentry_bootstrap_is_not_ready(minimal_sentry_config: SentryConfig) -> None: 33 | minimal_sentry_config.sentry_dsn = "" 34 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config) 35 | assert not sentry_instrument.is_ready() 36 | 37 | 38 | def test_sentry_bootstrap_after( 39 | default_litestar_app: litestar.Litestar, 40 | minimal_sentry_config: SentryConfig, 41 | ) -> None: 42 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config) 43 | assert sentry_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 44 | 45 | 46 | def test_sentry_teardown( 47 | minimal_sentry_config: SentryConfig, 48 | ) -> None: 49 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config) 50 | assert sentry_instrument.teardown() is None # type: ignore[func-returns-value] 51 | 52 | 53 | def test_litestar_sentry_bootstrap(minimal_sentry_config: SentryConfig) -> None: 54 | sentry_instrument: typing.Final = LitestarSentryInstrument(minimal_sentry_config) 55 | sentry_instrument.bootstrap() 56 | assert sentry_instrument.bootstrap_before() == {} 57 | 58 | 59 | def test_litestar_sentry_bootstrap_catch_exception( 60 | minimal_sentry_config: SentryConfig, 61 | ) -> None: 62 | sentry_instrument: typing.Final = LitestarSentryInstrument(minimal_sentry_config) 63 | 64 | @litestar.get("/test-error-handler") 65 | async def error_handler() -> None: 66 | raise ValueError("I'm test error") 67 | 68 | sentry_instrument.bootstrap() 69 | litestar_application: typing.Final = litestar.Litestar(route_handlers=[error_handler]) 70 | with mock.patch("sentry_sdk.Scope.capture_event") as mock_capture_event: 71 | with LitestarTestClient(app=litestar_application) as test_client: 72 | test_client.get("/test-error-handler") 73 | 74 | assert mock_capture_event.called 75 | 76 | 77 | class TestSentryEnrichEventFromStructlog: 78 | @pytest.mark.parametrize( 79 | "event", 80 | [ 81 | {}, 82 | {"logentry": None}, 83 | {"logentry": {}}, 84 | {"logentry": {"formatted": b""}}, 85 | {"logentry": {"formatted": ""}}, 86 | {"logentry": {"formatted": "hi"}}, 87 | {"logentry": {"formatted": "[]"}}, 88 | {"logentry": {"formatted": "[{}]"}}, 89 | {"logentry": {"formatted": "{"}, "contexts": {}}, 90 | {"logentry": {"formatted": "{}"}, "contexts": {}}, 91 | ], 92 | ) 93 | def test_skip(self, event: sentry_types.Event) -> None: 94 | assert enrich_sentry_event_from_structlog_log(copy.deepcopy(event), mock.Mock()) == event 95 | 96 | @pytest.mark.parametrize( 97 | ("event_before", "event_after"), 98 | [ 99 | ( 100 | {"logentry": {"formatted": '{"event": "event name"}'}, "contexts": {}}, 101 | {"logentry": {"formatted": "event name"}, "contexts": {}}, 102 | ), 103 | ( 104 | { 105 | "logentry": { 106 | "formatted": '{"event": "event name", "timestamp": 1, "level": "error", "logger": "event.logger", "tracing": {}, "foo": "bar"}' # noqa: E501 107 | }, 108 | "contexts": {}, 109 | }, 110 | { 111 | "logentry": {"formatted": "event name"}, 112 | "contexts": {"structlog": {"foo": "bar"}}, 113 | }, 114 | ), 115 | ], 116 | ) 117 | def test_modify(self, event_before: sentry_types.Event, event_after: sentry_types.Event) -> None: 118 | assert enrich_sentry_event_from_structlog_log(event_before, mock.Mock()) == event_after 119 | 120 | 121 | TRACE_URL_TEMPLATE = "https://example.com/traces/{trace_id}" 122 | 123 | 124 | class TestSentryAddTraceUrlToEvent: 125 | def test_add_trace_url_with_trace_id(self, faker: faker.Faker) -> None: 126 | trace_id = faker.pystr() 127 | event: sentry_types.Event = {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: trace_id}} 128 | 129 | result = add_trace_url_to_event(TRACE_URL_TEMPLATE, event, mock.Mock()) 130 | 131 | assert result["extra"][SENTRY_EXTRA_OTEL_TRACE_URL_KEY] == f"https://example.com/traces/{trace_id}" 132 | 133 | @pytest.mark.parametrize( 134 | "event", 135 | [ 136 | {}, 137 | {"extra": {}}, 138 | {"extra": {"other_field": "value"}}, 139 | {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: None}}, 140 | {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: ""}}, 141 | ], 142 | ) 143 | def test_add_trace_url_without_trace_id(self, event: sentry_types.Event) -> None: 144 | result = add_trace_url_to_event(TRACE_URL_TEMPLATE, event, mock.Mock()) 145 | 146 | assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY not in result.get("extra", {}) 147 | 148 | def test_add_trace_url_empty_template(self, faker: faker.Faker) -> None: 149 | event: sentry_types.Event = {"extra": {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: faker.pystr()}} 150 | 151 | result = add_trace_url_to_event("", event, mock.Mock()) 152 | 153 | assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY not in result["extra"] 154 | 155 | @pytest.mark.parametrize("event", [{}, {"contexts": {}}]) 156 | def test_add_trace_url_creates_contexts(self, faker: faker.Faker, event: sentry_types.Event) -> None: 157 | event["extra"] = {SENTRY_EXTRA_OTEL_TRACE_ID_KEY: faker.pystr()} 158 | 159 | result = add_trace_url_to_event(TRACE_URL_TEMPLATE, event, mock.Mock()) 160 | 161 | assert SENTRY_EXTRA_OTEL_TRACE_URL_KEY in result["extra"] 162 | assert SENTRY_EXTRA_OTEL_TRACE_ID_KEY in result["extra"] 163 | -------------------------------------------------------------------------------- /microbootstrap/bootstrappers/faststream.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | import typing 4 | 5 | import prometheus_client 6 | import structlog 7 | import typing_extensions 8 | from faststream._internal.logger.logger_proxy import RealLoggerObject 9 | from faststream.asgi import AsgiFastStream, AsgiResponse 10 | from faststream.asgi import get as handle_get 11 | from faststream.specification import AsyncAPI 12 | from opentelemetry import trace 13 | 14 | from microbootstrap.bootstrappers.base import ApplicationBootstrapper 15 | from microbootstrap.config.faststream import FastStreamConfig 16 | from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument 17 | from microbootstrap.instruments.logging_instrument import LoggingInstrument 18 | from microbootstrap.instruments.opentelemetry_instrument import ( 19 | BaseOpentelemetryInstrument, 20 | FastStreamOpentelemetryConfig, 21 | ) 22 | from microbootstrap.instruments.prometheus_instrument import FastStreamPrometheusConfig, PrometheusInstrument 23 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument 24 | from microbootstrap.instruments.sentry_instrument import SentryInstrument 25 | from microbootstrap.settings import FastStreamSettings 26 | 27 | 28 | tracer: typing.Final = trace.get_tracer(__name__) 29 | 30 | 31 | class KwargsAsgiFastStream(AsgiFastStream): 32 | def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401 33 | # `broker` argument is positional-only 34 | super().__init__(kwargs.pop("broker", None), **kwargs) 35 | 36 | 37 | class FastStreamBootstrapper(ApplicationBootstrapper[FastStreamSettings, AsgiFastStream, FastStreamConfig]): 38 | application_config = FastStreamConfig() 39 | application_type = KwargsAsgiFastStream 40 | 41 | def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]: 42 | return { 43 | "specification": AsyncAPI( 44 | title=self.settings.service_name, 45 | version=self.settings.service_version, 46 | description=self.settings.service_description, 47 | ), 48 | "on_shutdown": [self.teardown], 49 | "on_startup": [self.console_writer.print_bootstrap_table], 50 | "asyncapi_path": self.settings.asyncapi_path, 51 | } 52 | 53 | 54 | FastStreamBootstrapper.use_instrument()(SentryInstrument) 55 | FastStreamBootstrapper.use_instrument()(PyroscopeInstrument) 56 | 57 | 58 | @FastStreamBootstrapper.use_instrument() 59 | class FastStreamOpentelemetryInstrument(BaseOpentelemetryInstrument[FastStreamOpentelemetryConfig]): 60 | def is_ready(self) -> bool: 61 | return bool(self.instrument_config.opentelemetry_middleware_cls and super().is_ready()) 62 | 63 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override] 64 | if self.instrument_config.opentelemetry_middleware_cls and application.broker: 65 | application.broker.add_middleware( 66 | self.instrument_config.opentelemetry_middleware_cls(tracer_provider=self.tracer_provider), 67 | ) 68 | return application 69 | 70 | @classmethod 71 | def get_config_type(cls) -> type[FastStreamOpentelemetryConfig]: 72 | return FastStreamOpentelemetryConfig 73 | 74 | 75 | faststream_app_logger: typing.Final = structlog.get_logger("microbootstrap.faststream.app") 76 | faststream_broker_logger: typing.Final = structlog.get_logger("microbootstrap.faststream.broker") 77 | 78 | 79 | @FastStreamBootstrapper.use_instrument() 80 | class FastStreamLoggingInstrument(LoggingInstrument): 81 | def bootstrap_before(self) -> dict[str, typing.Any]: 82 | return {"logger": faststream_app_logger} 83 | 84 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override] 85 | for one_broker in application.brokers: 86 | one_broker.config.broker_config.logger.logger = RealLoggerObject(faststream_broker_logger) 87 | return application 88 | 89 | 90 | @FastStreamBootstrapper.use_instrument() 91 | class FastStreamPrometheusInstrument(PrometheusInstrument[FastStreamPrometheusConfig]): 92 | def is_ready(self) -> bool: 93 | return bool(self.instrument_config.prometheus_middleware_cls and super().is_ready()) 94 | 95 | def bootstrap_before(self) -> dict[str, typing.Any]: 96 | return { 97 | "asgi_routes": ( 98 | ( 99 | self.instrument_config.prometheus_metrics_path, 100 | prometheus_client.make_asgi_app(prometheus_client.REGISTRY), 101 | ), 102 | ), 103 | } 104 | 105 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override] 106 | if self.instrument_config.prometheus_middleware_cls and application.broker: 107 | application.broker.add_middleware( 108 | self.instrument_config.prometheus_middleware_cls( 109 | registry=prometheus_client.REGISTRY, 110 | custom_labels=self.instrument_config.prometheus_custom_labels, 111 | ), 112 | ) 113 | return application 114 | 115 | @classmethod 116 | def get_config_type(cls) -> type[FastStreamPrometheusConfig]: 117 | return FastStreamPrometheusConfig 118 | 119 | 120 | @FastStreamBootstrapper.use_instrument() 121 | class FastStreamHealthChecksInstrument(HealthChecksInstrument): 122 | def bootstrap(self) -> None: ... 123 | def bootstrap_before(self) -> dict[str, typing.Any]: 124 | @handle_get 125 | async def check_health(scope: typing.Any) -> AsgiResponse: # noqa: ANN401, ARG001 126 | return ( 127 | AsgiResponse( 128 | json.dumps(self.render_health_check_data()).encode(), 129 | 200, 130 | headers={"content-type": "text/plain"}, 131 | ) 132 | if await self.define_health_status() 133 | else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "application/json"}) 134 | ) 135 | 136 | if self.instrument_config.opentelemetry_generate_health_check_spans: 137 | check_health = tracer.start_as_current_span(f"GET {self.instrument_config.health_checks_path}")( 138 | check_health, 139 | ) 140 | 141 | return {"asgi_routes": ((self.instrument_config.health_checks_path, check_health),)} 142 | 143 | async def define_health_status(self) -> bool: 144 | return await self.application.broker.ping(timeout=5) if self.application and self.application.broker else False 145 | 146 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override] 147 | self.application = application 148 | return application 149 | -------------------------------------------------------------------------------- /microbootstrap/bootstrappers/fastapi.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import typing 3 | 4 | import fastapi 5 | from fastapi.middleware.cors import CORSMiddleware 6 | from fastapi_offline_docs import enable_offline_docs 7 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 8 | from prometheus_fastapi_instrumentator import Instrumentator, metrics 9 | 10 | from microbootstrap.bootstrappers.base import ApplicationBootstrapper 11 | from microbootstrap.config.fastapi import FastApiConfig 12 | from microbootstrap.instruments.cors_instrument import CorsInstrument 13 | from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict 14 | from microbootstrap.instruments.logging_instrument import LoggingInstrument 15 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument 16 | from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument 17 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument 18 | from microbootstrap.instruments.sentry_instrument import SentryInstrument 19 | from microbootstrap.instruments.swagger_instrument import SwaggerInstrument 20 | from microbootstrap.middlewares.fastapi import build_fastapi_logging_middleware 21 | from microbootstrap.settings import FastApiSettings 22 | 23 | 24 | ApplicationT = typing.TypeVar("ApplicationT", bound=fastapi.FastAPI) 25 | 26 | 27 | class FastApiBootstrapper( 28 | ApplicationBootstrapper[FastApiSettings, fastapi.FastAPI, FastApiConfig], 29 | ): 30 | application_config = FastApiConfig() 31 | application_type = fastapi.FastAPI 32 | 33 | @contextlib.asynccontextmanager 34 | async def _lifespan_manager(self, _: fastapi.FastAPI) -> typing.AsyncIterator[None]: 35 | try: 36 | self.console_writer.print_bootstrap_table() 37 | yield 38 | finally: 39 | self.teardown() 40 | 41 | @contextlib.asynccontextmanager 42 | async def _wrapped_lifespan_manager(self, app: fastapi.FastAPI) -> typing.AsyncIterator[None]: 43 | assert self.application_config.lifespan # noqa: S101 44 | async with self._lifespan_manager(app), self.application_config.lifespan(app): 45 | yield None 46 | 47 | def bootstrap_before(self) -> dict[str, typing.Any]: 48 | return { 49 | "debug": self.settings.service_debug, 50 | "lifespan": self._wrapped_lifespan_manager if self.application_config.lifespan else self._lifespan_manager, 51 | } 52 | 53 | 54 | FastApiBootstrapper.use_instrument()(SentryInstrument) 55 | 56 | 57 | @FastApiBootstrapper.use_instrument() 58 | class FastApiSwaggerInstrument(SwaggerInstrument): 59 | def bootstrap_before(self) -> dict[str, typing.Any]: 60 | return { 61 | "title": self.instrument_config.service_name, 62 | "description": self.instrument_config.service_description, 63 | "docs_url": self.instrument_config.swagger_path, 64 | "version": self.instrument_config.service_version, 65 | } 66 | 67 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 68 | if self.instrument_config.swagger_offline_docs: 69 | enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path) 70 | return application 71 | 72 | 73 | @FastApiBootstrapper.use_instrument() 74 | class FastApiCorsInstrument(CorsInstrument): 75 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 76 | application.add_middleware( 77 | CORSMiddleware, 78 | allow_origins=self.instrument_config.cors_allowed_origins, 79 | allow_methods=self.instrument_config.cors_allowed_methods, 80 | allow_headers=self.instrument_config.cors_allowed_headers, 81 | allow_credentials=self.instrument_config.cors_allowed_credentials, 82 | allow_origin_regex=self.instrument_config.cors_allowed_origin_regex, 83 | expose_headers=self.instrument_config.cors_exposed_headers, 84 | max_age=self.instrument_config.cors_max_age, 85 | ) 86 | return application 87 | 88 | 89 | FastApiBootstrapper.use_instrument()(PyroscopeInstrument) 90 | 91 | 92 | @FastApiBootstrapper.use_instrument() 93 | class FastApiOpentelemetryInstrument(OpentelemetryInstrument): 94 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 95 | FastAPIInstrumentor.instrument_app( 96 | application, 97 | tracer_provider=self.tracer_provider, 98 | excluded_urls=",".join(self.define_exclude_urls()), 99 | ) 100 | return application 101 | 102 | 103 | @FastApiBootstrapper.use_instrument() 104 | class FastApiLoggingInstrument(LoggingInstrument): 105 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 106 | if not self.instrument_config.logging_turn_off_middleware: 107 | application.add_middleware( 108 | build_fastapi_logging_middleware(self.instrument_config.logging_exclude_endpoints), 109 | ) 110 | return application 111 | 112 | 113 | @FastApiBootstrapper.use_instrument() 114 | class FastApiPrometheusInstrument(PrometheusInstrument[FastApiPrometheusConfig]): 115 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 116 | Instrumentator(**self.instrument_config.prometheus_instrumentator_params).add( 117 | metrics.default( 118 | custom_labels=self.instrument_config.prometheus_custom_labels, 119 | ), 120 | ).instrument( 121 | application, 122 | **self.instrument_config.prometheus_instrument_params, 123 | ).expose( 124 | application, 125 | endpoint=self.instrument_config.prometheus_metrics_path, 126 | include_in_schema=self.instrument_config.prometheus_metrics_include_in_schema, 127 | **self.instrument_config.prometheus_expose_params, 128 | ) 129 | return application 130 | 131 | @classmethod 132 | def get_config_type(cls) -> type[FastApiPrometheusConfig]: 133 | return FastApiPrometheusConfig 134 | 135 | 136 | @FastApiBootstrapper.use_instrument() 137 | class FastApiHealthChecksInstrument(HealthChecksInstrument): 138 | def build_fastapi_health_check_router(self) -> fastapi.APIRouter: 139 | fastapi_router: typing.Final = fastapi.APIRouter( 140 | tags=["probes"], 141 | include_in_schema=self.instrument_config.health_checks_include_in_schema, 142 | ) 143 | 144 | @fastapi_router.get(self.instrument_config.health_checks_path) 145 | async def health_check_handler() -> HealthCheckTypedDict: 146 | return self.render_health_check_data() 147 | 148 | return fastapi_router 149 | 150 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT: 151 | application.include_router(self.build_fastapi_health_check_router()) 152 | return application 153 | -------------------------------------------------------------------------------- /tests/instruments/test_opentelemetry.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import typing 3 | from unittest import mock 4 | from unittest.mock import AsyncMock, MagicMock, Mock, patch 5 | 6 | import fastapi 7 | import litestar 8 | import pytest 9 | from fastapi.testclient import TestClient as FastAPITestClient 10 | from litestar.middleware.base import DefineMiddleware 11 | from litestar.testing import TestClient as LitestarTestClient 12 | from opentelemetry.instrumentation.dependencies import DependencyConflictError 13 | 14 | from microbootstrap import OpentelemetryConfig 15 | from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument 16 | from microbootstrap.bootstrappers.litestar import LitestarOpentelemetryInstrument 17 | from microbootstrap.instruments import opentelemetry_instrument 18 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument 19 | 20 | 21 | def test_opentelemetry_is_ready( 22 | minimal_opentelemetry_config: OpentelemetryConfig, 23 | ) -> None: 24 | test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config) 25 | assert test_opentelemetry_instrument.is_ready() 26 | 27 | 28 | def test_opentelemetry_bootstrap_is_not_ready(minimal_opentelemetry_config: OpentelemetryConfig) -> None: 29 | minimal_opentelemetry_config.service_debug = False 30 | minimal_opentelemetry_config.opentelemetry_endpoint = None 31 | test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config) 32 | assert not test_opentelemetry_instrument.is_ready() 33 | 34 | 35 | def test_opentelemetry_bootstrap_after( 36 | default_litestar_app: litestar.Litestar, 37 | minimal_opentelemetry_config: OpentelemetryConfig, 38 | ) -> None: 39 | test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config) 40 | assert test_opentelemetry_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 41 | 42 | 43 | def test_opentelemetry_teardown( 44 | minimal_opentelemetry_config: OpentelemetryConfig, 45 | ) -> None: 46 | test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config) 47 | assert test_opentelemetry_instrument.teardown() is None # type: ignore[func-returns-value] 48 | 49 | 50 | def test_litestar_opentelemetry_bootstrap( 51 | minimal_opentelemetry_config: OpentelemetryConfig, 52 | magic_mock: MagicMock, 53 | ) -> None: 54 | minimal_opentelemetry_config.opentelemetry_instrumentors = [magic_mock] 55 | test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) 56 | 57 | test_opentelemetry_instrument.bootstrap() 58 | opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before() 59 | 60 | assert opentelemetry_bootstrap_result 61 | assert "middleware" in opentelemetry_bootstrap_result 62 | assert isinstance(opentelemetry_bootstrap_result["middleware"], list) 63 | assert len(opentelemetry_bootstrap_result["middleware"]) == 1 64 | assert isinstance(opentelemetry_bootstrap_result["middleware"][0], DefineMiddleware) 65 | 66 | 67 | def test_litestar_opentelemetry_teardown( 68 | minimal_opentelemetry_config: OpentelemetryConfig, 69 | magic_mock: MagicMock, 70 | ) -> None: 71 | minimal_opentelemetry_config.opentelemetry_instrumentors = [magic_mock] 72 | test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) 73 | 74 | test_opentelemetry_instrument.teardown() 75 | 76 | 77 | def test_litestar_opentelemetry_bootstrap_working( 78 | minimal_opentelemetry_config: OpentelemetryConfig, 79 | async_mock: AsyncMock, 80 | ) -> None: 81 | test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) 82 | test_opentelemetry_instrument.bootstrap() 83 | opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before() 84 | 85 | opentelemetry_middleware = opentelemetry_bootstrap_result["middleware"][0] 86 | assert isinstance(opentelemetry_middleware, DefineMiddleware) 87 | async_mock.__name__ = "test-name" 88 | opentelemetry_middleware.middleware.__call__ = async_mock # type: ignore[operator] 89 | 90 | @litestar.get("/test-handler") 91 | async def test_handler() -> None: 92 | return None 93 | 94 | litestar_application: typing.Final = litestar.Litestar( 95 | route_handlers=[test_handler], 96 | **opentelemetry_bootstrap_result, 97 | ) 98 | with LitestarTestClient(app=litestar_application) as test_client: 99 | # Silencing error, because we are mocking middleware call, so ASGI scope remains unchanged. 100 | with contextlib.suppress(AssertionError): 101 | test_client.get("/test-handler") 102 | assert async_mock.called 103 | 104 | 105 | def test_fastapi_opentelemetry_bootstrap_working( 106 | minimal_opentelemetry_config: OpentelemetryConfig, monkeypatch: pytest.MonkeyPatch 107 | ) -> None: 108 | monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock()) 109 | 110 | test_opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config) 111 | test_opentelemetry_instrument.bootstrap() 112 | fastapi_application: typing.Final = test_opentelemetry_instrument.bootstrap_after(fastapi.FastAPI()) 113 | 114 | @fastapi_application.get("/test-handler") 115 | async def test_handler() -> None: 116 | return None 117 | 118 | with patch("opentelemetry.trace.use_span") as mock_capture_event: 119 | FastAPITestClient(app=fastapi_application).get("/test-handler") 120 | assert mock_capture_event.called 121 | 122 | 123 | @pytest.mark.parametrize( 124 | ("instruments", "result"), 125 | [ 126 | ( 127 | [ 128 | MagicMock(), 129 | MagicMock(load=MagicMock(side_effect=ImportError)), 130 | MagicMock(load=MagicMock(side_effect=DependencyConflictError(mock.Mock()))), 131 | MagicMock(load=MagicMock(side_effect=ModuleNotFoundError)), 132 | ], 133 | "ok", 134 | ), 135 | ( 136 | [ 137 | MagicMock(load=MagicMock(side_effect=ValueError)), 138 | ], 139 | "raise", 140 | ), 141 | ( 142 | [ 143 | MagicMock(load=MagicMock(side_effect=ValueError)), 144 | ], 145 | "exclude", 146 | ), 147 | ], 148 | ) 149 | def test_instrumentors_loader( 150 | minimal_opentelemetry_config: OpentelemetryConfig, 151 | instruments: list[MagicMock], 152 | result: str, 153 | monkeypatch: pytest.MonkeyPatch, 154 | ) -> None: 155 | if result == "exclude": 156 | minimal_opentelemetry_config.opentelemetry_disabled_instrumentations = ["exclude_this", "exclude_that"] 157 | instruments[0].name = "exclude_this" 158 | monkeypatch.setattr( 159 | opentelemetry_instrument, 160 | "entry_points", 161 | MagicMock(return_value=[*instruments]), 162 | ) 163 | 164 | if result != "raise": 165 | opentelemetry_instrument.OpentelemetryInstrument(instrument_config=minimal_opentelemetry_config).bootstrap() 166 | return 167 | 168 | with pytest.raises(ValueError): # noqa: PT011 169 | opentelemetry_instrument.OpentelemetryInstrument(instrument_config=minimal_opentelemetry_config).bootstrap() 170 | -------------------------------------------------------------------------------- /tests/instruments/test_prometheus.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import fastapi 4 | import litestar 5 | import pytest 6 | from fastapi.testclient import TestClient as FastAPITestClient 7 | from faststream.redis import RedisBroker, TestRedisBroker 8 | from faststream.redis.prometheus import RedisPrometheusMiddleware 9 | from litestar import status_codes 10 | from litestar.middleware.base import DefineMiddleware 11 | from litestar.testing import TestClient as LitestarTestClient 12 | from prometheus_client import REGISTRY 13 | 14 | from microbootstrap import FastApiPrometheusConfig, FastStreamSettings, LitestarPrometheusConfig 15 | from microbootstrap.bootstrappers.fastapi import FastApiPrometheusInstrument 16 | from microbootstrap.bootstrappers.faststream import FastStreamBootstrapper 17 | from microbootstrap.bootstrappers.litestar import LitestarPrometheusInstrument 18 | from microbootstrap.config.faststream import FastStreamConfig 19 | from microbootstrap.instruments.prometheus_instrument import ( 20 | BasePrometheusConfig, 21 | FastStreamPrometheusConfig, 22 | PrometheusInstrument, 23 | ) 24 | 25 | 26 | def check_is_metrics_has_labels(custom_labels_keys: set[str]) -> bool: 27 | for metric in REGISTRY.collect(): 28 | for sample in metric.samples: 29 | label_keys = set(sample.labels.keys()) 30 | if custom_labels_keys & label_keys: 31 | return True 32 | return False 33 | 34 | 35 | def test_prometheus_is_ready(minimal_base_prometheus_config: BasePrometheusConfig) -> None: 36 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config) 37 | assert prometheus_instrument.is_ready() 38 | 39 | 40 | def test_prometheus_bootstrap_is_not_ready( 41 | minimal_base_prometheus_config: BasePrometheusConfig, 42 | ) -> None: 43 | minimal_base_prometheus_config.prometheus_metrics_path = "" 44 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config) 45 | assert not prometheus_instrument.is_ready() 46 | 47 | 48 | def test_prometheus_bootstrap_after( 49 | default_litestar_app: litestar.Litestar, 50 | minimal_base_prometheus_config: BasePrometheusConfig, 51 | ) -> None: 52 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config) 53 | assert prometheus_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 54 | 55 | 56 | def test_prometheus_teardown( 57 | minimal_base_prometheus_config: BasePrometheusConfig, 58 | ) -> None: 59 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config) 60 | assert prometheus_instrument.teardown() is None # type: ignore[func-returns-value] 61 | 62 | 63 | def test_litestar_prometheus_bootstrap(minimal_litestar_prometheus_config: LitestarPrometheusConfig) -> None: 64 | prometheus_instrument: typing.Final = LitestarPrometheusInstrument(minimal_litestar_prometheus_config) 65 | prometheus_instrument.bootstrap() 66 | prometheus_bootstrap_result: typing.Final = prometheus_instrument.bootstrap_before() 67 | 68 | assert prometheus_bootstrap_result 69 | assert "route_handlers" in prometheus_bootstrap_result 70 | assert isinstance(prometheus_bootstrap_result["route_handlers"], list) 71 | assert len(prometheus_bootstrap_result["route_handlers"]) == 1 72 | assert "middleware" in prometheus_bootstrap_result 73 | assert isinstance(prometheus_bootstrap_result["middleware"], list) 74 | assert len(prometheus_bootstrap_result["middleware"]) == 1 75 | assert isinstance(prometheus_bootstrap_result["middleware"][0], DefineMiddleware) 76 | 77 | 78 | def test_litestar_prometheus_bootstrap_working( 79 | minimal_litestar_prometheus_config: LitestarPrometheusConfig, 80 | ) -> None: 81 | minimal_litestar_prometheus_config.prometheus_metrics_path = "/custom-metrics-path" 82 | prometheus_instrument: typing.Final = LitestarPrometheusInstrument(minimal_litestar_prometheus_config) 83 | 84 | prometheus_instrument.bootstrap() 85 | litestar_application: typing.Final = litestar.Litestar( 86 | **prometheus_instrument.bootstrap_before(), 87 | ) 88 | 89 | with LitestarTestClient(app=litestar_application) as test_client: 90 | response: typing.Final = test_client.get(minimal_litestar_prometheus_config.prometheus_metrics_path) 91 | assert response.status_code == status_codes.HTTP_200_OK 92 | assert response.text 93 | 94 | 95 | def test_fastapi_prometheus_bootstrap_working(minimal_fastapi_prometheus_config: FastApiPrometheusConfig) -> None: 96 | minimal_fastapi_prometheus_config.prometheus_metrics_path = "/custom-metrics-path" 97 | prometheus_instrument: typing.Final = FastApiPrometheusInstrument(minimal_fastapi_prometheus_config) 98 | 99 | fastapi_application = fastapi.FastAPI() 100 | fastapi_application = prometheus_instrument.bootstrap_after(fastapi_application) 101 | 102 | response: typing.Final = FastAPITestClient(app=fastapi_application).get( 103 | minimal_fastapi_prometheus_config.prometheus_metrics_path 104 | ) 105 | assert response.status_code == status_codes.HTTP_200_OK 106 | assert response.text 107 | 108 | 109 | @pytest.mark.parametrize( 110 | ("custom_labels", "expected_label_keys"), 111 | [ 112 | ({"test_label": "test_value"}, {"test_label"}), 113 | ({}, {"method", "handler", "status"}), 114 | ], 115 | ) 116 | def test_fastapi_prometheus_custom_labels( 117 | minimal_fastapi_prometheus_config: FastApiPrometheusConfig, 118 | custom_labels: dict[str, str], 119 | expected_label_keys: set[str], 120 | ) -> None: 121 | minimal_fastapi_prometheus_config.prometheus_custom_labels = custom_labels 122 | prometheus_instrument: typing.Final = FastApiPrometheusInstrument(minimal_fastapi_prometheus_config) 123 | 124 | fastapi_application = fastapi.FastAPI() 125 | fastapi_application = prometheus_instrument.bootstrap_after(fastapi_application) 126 | 127 | response: typing.Final = FastAPITestClient(app=fastapi_application).get( 128 | minimal_fastapi_prometheus_config.prometheus_metrics_path 129 | ) 130 | 131 | assert response.status_code == status_codes.HTTP_200_OK 132 | assert check_is_metrics_has_labels(expected_label_keys) 133 | 134 | 135 | @pytest.mark.parametrize( 136 | ("custom_labels", "expected_label_keys"), 137 | [ 138 | ({"test_label": "test_value"}, {"test_label"}), 139 | ({}, {"app_name", "broker", "handler"}), 140 | ], 141 | ) 142 | async def test_faststream_prometheus_custom_labels( 143 | minimal_faststream_prometheus_config: FastStreamPrometheusConfig, 144 | custom_labels: dict[str, str], 145 | expected_label_keys: set[str], 146 | ) -> None: 147 | minimal_faststream_prometheus_config.prometheus_custom_labels = custom_labels 148 | minimal_faststream_prometheus_config.prometheus_middleware_cls = RedisPrometheusMiddleware 149 | 150 | broker: typing.Final = RedisBroker() 151 | ( 152 | FastStreamBootstrapper(FastStreamSettings()) 153 | .configure_application(FastStreamConfig(broker=broker)) 154 | .configure_instrument(minimal_faststream_prometheus_config) 155 | .bootstrap() 156 | ) 157 | 158 | def create_test_redis_subscriber( 159 | broker: RedisBroker, 160 | topic: str, 161 | ) -> typing.Callable[[dict[str, str]], typing.Coroutine[typing.Any, typing.Any, None]]: 162 | @broker.subscriber(topic) 163 | async def test_subscriber(payload: dict[str, str]) -> None: 164 | pass 165 | 166 | return test_subscriber 167 | 168 | create_test_redis_subscriber(broker, topic="test-topic") 169 | 170 | async with TestRedisBroker(broker) as tb: 171 | await tb.publish({"foo": "bar"}, "test-topic") 172 | assert check_is_metrics_has_labels(expected_label_keys) 173 | -------------------------------------------------------------------------------- /microbootstrap/instruments/logging_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | import logging.handlers 4 | import sys 5 | import time 6 | import typing 7 | import urllib.parse 8 | 9 | import orjson 10 | import pydantic 11 | import structlog 12 | import typing_extensions 13 | from opentelemetry import trace 14 | 15 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 16 | 17 | 18 | if typing.TYPE_CHECKING: 19 | import fastapi 20 | import litestar 21 | from structlog.typing import EventDict, WrappedLogger 22 | 23 | 24 | ScopeType = typing.MutableMapping[str, typing.Any] 25 | 26 | access_logger: typing.Final = structlog.get_logger("api.access") 27 | 28 | 29 | def make_path_with_query_string(scope: ScopeType) -> str: 30 | path_with_query_string: typing.Final = urllib.parse.quote(scope["path"]) 31 | if scope["query_string"]: 32 | return f"{path_with_query_string}?{scope['query_string'].decode('ascii')}" 33 | return path_with_query_string 34 | 35 | 36 | def fill_log_message( 37 | log_level: str, 38 | request: litestar.Request[typing.Any, typing.Any, typing.Any] | fastapi.Request, 39 | status_code: int, 40 | start_time: int, 41 | ) -> None: 42 | process_time: typing.Final = time.perf_counter_ns() - start_time 43 | url_with_query: typing.Final = make_path_with_query_string(typing.cast("ScopeType", request.scope)) 44 | client_host: typing.Final = request.client.host if request.client is not None else None 45 | client_port: typing.Final = request.client.port if request.client is not None else None 46 | http_method: typing.Final = request.method 47 | http_version: typing.Final = request.scope["http_version"] 48 | log_on_correct_level: typing.Final = getattr(access_logger, log_level) 49 | log_on_correct_level( 50 | f"{http_method} {url_with_query}", 51 | http={ 52 | "url": url_with_query, 53 | "status_code": status_code, 54 | "method": http_method, 55 | "version": http_version, 56 | }, 57 | network={"client": {"ip": client_host, "port": client_port}}, 58 | duration=process_time, 59 | ) 60 | 61 | 62 | def tracer_injection(_: WrappedLogger, __: str, event_dict: EventDict) -> EventDict: 63 | current_span = trace.get_current_span() 64 | if not current_span.is_recording(): 65 | event_dict["tracing"] = {} 66 | return event_dict 67 | 68 | current_span_context = current_span.get_span_context() 69 | event_dict["tracing"] = { 70 | "span_id": trace.format_span_id(current_span_context.span_id), 71 | "trace_id": trace.format_trace_id(current_span_context.trace_id), 72 | } 73 | return event_dict 74 | 75 | 76 | STRUCTLOG_PRE_CHAIN_PROCESSORS: typing.Final[list[typing.Any]] = [ 77 | structlog.stdlib.add_log_level, 78 | structlog.stdlib.add_logger_name, 79 | tracer_injection, 80 | structlog.stdlib.PositionalArgumentsFormatter(), 81 | structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"), 82 | structlog.processors.StackInfoRenderer(), 83 | structlog.processors.format_exc_info, 84 | structlog.processors.UnicodeDecoder(), 85 | ] 86 | 87 | 88 | def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 89 | return orjson.dumps(value, **kwargs).decode() 90 | 91 | 92 | STRUCTLOG_FORMATTER_PROCESSOR: typing.Final = structlog.processors.JSONRenderer( 93 | serializer=_serialize_log_with_orjson_to_string 94 | ) 95 | 96 | 97 | class MemoryLoggerFactory(structlog.stdlib.LoggerFactory): 98 | def __init__( 99 | self, 100 | *args: typing.Any, # noqa: ANN401 101 | logging_buffer_capacity: int, 102 | logging_flush_level: int, 103 | logging_log_level: int, 104 | log_stream: typing.Any = sys.stdout, # noqa: ANN401 105 | **kwargs: typing.Any, # noqa: ANN401 106 | ) -> None: 107 | super().__init__(*args, **kwargs) 108 | self.logging_buffer_capacity = logging_buffer_capacity 109 | self.logging_flush_level = logging_flush_level 110 | self.logging_log_level = logging_log_level 111 | self.log_stream = log_stream 112 | 113 | def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 114 | logger: typing.Final = super().__call__(*args) 115 | stream_handler: typing.Final = logging.StreamHandler(stream=self.log_stream) 116 | handler: typing.Final = logging.handlers.MemoryHandler( 117 | capacity=self.logging_buffer_capacity, 118 | flushLevel=self.logging_flush_level, 119 | target=stream_handler, 120 | ) 121 | logger.addHandler(handler) 122 | logger.setLevel(self.logging_log_level) 123 | logger.propagate = False 124 | return logger 125 | 126 | 127 | class LoggingConfig(BaseInstrumentConfig): 128 | service_debug: bool = True 129 | 130 | logging_log_level: int = logging.INFO 131 | logging_flush_level: int = logging.ERROR 132 | logging_buffer_capacity: int = 10 133 | logging_extra_processors: list[typing.Any] = pydantic.Field(default_factory=list) 134 | logging_unset_handlers: list[str] = pydantic.Field( 135 | default_factory=lambda: ["uvicorn", "uvicorn.access"], 136 | ) 137 | logging_exclude_endpoints: list[str] = pydantic.Field(default_factory=lambda: ["/health/", "/metrics"]) 138 | logging_turn_off_middleware: bool = False 139 | 140 | @pydantic.model_validator(mode="after") 141 | def remove_trailing_slashes_from_logging_exclude_endpoints(self) -> typing_extensions.Self: 142 | self.logging_exclude_endpoints = [ 143 | one_endpoint.removesuffix("/") for one_endpoint in self.logging_exclude_endpoints 144 | ] 145 | return self 146 | 147 | 148 | class LoggingInstrument(Instrument[LoggingConfig]): 149 | instrument_name = "Logging" 150 | ready_condition = "Always ready" 151 | 152 | def is_ready(self) -> bool: 153 | return True 154 | 155 | def teardown(self) -> None: 156 | structlog.reset_defaults() 157 | 158 | def _unset_handlers(self) -> None: 159 | for unset_handlers_logger in self.instrument_config.logging_unset_handlers: 160 | logging.getLogger(unset_handlers_logger).handlers = [] 161 | 162 | def _configure_structlog_loggers(self) -> None: 163 | if self.instrument_config.service_debug: 164 | return 165 | structlog.configure( 166 | processors=[ 167 | structlog.stdlib.filter_by_level, 168 | *STRUCTLOG_PRE_CHAIN_PROCESSORS, 169 | *self.instrument_config.logging_extra_processors, 170 | STRUCTLOG_FORMATTER_PROCESSOR, 171 | ], 172 | context_class=dict, 173 | logger_factory=MemoryLoggerFactory( 174 | logging_buffer_capacity=self.instrument_config.logging_buffer_capacity, 175 | logging_flush_level=self.instrument_config.logging_flush_level, 176 | logging_log_level=self.instrument_config.logging_log_level, 177 | ), 178 | wrapper_class=structlog.stdlib.BoundLogger, 179 | cache_logger_on_first_use=True, 180 | ) 181 | 182 | def _configure_foreign_loggers(self) -> None: 183 | root_logger: typing.Final = logging.getLogger() 184 | stream_handler: typing.Final = logging.StreamHandler(sys.stdout) 185 | stream_handler.setFormatter( 186 | structlog.stdlib.ProcessorFormatter( 187 | foreign_pre_chain=structlog.get_config()["processors"][:-1], 188 | processors=[ 189 | structlog.stdlib.ProcessorFormatter.remove_processors_meta, 190 | structlog.get_config()["processors"][-1], 191 | ], 192 | logger=root_logger, 193 | ) 194 | if self.instrument_config.service_debug 195 | else structlog.stdlib.ProcessorFormatter( 196 | foreign_pre_chain=STRUCTLOG_PRE_CHAIN_PROCESSORS, 197 | processors=[ 198 | structlog.stdlib.ProcessorFormatter.remove_processors_meta, 199 | STRUCTLOG_FORMATTER_PROCESSOR, 200 | ], 201 | logger=root_logger, 202 | ) 203 | ) 204 | root_logger.addHandler(stream_handler) 205 | root_logger.setLevel(self.instrument_config.logging_log_level) 206 | 207 | def bootstrap(self) -> None: 208 | self._unset_handlers() 209 | self._configure_structlog_loggers() 210 | self._configure_foreign_loggers() 211 | 212 | @classmethod 213 | def get_config_type(cls) -> type[LoggingConfig]: 214 | return LoggingConfig 215 | -------------------------------------------------------------------------------- /tests/instruments/test_swagger.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import fastapi 4 | import litestar 5 | from fastapi.testclient import TestClient as FastAPITestClient 6 | from litestar import openapi, status_codes 7 | from litestar.openapi import spec as litestar_openapi 8 | from litestar.openapi.plugins import ScalarRenderPlugin 9 | from litestar.static_files import StaticFilesConfig 10 | from litestar.testing import TestClient as LitestarTestClient 11 | 12 | from microbootstrap.bootstrappers.fastapi import FastApiSwaggerInstrument 13 | from microbootstrap.bootstrappers.litestar import LitestarSwaggerInstrument 14 | from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument 15 | 16 | 17 | def test_swagger_is_ready(minimal_swagger_config: SwaggerConfig) -> None: 18 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config) 19 | assert swagger_instrument.is_ready() 20 | 21 | 22 | def test_swagger_bootstrap_is_not_ready(minimal_swagger_config: SwaggerConfig) -> None: 23 | minimal_swagger_config.swagger_path = "" 24 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config) 25 | assert not swagger_instrument.is_ready() 26 | 27 | 28 | def test_swagger_bootstrap_after( 29 | default_litestar_app: litestar.Litestar, 30 | minimal_swagger_config: SwaggerConfig, 31 | ) -> None: 32 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config) 33 | assert swagger_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 34 | 35 | 36 | def test_swagger_teardown( 37 | minimal_swagger_config: SwaggerConfig, 38 | ) -> None: 39 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config) 40 | assert swagger_instrument.teardown() is None # type: ignore[func-returns-value] 41 | 42 | 43 | def test_litestar_swagger_bootstrap_online_docs(minimal_swagger_config: SwaggerConfig) -> None: 44 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) 45 | 46 | swagger_instrument.bootstrap() 47 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before() 48 | assert "openapi_config" in bootstrap_result 49 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig) 50 | assert bootstrap_result["openapi_config"].title == minimal_swagger_config.service_name 51 | assert bootstrap_result["openapi_config"].version == minimal_swagger_config.service_version 52 | assert bootstrap_result["openapi_config"].description == minimal_swagger_config.service_description 53 | assert "static_files_config" not in bootstrap_result 54 | 55 | 56 | def test_litestar_swagger_bootstrap_with_overridden_render_plugins(minimal_swagger_config: SwaggerConfig) -> None: 57 | new_render_plugins: typing.Final = [ScalarRenderPlugin()] 58 | minimal_swagger_config.swagger_extra_params["render_plugins"] = new_render_plugins 59 | 60 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) 61 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before() 62 | 63 | assert "openapi_config" in bootstrap_result 64 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig) 65 | assert bootstrap_result["openapi_config"].render_plugins is new_render_plugins 66 | 67 | 68 | def test_litestar_swagger_bootstrap_extra_params_have_correct_types(minimal_swagger_config: SwaggerConfig) -> None: 69 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) 70 | new_components: typing.Final = litestar_openapi.Components( 71 | security_schemes={"Bearer": litestar_openapi.SecurityScheme(type="http", scheme="Bearer")} 72 | ) 73 | swagger_instrument.configure_instrument( 74 | minimal_swagger_config.model_copy(update={"swagger_extra_params": {"components": new_components}}) 75 | ) 76 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before() 77 | 78 | assert "openapi_config" in bootstrap_result 79 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig) 80 | assert type(bootstrap_result["openapi_config"].components) is litestar_openapi.Components 81 | 82 | 83 | def test_litestar_swagger_bootstrap_offline_docs(minimal_swagger_config: SwaggerConfig) -> None: 84 | minimal_swagger_config.swagger_offline_docs = True 85 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) 86 | 87 | swagger_instrument.bootstrap() 88 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before() 89 | assert "openapi_config" in bootstrap_result 90 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig) 91 | assert bootstrap_result["openapi_config"].title == minimal_swagger_config.service_name 92 | assert bootstrap_result["openapi_config"].version == minimal_swagger_config.service_version 93 | assert bootstrap_result["openapi_config"].description == minimal_swagger_config.service_description 94 | assert "static_files_config" in bootstrap_result 95 | assert isinstance(bootstrap_result["static_files_config"], list) 96 | assert len(bootstrap_result["static_files_config"]) == 1 97 | assert isinstance(bootstrap_result["static_files_config"][0], StaticFilesConfig) 98 | 99 | 100 | def test_litestar_swagger_bootstrap_working_online_docs( 101 | minimal_swagger_config: SwaggerConfig, 102 | ) -> None: 103 | minimal_swagger_config.swagger_path = "/my-docs-path" 104 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) 105 | 106 | swagger_instrument.bootstrap() 107 | litestar_application: typing.Final = litestar.Litestar( 108 | **swagger_instrument.bootstrap_before(), 109 | ) 110 | 111 | with LitestarTestClient(app=litestar_application) as test_client: 112 | response: typing.Final = test_client.get(minimal_swagger_config.swagger_path) 113 | assert response.status_code == status_codes.HTTP_200_OK 114 | 115 | 116 | def test_litestar_swagger_bootstrap_working_offline_docs( 117 | minimal_swagger_config: SwaggerConfig, 118 | ) -> None: 119 | minimal_swagger_config.service_static_path = "/my-static-path" 120 | minimal_swagger_config.swagger_offline_docs = True 121 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) 122 | 123 | swagger_instrument.bootstrap() 124 | litestar_application: typing.Final = litestar.Litestar( 125 | **swagger_instrument.bootstrap_before(), 126 | ) 127 | 128 | with LitestarTestClient(app=litestar_application) as test_client: 129 | response = test_client.get(minimal_swagger_config.swagger_path) 130 | assert response.status_code == status_codes.HTTP_200_OK 131 | response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui.css") 132 | assert response.status_code == status_codes.HTTP_200_OK 133 | 134 | 135 | def test_fastapi_swagger_bootstrap_online_docs(minimal_swagger_config: SwaggerConfig) -> None: 136 | swagger_instrument: typing.Final = FastApiSwaggerInstrument(minimal_swagger_config) 137 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before() 138 | assert bootstrap_result["title"] == minimal_swagger_config.service_name 139 | assert bootstrap_result["description"] == minimal_swagger_config.service_description 140 | assert bootstrap_result["docs_url"] == minimal_swagger_config.swagger_path 141 | assert bootstrap_result["version"] == minimal_swagger_config.service_version 142 | 143 | 144 | def test_fastapi_swagger_bootstrap_working_online_docs( 145 | minimal_swagger_config: SwaggerConfig, 146 | ) -> None: 147 | minimal_swagger_config.swagger_path = "/my-docs-path" 148 | swagger_instrument: typing.Final = FastApiSwaggerInstrument(minimal_swagger_config) 149 | 150 | swagger_instrument.bootstrap() 151 | fastapi_application: typing.Final = fastapi.FastAPI( 152 | **swagger_instrument.bootstrap_before(), 153 | ) 154 | 155 | response: typing.Final = FastAPITestClient(app=fastapi_application).get(minimal_swagger_config.swagger_path) 156 | assert response.status_code == status_codes.HTTP_200_OK 157 | 158 | 159 | def test_fastapi_swagger_bootstrap_working_offline_docs( 160 | minimal_swagger_config: SwaggerConfig, 161 | ) -> None: 162 | minimal_swagger_config.service_static_path = "/my-static-path" 163 | minimal_swagger_config.swagger_offline_docs = True 164 | swagger_instrument: typing.Final = FastApiSwaggerInstrument(minimal_swagger_config) 165 | fastapi_application = fastapi.FastAPI( 166 | **swagger_instrument.bootstrap_before(), 167 | ) 168 | swagger_instrument.bootstrap_after(fastapi_application) 169 | 170 | with FastAPITestClient(app=fastapi_application) as test_client: 171 | response = test_client.get(minimal_swagger_config.swagger_path) 172 | assert response.status_code == status_codes.HTTP_200_OK 173 | response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui.css") 174 | assert response.status_code == status_codes.HTTP_200_OK 175 | -------------------------------------------------------------------------------- /microbootstrap/instruments/opentelemetry_instrument.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import dataclasses 3 | import os 4 | import threading 5 | import typing 6 | 7 | import pydantic 8 | import structlog 9 | from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter 10 | from opentelemetry.instrumentation.dependencies import DependencyConflictError 11 | from opentelemetry.instrumentation.environment_variables import OTEL_PYTHON_DISABLED_INSTRUMENTATIONS 12 | from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] # noqa: TC002 13 | from opentelemetry.sdk import resources 14 | from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor 15 | from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider 16 | from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor 17 | from opentelemetry.semconv.resource import ResourceAttributes 18 | from opentelemetry.trace import format_span_id, set_tracer_provider 19 | from opentelemetry.util._importlib_metadata import entry_points 20 | 21 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument 22 | 23 | 24 | LOGGER_OBJ: typing.Final = structlog.get_logger(__name__) 25 | 26 | 27 | try: 28 | import pyroscope # type: ignore[import-untyped] 29 | except ImportError: # pragma: no cover 30 | pyroscope = None 31 | 32 | 33 | if typing.TYPE_CHECKING: 34 | from opentelemetry.context import Context 35 | from opentelemetry.metrics import Meter, MeterProvider 36 | from opentelemetry.trace import TracerProvider 37 | 38 | 39 | OpentelemetryConfigT = typing.TypeVar("OpentelemetryConfigT", bound="OpentelemetryConfig") 40 | 41 | 42 | @dataclasses.dataclass() 43 | class OpenTelemetryInstrumentor: 44 | instrumentor: BaseInstrumentor 45 | additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) 46 | 47 | 48 | class OpentelemetryConfig(BaseInstrumentConfig): 49 | service_debug: bool = True 50 | service_name: str = "micro-service" 51 | service_version: str = "1.0.0" 52 | health_checks_path: str = "/health/" 53 | pyroscope_endpoint: pydantic.HttpUrl | None = None 54 | 55 | opentelemetry_service_name: str | None = None 56 | opentelemetry_container_name: str | None = pydantic.Field(os.environ.get("HOSTNAME") or None) 57 | opentelemetry_endpoint: str | None = None 58 | opentelemetry_namespace: str | None = None 59 | opentelemetry_insecure: bool = pydantic.Field(default=True) 60 | opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = pydantic.Field(default_factory=list) 61 | opentelemetry_exclude_urls: list[str] = pydantic.Field(default=["/metrics"]) 62 | opentelemetry_disabled_instrumentations: list[str] = pydantic.Field( 63 | default=[ 64 | one_package_to_exclude.strip() 65 | for one_package_to_exclude in os.environ.get(OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, "").split(",") 66 | ], 67 | ) 68 | opentelemetry_log_traces: bool = False 69 | opentelemetry_generate_health_check_spans: bool = True 70 | 71 | 72 | @typing.runtime_checkable 73 | class FastStreamTelemetryMiddlewareProtocol(typing.Protocol): 74 | def __init__( 75 | self, 76 | *, 77 | tracer_provider: TracerProvider | None = None, 78 | meter_provider: MeterProvider | None = None, 79 | meter: Meter | None = None, 80 | ) -> None: ... 81 | 82 | def __call__( 83 | self, 84 | msg: typing.Any, # noqa: ANN401 85 | /, 86 | *, 87 | context: typing.Any, # noqa: ANN401 88 | ) -> typing.Any: ... # noqa: ANN401 89 | 90 | 91 | class FastStreamOpentelemetryConfig(OpentelemetryConfig): 92 | opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None 93 | 94 | 95 | def _format_span(readable_span: ReadableSpan) -> str: 96 | return typing.cast("str", readable_span.to_json(indent=None)) + os.linesep 97 | 98 | 99 | class BaseOpentelemetryInstrument(Instrument[OpentelemetryConfigT]): 100 | instrument_name = "Opentelemetry" 101 | ready_condition = "Provide all necessary config parameters" 102 | 103 | def _load_instrumentors(self) -> None: 104 | for entry_point in entry_points(group="opentelemetry_instrumentor"): # type: ignore[no-untyped-call] 105 | if entry_point.name in self.instrument_config.opentelemetry_disabled_instrumentations: 106 | continue 107 | 108 | try: 109 | entry_point.load()().instrument(tracer_provider=self.tracer_provider) 110 | except DependencyConflictError as exc: 111 | LOGGER_OBJ.debug("Skipping instrumentation", entry_point_name=entry_point.name, reason=exc.conflict) 112 | continue 113 | except ModuleNotFoundError: 114 | continue 115 | except ImportError: 116 | LOGGER_OBJ.debug("Importing failed, skipping it", entry_point_name=entry_point.name) 117 | continue 118 | except Exception: 119 | LOGGER_OBJ.debug("Instrumenting failed", entry_point_name=entry_point.name) 120 | raise 121 | 122 | def is_ready(self) -> bool: 123 | return ( 124 | bool(self.instrument_config.opentelemetry_endpoint) 125 | or self.instrument_config.service_debug 126 | or self.instrument_config.opentelemetry_log_traces 127 | ) 128 | 129 | def teardown(self) -> None: 130 | for instrumentor_with_params in self.instrument_config.opentelemetry_instrumentors: 131 | instrumentor_with_params.instrumentor.uninstrument(**instrumentor_with_params.additional_params) 132 | 133 | def bootstrap(self) -> None: 134 | attributes = { 135 | ResourceAttributes.SERVICE_NAME: self.instrument_config.opentelemetry_service_name 136 | or self.instrument_config.service_name, 137 | ResourceAttributes.TELEMETRY_SDK_LANGUAGE: "python", 138 | ResourceAttributes.SERVICE_VERSION: self.instrument_config.service_version, 139 | } 140 | if self.instrument_config.opentelemetry_namespace: 141 | attributes[ResourceAttributes.SERVICE_NAMESPACE] = self.instrument_config.opentelemetry_namespace 142 | if self.instrument_config.opentelemetry_container_name: 143 | attributes[ResourceAttributes.CONTAINER_NAME] = self.instrument_config.opentelemetry_container_name 144 | resource: typing.Final = resources.Resource.create(attributes=attributes) 145 | 146 | self.tracer_provider = SdkTracerProvider(resource=resource) 147 | if self.instrument_config.pyroscope_endpoint and pyroscope: 148 | self.tracer_provider.add_span_processor(PyroscopeSpanProcessor()) 149 | 150 | if self.instrument_config.opentelemetry_log_traces: 151 | self.tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span))) 152 | if self.instrument_config.opentelemetry_endpoint: 153 | self.tracer_provider.add_span_processor( 154 | BatchSpanProcessor( 155 | OTLPSpanExporter( 156 | endpoint=self.instrument_config.opentelemetry_endpoint, 157 | insecure=self.instrument_config.opentelemetry_insecure, 158 | ), 159 | ), 160 | ) 161 | for opentelemetry_instrumentor in self.instrument_config.opentelemetry_instrumentors: 162 | opentelemetry_instrumentor.instrumentor.instrument( 163 | tracer_provider=self.tracer_provider, 164 | **opentelemetry_instrumentor.additional_params, 165 | ) 166 | self._load_instrumentors() 167 | set_tracer_provider(self.tracer_provider) 168 | 169 | 170 | class OpentelemetryInstrument(BaseOpentelemetryInstrument[OpentelemetryConfig]): 171 | def define_exclude_urls(self) -> list[str]: 172 | exclude_urls = [*self.instrument_config.opentelemetry_exclude_urls] 173 | if ( 174 | not self.instrument_config.opentelemetry_generate_health_check_spans 175 | and self.instrument_config.health_checks_path 176 | and self.instrument_config.health_checks_path not in exclude_urls 177 | ): 178 | exclude_urls.append(self.instrument_config.health_checks_path) 179 | return exclude_urls 180 | 181 | @classmethod 182 | def get_config_type(cls) -> type[OpentelemetryConfig]: 183 | return OpentelemetryConfig 184 | 185 | 186 | OTEL_PROFILE_ID_KEY: typing.Final = "pyroscope.profile.id" 187 | PYROSCOPE_SPAN_ID_KEY: typing.Final = "span_id" 188 | PYROSCOPE_SPAN_NAME_KEY: typing.Final = "span_name" 189 | 190 | 191 | def _is_root_span(span: ReadableSpan) -> bool: 192 | return span.parent is None or span.parent.is_remote 193 | 194 | 195 | # Extended `pyroscope-otel` span processor: https://github.com/grafana/otel-profiling-python/blob/990662d416943e992ab70036b35b27488c98336a/src/pyroscope/otel/__init__.py 196 | # Includes `span_name` to identify if it makes sense to go to profiles from traces. 197 | class PyroscopeSpanProcessor(SpanProcessor): 198 | def on_start(self, span: Span, parent_context: Context | None = None) -> None: # noqa: ARG002 199 | if _is_root_span(span): 200 | formatted_span_id = format_span_id(span.context.span_id) 201 | thread_id = threading.get_ident() 202 | 203 | span.set_attribute(OTEL_PROFILE_ID_KEY, formatted_span_id) 204 | pyroscope.add_thread_tag(thread_id, PYROSCOPE_SPAN_ID_KEY, formatted_span_id) 205 | pyroscope.add_thread_tag(thread_id, PYROSCOPE_SPAN_NAME_KEY, span.name) 206 | 207 | def on_end(self, span: ReadableSpan) -> None: 208 | if _is_root_span(span): 209 | thread_id = threading.get_ident() 210 | pyroscope.remove_thread_tag(thread_id, PYROSCOPE_SPAN_ID_KEY, format_span_id(span.context.span_id)) 211 | pyroscope.remove_thread_tag(thread_id, PYROSCOPE_SPAN_NAME_KEY, span.name) 212 | 213 | def force_flush(self, timeout_millis: int = 30000) -> bool: # noqa: ARG002 # pragma: no cover 214 | return True 215 | -------------------------------------------------------------------------------- /microbootstrap/bootstrappers/litestar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import typing 3 | 4 | import litestar 5 | import litestar.exceptions 6 | import litestar.types 7 | import typing_extensions 8 | from litestar import openapi 9 | from litestar.config.cors import CORSConfig as LitestarCorsConfig 10 | from litestar.contrib.opentelemetry.config import ( 11 | OpenTelemetryConfig as LitestarOpentelemetryConfig, 12 | ) 13 | from litestar.contrib.opentelemetry.middleware import ( 14 | OpenTelemetryInstrumentationMiddleware, 15 | ) 16 | from litestar.contrib.prometheus import PrometheusConfig, PrometheusController 17 | from litestar.openapi.plugins import SwaggerRenderPlugin 18 | from litestar_offline_docs import generate_static_files_config 19 | from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware 20 | from opentelemetry.util.http import get_excluded_urls 21 | from sentry_sdk.integrations.litestar import LitestarIntegration 22 | 23 | from microbootstrap.bootstrappers.base import ApplicationBootstrapper 24 | from microbootstrap.config.litestar import LitestarConfig 25 | from microbootstrap.instruments.cors_instrument import CorsInstrument 26 | from microbootstrap.instruments.health_checks_instrument import ( 27 | HealthChecksInstrument, 28 | HealthCheckTypedDict, 29 | ) 30 | from microbootstrap.instruments.logging_instrument import LoggingInstrument 31 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument 32 | from microbootstrap.instruments.prometheus_instrument import ( 33 | LitestarPrometheusConfig, 34 | PrometheusInstrument, 35 | ) 36 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument 37 | from microbootstrap.instruments.sentry_instrument import SentryInstrument 38 | from microbootstrap.instruments.swagger_instrument import SwaggerInstrument 39 | from microbootstrap.middlewares.litestar import build_litestar_logging_middleware 40 | from microbootstrap.settings import LitestarSettings 41 | 42 | 43 | if typing.TYPE_CHECKING: 44 | from litestar.contrib.opentelemetry import OpenTelemetryConfig 45 | from litestar.types import ASGIApp, Scope 46 | 47 | 48 | class LitestarBootstrapper( 49 | ApplicationBootstrapper[LitestarSettings, litestar.Litestar, LitestarConfig], 50 | ): 51 | application_config = LitestarConfig() 52 | application_type = litestar.Litestar 53 | 54 | def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]: 55 | return { 56 | "debug": self.settings.service_debug, 57 | "on_shutdown": [self.teardown], 58 | "on_startup": [self.console_writer.print_bootstrap_table], 59 | } 60 | 61 | 62 | @LitestarBootstrapper.use_instrument() 63 | class LitestarSentryInstrument(SentryInstrument): 64 | def bootstrap(self) -> None: 65 | for sentry_integration in self.instrument_config.sentry_integrations: 66 | if isinstance(sentry_integration, LitestarIntegration): 67 | break 68 | else: 69 | self.instrument_config.sentry_integrations.append(LitestarIntegration()) 70 | super().bootstrap() 71 | 72 | 73 | @LitestarBootstrapper.use_instrument() 74 | class LitestarSwaggerInstrument(SwaggerInstrument): 75 | def bootstrap_before(self) -> dict[str, typing.Any]: 76 | render_plugins: typing.Final = ( 77 | ( 78 | SwaggerRenderPlugin( 79 | js_url=f"{self.instrument_config.service_static_path}/swagger-ui-bundle.js", 80 | css_url=f"{self.instrument_config.service_static_path}/swagger-ui.css", 81 | standalone_preset_js_url=( 82 | f"{self.instrument_config.service_static_path}/swagger-ui-standalone-preset.js" 83 | ), 84 | ), 85 | ) 86 | if self.instrument_config.swagger_offline_docs 87 | else (SwaggerRenderPlugin(),) 88 | ) 89 | 90 | all_swagger_params: typing.Final = { 91 | "path": self.instrument_config.swagger_path, 92 | "title": self.instrument_config.service_name, 93 | "version": self.instrument_config.service_version, 94 | "description": self.instrument_config.service_description, 95 | "render_plugins": render_plugins, 96 | } | self.instrument_config.swagger_extra_params 97 | 98 | bootstrap_result: typing.Final[dict[str, typing.Any]] = { 99 | "openapi_config": openapi.OpenAPIConfig(**all_swagger_params), 100 | } 101 | if self.instrument_config.swagger_offline_docs: 102 | bootstrap_result["static_files_config"] = [ 103 | generate_static_files_config(static_files_handler_path=self.instrument_config.service_static_path), 104 | ] 105 | return bootstrap_result 106 | 107 | 108 | @LitestarBootstrapper.use_instrument() 109 | class LitestarCorsInstrument(CorsInstrument): 110 | def bootstrap_before(self) -> dict[str, typing.Any]: 111 | return { 112 | "cors_config": LitestarCorsConfig( 113 | allow_origins=self.instrument_config.cors_allowed_origins, 114 | allow_methods=self.instrument_config.cors_allowed_methods, # type: ignore[arg-type] 115 | allow_headers=self.instrument_config.cors_allowed_headers, 116 | allow_credentials=self.instrument_config.cors_allowed_credentials, 117 | allow_origin_regex=self.instrument_config.cors_allowed_origin_regex, 118 | expose_headers=self.instrument_config.cors_exposed_headers, 119 | max_age=self.instrument_config.cors_max_age, 120 | ), 121 | } 122 | 123 | 124 | LitestarBootstrapper.use_instrument()(PyroscopeInstrument) 125 | 126 | 127 | def build_span_name(method: str, route: str) -> str: 128 | if not route: 129 | return method 130 | return f"{method} {route}" 131 | 132 | 133 | def build_litestar_route_details_from_scope( 134 | scope: Scope, 135 | ) -> tuple[str, dict[str, str]]: 136 | """Retrieve the span name and attributes from the ASGI scope for Litestar routes. 137 | 138 | Args: 139 | scope: The ASGI scope instance. 140 | 141 | Returns: 142 | A tuple of the span name and a dict of attrs. 143 | 144 | """ 145 | path_template: typing.Final = scope.get("path_template") 146 | method: typing.Final = str(scope.get("method", "HTTP")).strip() 147 | if path_template is not None: 148 | path_template_stripped: typing.Final = path_template.strip() 149 | return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped} 150 | 151 | path: typing.Final = scope.get("path") 152 | if path is not None: 153 | path_stripped: typing.Final = path.strip() 154 | return build_span_name(method, path_stripped), {"http.route": path_stripped} 155 | return method, {} 156 | 157 | 158 | class LitestarOpenTelemetryInstrumentationMiddleware(OpenTelemetryInstrumentationMiddleware): 159 | def __init__(self, app: ASGIApp, config: OpenTelemetryConfig) -> None: 160 | super().__init__( 161 | app=app, 162 | config=config, 163 | ) 164 | self.open_telemetry_middleware = OpenTelemetryMiddleware( 165 | app=app, 166 | client_request_hook=config.client_request_hook_handler, # type: ignore[arg-type] 167 | client_response_hook=config.client_response_hook_handler, # type: ignore[arg-type] 168 | default_span_details=build_litestar_route_details_from_scope, 169 | excluded_urls=get_excluded_urls(config.exclude_urls_env_key), 170 | meter=config.meter, 171 | meter_provider=config.meter_provider, 172 | server_request_hook=config.server_request_hook_handler, 173 | tracer_provider=config.tracer_provider, 174 | ) 175 | 176 | 177 | @LitestarBootstrapper.use_instrument() 178 | class LitestarOpentelemetryInstrument(OpentelemetryInstrument): 179 | def bootstrap_before(self) -> dict[str, typing.Any]: 180 | return { 181 | "middleware": [ 182 | LitestarOpentelemetryConfig( 183 | tracer_provider=self.tracer_provider, 184 | middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, 185 | ).middleware, 186 | ] 187 | } 188 | 189 | 190 | @LitestarBootstrapper.use_instrument() 191 | class LitestarLoggingInstrument(LoggingInstrument): 192 | def bootstrap_before(self) -> dict[str, typing.Any]: 193 | if self.instrument_config.logging_turn_off_middleware: 194 | return {} 195 | 196 | return {"middleware": [build_litestar_logging_middleware(self.instrument_config.logging_exclude_endpoints)]} 197 | 198 | 199 | @LitestarBootstrapper.use_instrument() 200 | class LitestarPrometheusInstrument(PrometheusInstrument[LitestarPrometheusConfig]): 201 | def bootstrap_before(self) -> dict[str, typing.Any]: 202 | class LitestarPrometheusController(PrometheusController): 203 | path = self.instrument_config.prometheus_metrics_path 204 | include_in_schema = self.instrument_config.prometheus_metrics_include_in_schema 205 | openmetrics_format = True 206 | 207 | litestar_prometheus_config: typing.Final = PrometheusConfig( 208 | app_name=self.instrument_config.service_name, 209 | **self.instrument_config.prometheus_additional_params, 210 | ) 211 | 212 | return { 213 | "route_handlers": [LitestarPrometheusController], 214 | "middleware": [litestar_prometheus_config.middleware], 215 | } 216 | 217 | @classmethod 218 | def get_config_type(cls) -> type[LitestarPrometheusConfig]: 219 | return LitestarPrometheusConfig 220 | 221 | 222 | @LitestarBootstrapper.use_instrument() 223 | class LitestarHealthChecksInstrument(HealthChecksInstrument): 224 | def build_litestar_health_check_router(self) -> litestar.Router: 225 | @litestar.get(media_type=litestar.MediaType.JSON) 226 | async def health_check_handler() -> HealthCheckTypedDict: 227 | return self.render_health_check_data() 228 | 229 | return litestar.Router( 230 | path=self.instrument_config.health_checks_path, 231 | route_handlers=[health_check_handler], 232 | tags=["probes"], 233 | include_in_schema=self.instrument_config.health_checks_include_in_schema, 234 | ) 235 | 236 | def bootstrap_before(self) -> dict[str, typing.Any]: 237 | return {"route_handlers": [self.build_litestar_health_check_router()]} 238 | -------------------------------------------------------------------------------- /tests/instruments/test_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing 3 | from io import StringIO 4 | from unittest import mock 5 | 6 | import fastapi 7 | import litestar 8 | import pytest 9 | from fastapi.testclient import TestClient as FastAPITestClient 10 | from faststream.redis import RedisBroker, TestRedisBroker 11 | from litestar.testing import TestClient as LitestarTestClient 12 | from opentelemetry import trace 13 | from opentelemetry.sdk.trace import TracerProvider 14 | from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor 15 | 16 | from microbootstrap import LoggingConfig 17 | from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper, FastApiLoggingInstrument 18 | from microbootstrap.bootstrappers.faststream import FastStreamBootstrapper 19 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper, LitestarLoggingInstrument 20 | from microbootstrap.config.faststream import FastStreamConfig 21 | from microbootstrap.config.litestar import LitestarConfig 22 | from microbootstrap.instruments.logging_instrument import LoggingInstrument, MemoryLoggerFactory 23 | from microbootstrap.settings import FastApiSettings, FastStreamSettings, LitestarSettings 24 | 25 | 26 | def test_logging_is_ready(minimal_logging_config: LoggingConfig) -> None: 27 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config) 28 | assert logging_instrument.is_ready() 29 | 30 | 31 | def test_logging_bootstrap_is_not_ready(minimal_logging_config: LoggingConfig) -> None: 32 | minimal_logging_config.service_debug = True 33 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config) 34 | assert logging_instrument.bootstrap_before() == {} 35 | 36 | 37 | def test_logging_bootstrap_after( 38 | default_litestar_app: litestar.Litestar, 39 | minimal_logging_config: LoggingConfig, 40 | ) -> None: 41 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config) 42 | assert logging_instrument.bootstrap_after(default_litestar_app) == default_litestar_app 43 | 44 | 45 | def test_logging_teardown( 46 | minimal_logging_config: LoggingConfig, 47 | ) -> None: 48 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config) 49 | assert logging_instrument.teardown() is None # type: ignore[func-returns-value] 50 | 51 | 52 | def test_litestar_logging_bootstrap(minimal_logging_config: LoggingConfig) -> None: 53 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config) 54 | logging_instrument.bootstrap() 55 | bootstrap_result: typing.Final = logging_instrument.bootstrap_before() 56 | assert "middleware" in bootstrap_result 57 | assert isinstance(bootstrap_result["middleware"], list) 58 | assert len(bootstrap_result["middleware"]) == 1 59 | 60 | 61 | def test_litestar_logging_bootstrap_working( 62 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig 63 | ) -> None: 64 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config) 65 | 66 | @litestar.get("/test-handler") 67 | async def error_handler() -> str: 68 | return "Ok" 69 | 70 | logging_instrument.bootstrap() 71 | litestar_application: typing.Final = litestar.Litestar( 72 | route_handlers=[error_handler], 73 | **logging_instrument.bootstrap_before(), 74 | ) 75 | monkeypatch.setattr("microbootstrap.middlewares.litestar.fill_log_message", fill_log_mock := mock.Mock()) 76 | 77 | with LitestarTestClient(app=litestar_application) as test_client: 78 | test_client.get("/test-handler?test-query=1") 79 | test_client.get("/test-handler") 80 | 81 | assert fill_log_mock.call_count == 2 # noqa: PLR2004 82 | 83 | 84 | def test_litestar_logging_bootstrap_ignores_health( 85 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig 86 | ) -> None: 87 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config) 88 | logging_instrument.bootstrap() 89 | litestar_application: typing.Final = litestar.Litestar(**logging_instrument.bootstrap_before()) 90 | monkeypatch.setattr("microbootstrap.middlewares.litestar.fill_log_message", fill_log_mock := mock.Mock()) 91 | 92 | with LitestarTestClient(app=litestar_application) as test_client: 93 | test_client.get("/health") 94 | 95 | assert fill_log_mock.call_count == 0 96 | 97 | 98 | def test_litestar_logging_bootstrap_tracer_injection(minimal_logging_config: LoggingConfig) -> None: 99 | trace.set_tracer_provider(TracerProvider()) 100 | tracer = trace.get_tracer(__name__) 101 | span_processor = SimpleSpanProcessor(ConsoleSpanExporter()) 102 | trace.get_tracer_provider().add_span_processor(span_processor) # type: ignore[attr-defined] 103 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config) 104 | 105 | @litestar.get("/test-handler") 106 | async def test_handler() -> str: 107 | return "Ok" 108 | 109 | logging_instrument.bootstrap() 110 | litestar_application: typing.Final = litestar.Litestar( 111 | route_handlers=[test_handler], 112 | **logging_instrument.bootstrap_before(), 113 | ) 114 | with tracer.start_as_current_span("my_fake_span") as span: 115 | # Do some fake work inside the span 116 | span.set_attribute("example_attribute", "value") 117 | span.add_event("example_event", {"event_attr": 1}) 118 | with LitestarTestClient(app=litestar_application) as test_client: 119 | test_client.get("/test-handler") 120 | 121 | 122 | def test_memory_logger_factory_info() -> None: 123 | test_capacity: typing.Final = 10 124 | test_flush_level: typing.Final = logging.ERROR 125 | test_stream: typing.Final = StringIO() 126 | 127 | logger_factory: typing.Final = MemoryLoggerFactory( 128 | logging_buffer_capacity=test_capacity, 129 | logging_flush_level=test_flush_level, 130 | logging_log_level=logging.INFO, 131 | log_stream=test_stream, 132 | ) 133 | test_logger: typing.Final = logger_factory() 134 | test_message: typing.Final = "test message" 135 | 136 | for current_log_index in range(test_capacity): 137 | test_logger.info(test_message) 138 | log_contents = test_stream.getvalue() 139 | if current_log_index == test_capacity - 1: 140 | assert test_message in log_contents 141 | else: 142 | assert not log_contents 143 | 144 | 145 | def test_memory_logger_factory_error() -> None: 146 | test_capacity: typing.Final = 10 147 | test_flush_level: typing.Final = logging.ERROR 148 | test_stream: typing.Final = StringIO() 149 | 150 | logger_factory: typing.Final = MemoryLoggerFactory( 151 | logging_buffer_capacity=test_capacity, 152 | logging_flush_level=test_flush_level, 153 | logging_log_level=logging.INFO, 154 | log_stream=test_stream, 155 | ) 156 | test_logger: typing.Final = logger_factory() 157 | error_message: typing.Final = "error message" 158 | test_logger.error(error_message) 159 | assert error_message in test_stream.getvalue() 160 | 161 | 162 | def test_fastapi_logging_bootstrap_working( 163 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig 164 | ) -> None: 165 | fastapi_application: typing.Final = fastapi.FastAPI() 166 | 167 | @fastapi_application.get("/test-handler") 168 | async def test_handler() -> str: 169 | return "Ok" 170 | 171 | logging_instrument: typing.Final = FastApiLoggingInstrument(minimal_logging_config) 172 | logging_instrument.bootstrap() 173 | logging_instrument.bootstrap_after(fastapi_application) 174 | monkeypatch.setattr("microbootstrap.middlewares.fastapi.fill_log_message", fill_log_mock := mock.Mock()) 175 | 176 | with FastAPITestClient(app=fastapi_application) as test_client: 177 | test_client.get("/test-handler?test-query=1") 178 | test_client.get("/test-handler") 179 | 180 | assert fill_log_mock.call_count == 2 # noqa: PLR2004 181 | 182 | 183 | def test_fastapi_logging_bootstrap_ignores_health( 184 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig 185 | ) -> None: 186 | fastapi_application: typing.Final = fastapi.FastAPI() 187 | logging_instrument: typing.Final = FastApiLoggingInstrument(minimal_logging_config) 188 | logging_instrument.bootstrap() 189 | logging_instrument.bootstrap_after(fastapi_application) 190 | monkeypatch.setattr("microbootstrap.middlewares.fastapi.fill_log_message", fill_log_mock := mock.Mock()) 191 | 192 | with FastAPITestClient(app=fastapi_application) as test_client: 193 | test_client.get("/health") 194 | 195 | assert fill_log_mock.call_count == 0 196 | 197 | 198 | class TestForeignLogs: 199 | def test_litestar(self, capsys: pytest.CaptureFixture[str]) -> None: 200 | logger = logging.getLogger() 201 | 202 | @litestar.get() 203 | async def greet() -> str: 204 | logger.info("said hi") 205 | return "hi" 206 | 207 | application = ( 208 | LitestarBootstrapper(LitestarSettings(service_debug=False, logging_buffer_capacity=0)) 209 | .configure_application(LitestarConfig(route_handlers=[greet])) 210 | .bootstrap() 211 | ) 212 | with LitestarTestClient(application) as test_client: 213 | test_client.get("/") 214 | 215 | stdout = capsys.readouterr().out 216 | assert '{"event":"said hi","level":"info","logger":"root"' in stdout 217 | assert stdout.count("said hi") == 1 218 | 219 | def test_fastapi(self, capsys: pytest.CaptureFixture[str]) -> None: 220 | logger = logging.getLogger() 221 | application = FastApiBootstrapper(FastApiSettings(service_debug=False, logging_buffer_capacity=0)).bootstrap() 222 | 223 | @application.get("/") 224 | async def greet() -> str: 225 | logger.info("said hi") 226 | return "hi" 227 | 228 | with FastAPITestClient(application) as test_client: 229 | test_client.get("/") 230 | 231 | stdout = capsys.readouterr().out 232 | assert '{"event":"said hi","level":"info","logger":"root"' in stdout 233 | assert stdout.count("said hi") == 1 234 | 235 | async def test_faststream(self, capsys: pytest.CaptureFixture[str]) -> None: 236 | logger = logging.getLogger() 237 | broker = RedisBroker() 238 | 239 | @broker.subscriber("greetings") 240 | async def greet() -> None: 241 | logger.info("said hi") 242 | 243 | ( 244 | FastStreamBootstrapper(FastStreamSettings(service_debug=False, logging_buffer_capacity=0)) 245 | .configure_application(FastStreamConfig(broker=broker)) 246 | .bootstrap() 247 | ) 248 | 249 | async with TestRedisBroker(broker): 250 | await broker.publish(message="hello", channel="greetings") 251 | 252 | stdout = capsys.readouterr().out 253 | assert '{"event":"said hi","level":"info","logger":"root"' in stdout 254 | assert stdout.count("said hi") == 1 255 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Pixso. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | --------------------------------------------------------------------------------