diff --git a/lite_bootstrap/__init__.py b/lite_bootstrap/__init__.py index 4c16e26..26476e9 100644 --- a/lite_bootstrap/__init__.py +++ b/lite_bootstrap/__init__.py @@ -3,6 +3,7 @@ FastAPIHealthChecksInstrument, FastAPILoggingInstrument, FastAPIOpenTelemetryInstrument, + FastAPIPrometheusInstrument, FastAPISentryInstrument, ) from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper @@ -11,6 +12,7 @@ LitestarHealthChecksInstrument, LitestarLoggingInstrument, LitestarOpenTelemetryInstrument, + LitestarPrometheusInstrument, LitestarSentryInstrument, ) from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksInstrument @@ -25,6 +27,7 @@ "FastAPIHealthChecksInstrument", "FastAPILoggingInstrument", "FastAPIOpenTelemetryInstrument", + "FastAPIPrometheusInstrument", "FastAPISentryInstrument", "FreeBootstrapper", "HealthChecksInstrument", @@ -32,6 +35,7 @@ "LitestarHealthChecksInstrument", "LitestarLoggingInstrument", "LitestarOpenTelemetryInstrument", + "LitestarPrometheusInstrument", "LitestarSentryInstrument", "LoggingInstrument", "OpenTelemetryInstrument", diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index cfc15f8..213a072 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -2,10 +2,13 @@ import dataclasses import typing +from prometheus_fastapi_instrumentator import Instrumentator + from lite_bootstrap.bootstrappers.base import BaseBootstrapper from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksInstrument, HealthCheckTypedDict from lite_bootstrap.instruments.logging_instrument import LoggingInstrument from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument +from lite_bootstrap.instruments.prometheus_instrument import PrometheusInstrument from lite_bootstrap.instruments.sentry_instrument import SentryInstrument from lite_bootstrap.service_config import ServiceConfig @@ -65,6 +68,27 @@ def teardown(self, application: fastapi.FastAPI | None = None) -> None: class FastAPISentryInstrument(SentryInstrument): ... +@dataclasses.dataclass(kw_only=True, frozen=True) +class FastAPIPrometheusInstrument(PrometheusInstrument): + metrics_path: str = "/metrics" + metrics_include_in_schema: bool = False + instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + expose_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + + def bootstrap(self, _: ServiceConfig, application: fastapi.FastAPI | None = None) -> None: + if application: + Instrumentator(**self.instrumentator_params).instrument( + application, + **self.instrument_params, + ).expose( + application, + endpoint=self.metrics_path, + include_in_schema=self.metrics_include_in_schema, + **self.expose_params, + ) + + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class FastAPIBootstrapper(BaseBootstrapper[fastapi.FastAPI, fastapi.FastAPI]): bootstrap_object: fastapi.FastAPI @@ -73,6 +97,7 @@ class FastAPIBootstrapper(BaseBootstrapper[fastapi.FastAPI, fastapi.FastAPI]): | FastAPISentryInstrument | FastAPIHealthChecksInstrument | FastAPILoggingInstrument + | FastAPIPrometheusInstrument ] service_config: ServiceConfig diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 5c97746..660f1e4 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -2,10 +2,13 @@ import dataclasses import typing +from litestar.plugins.prometheus import PrometheusConfig, PrometheusController + from lite_bootstrap.bootstrappers.base import BaseBootstrapper from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksInstrument, HealthCheckTypedDict from lite_bootstrap.instruments.logging_instrument import LoggingInstrument from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument +from lite_bootstrap.instruments.prometheus_instrument import PrometheusInstrument from lite_bootstrap.instruments.sentry_instrument import SentryInstrument from lite_bootstrap.service_config import ServiceConfig @@ -63,6 +66,26 @@ def bootstrap(self, service_config: ServiceConfig, app_config: AppConfig | None class LitestarSentryInstrument(SentryInstrument): ... +@dataclasses.dataclass(kw_only=True, frozen=True) +class LitestarPrometheusInstrument(PrometheusInstrument): + additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + + def bootstrap(self, service_config: ServiceConfig, app_config: AppConfig | None = None) -> None: + class LitestarPrometheusController(PrometheusController): + path = self.metrics_path + include_in_schema = self.metrics_include_in_schema + openmetrics_format = True + + litestar_prometheus_config = PrometheusConfig( + app_name=service_config.service_name, + **self.additional_params, + ) + + if app_config: + app_config.route_handlers.append(LitestarPrometheusController) + app_config.middleware.append(litestar_prometheus_config.middleware) + + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class LitestarBootstrapper(BaseBootstrapper[AppConfig, litestar.Litestar]): bootstrap_object: AppConfig @@ -71,6 +94,7 @@ class LitestarBootstrapper(BaseBootstrapper[AppConfig, litestar.Litestar]): | LitestarSentryInstrument | LitestarHealthChecksInstrument | LitestarLoggingInstrument + | LitestarPrometheusInstrument ] service_config: ServiceConfig diff --git a/lite_bootstrap/instruments/prometheus_instrument.py b/lite_bootstrap/instruments/prometheus_instrument.py new file mode 100644 index 0000000..1937e5a --- /dev/null +++ b/lite_bootstrap/instruments/prometheus_instrument.py @@ -0,0 +1,22 @@ +import dataclasses +import re +import typing + +from lite_bootstrap.instruments.base import BaseInstrument +from lite_bootstrap.service_config import ServiceConfig + + +VALID_PATH_PATTERN: typing.Final = re.compile(r"^(/[a-zA-Z0-9_-]+)+/?$") + + +def _is_valid_path(maybe_path: str) -> bool: + return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path)) + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class PrometheusInstrument(BaseInstrument): + metrics_path: str = "/metrics" + metrics_include_in_schema: bool = False + + def is_ready(self, _: ServiceConfig) -> bool: + return bool(self.metrics_path) and _is_valid_path(self.metrics_path) diff --git a/pyproject.toml b/pyproject.toml index 6b1deb7..6bf33f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,11 @@ fastapi = [ fastapi-otl = [ "opentelemetry-instrumentation-fastapi", ] +fastapi-metrics = [ + "prometheus-fastapi-instrumentator>=6.1", +] fastapi-all = [ - "lite-bootstrap[sentry,otl,logging,fastapi,fastapi-otl]" + "lite-bootstrap[sentry,otl,logging,fastapi,fastapi-otl,fastapi-metrics]" ] litestar = [ "litestar>=2.9", diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 83a377d..66d18d1 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -9,6 +9,7 @@ FastAPIHealthChecksInstrument, FastAPILoggingInstrument, FastAPIOpenTelemetryInstrument, + FastAPIPrometheusInstrument, FastAPISentryInstrument, ServiceConfig, ) @@ -35,6 +36,7 @@ def test_fastapi_bootstrap(fastapi_app: FastAPI, service_config: ServiceConfig) path="/health/", ), FastAPILoggingInstrument(logging_buffer_capacity=0), + FastAPIPrometheusInstrument(), ], ) bootstrapper.bootstrap() @@ -46,3 +48,12 @@ def test_fastapi_bootstrap(fastapi_app: FastAPI, service_config: ServiceConfig) assert response.json() == {"health_status": True, "service_name": "microservice", "service_version": "2.0.0"} finally: bootstrapper.teardown() + + +def test_fastapi_prometheus_instrument(fastapi_app: FastAPI, service_config: ServiceConfig) -> None: + prometheus_instrument = FastAPIPrometheusInstrument(metrics_path="/custom-metrics-path") + prometheus_instrument.bootstrap(service_config, fastapi_app) + + response = TestClient(fastapi_app).get(prometheus_instrument.metrics_path) + assert response.status_code == status.HTTP_200_OK + assert response.text diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index df0b255..baebda9 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -9,6 +9,7 @@ LitestarHealthChecksInstrument, LitestarLoggingInstrument, LitestarOpenTelemetryInstrument, + LitestarPrometheusInstrument, LitestarSentryInstrument, ServiceConfig, ) @@ -36,6 +37,7 @@ def test_litestar_bootstrap(service_config: ServiceConfig) -> None: path="/health/", ), LitestarLoggingInstrument(logging_buffer_capacity=0), + LitestarPrometheusInstrument(), ], ) application = bootstrapper.bootstrap() @@ -52,3 +54,19 @@ def test_litestar_bootstrap(service_config: ServiceConfig) -> None: } finally: bootstrapper.teardown() + + +def test_litestar_prometheus_bootstrap(service_config: ServiceConfig) -> None: + app_config = AppConfig() + prometheus_instrument = LitestarPrometheusInstrument(metrics_path="/custom-metrics-path") + bootstrapper = LitestarBootstrapper( + bootstrap_object=app_config, + service_config=service_config, + instruments=[prometheus_instrument], + ) + application = bootstrapper.bootstrap() + + with TestClient(app=application) as test_client: + response = test_client.get(prometheus_instrument.metrics_path) + assert response.status_code == status_codes.HTTP_200_OK + assert response.text