From c286d5aab17b36ad281e6a5ff340e92a77ff2af6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 14:40:29 +0300 Subject: [PATCH 1/2] fix: mount FastStream metrics for an injected collector registry Closes #229 --- docs/introduction/configuration.md | 15 ++++-- .../bootstrappers/faststream_bootstrapper.py | 9 +++- tests/test_faststream_bootstrap.py | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 1ea9e2d..2db72c0 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -73,9 +73,18 @@ Additional parameters for Litestar integration: Prometheus's integration for FastStream requires `prometheus_client` package. -To bootstrap Prometheus for FastStream, you must provide additionally: - -- `prometheus_middleware_cls`. +To bootstrap Prometheus for FastStream, you must provide at least one of: + +- `prometheus_middleware_cls` - the broker metrics middleware, e.g. + `faststream.redis.prometheus.RedisPrometheusMiddleware`. It is constructed with the instrument's + registry and added to the broker. +- `prometheus_collector_registry` - a `prometheus_client.CollectorRegistry` of your own, used in + place of the fresh one the instrument would otherwise build. + +With neither, nothing would populate the registry, so the metrics endpoint is not mounted and the +instrument is skipped. Unlike the other frameworks, FastStream serves a private registry rather than +`prometheus_client.REGISTRY`, so an endpoint with no middleware and no injected registry would have +nothing to report. ### Prometheus FastAPI diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index 5b7d52d..5bdc3b1 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -161,7 +161,10 @@ def _make_collector_registry() -> "prometheus_client.CollectorRegistry": class FastStreamPrometheusInstrument(PrometheusInstrument): bootstrap_config: FastStreamConfig collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field(init=False) - not_configured_reason = PrometheusInstrument.not_configured_reason + " or prometheus_middleware_cls is missing" + not_configured_reason = ( + PrometheusInstrument.not_configured_reason + + " or neither prometheus_middleware_cls nor prometheus_collector_registry is set" + ) missing_dependency_message = "prometheus_client is not installed" def __post_init__(self) -> None: @@ -170,7 +173,9 @@ def __post_init__(self) -> None: @classmethod def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: # ty: ignore[invalid-method-override] - return super().is_configured(bootstrap_config) and bool(bootstrap_config.prometheus_middleware_cls) + return super().is_configured(bootstrap_config) and bool( + bootstrap_config.prometheus_middleware_cls or bootstrap_config.prometheus_collector_registry + ) @staticmethod def dependencies_installed() -> bool: diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index 4f3ac39..a534789 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -23,6 +23,7 @@ from lite_bootstrap.bootstrappers.faststream_bootstrapper import ( FastStreamLoggingInstrument, FastStreamOpenTelemetryInstrument, + FastStreamPrometheusInstrument, ) from lite_bootstrap.exceptions import ConfigurationError from tests.conftest import ( @@ -276,6 +277,51 @@ async def test_faststream_prometheus_uses_injected_registry(broker: RedisBroker) bootstrapper.teardown() +async def test_faststream_prometheus_mounts_metrics_for_an_injected_registry_without_middleware( + broker: RedisBroker, +) -> None: + custom_registry = prometheus_client.CollectorRegistry() + counter_name = f"injected_counter_{uuid.uuid4().hex}_total" + counter = prometheus_client.Counter(counter_name, "Injected registry counter", registry=custom_registry) + counter.inc() + + bootstrap_config = dataclasses.replace( + build_faststream_config(broker=broker), + prometheus_middleware_cls=None, + prometheus_collector_registry=custom_registry, + ) + bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) + application = bootstrapper.bootstrap() + try: + with TestClient(app=application) as test_client: + async with TestRedisBroker(broker): + response = test_client.get(bootstrap_config.prometheus_metrics_path) + assert response.status_code == status.HTTP_200_OK + assert counter_name.encode() in response.content + finally: + bootstrapper.teardown() + + +async def test_faststream_prometheus_mounts_nothing_without_middleware_or_registry(broker: RedisBroker) -> None: + bootstrap_config = dataclasses.replace( + build_faststream_config(broker=broker), + prometheus_middleware_cls=None, + prometheus_collector_registry=None, + ) + bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) + skipped = [one for one, _ in bootstrapper.skipped_instruments if one is FastStreamPrometheusInstrument] + assert len(skipped) == 1 + + application = bootstrapper.bootstrap() + try: + with TestClient(app=application) as test_client: + async with TestRedisBroker(broker): + response = test_client.get(bootstrap_config.prometheus_metrics_path) + assert response.status_code == status.HTTP_404_NOT_FOUND + finally: + bootstrapper.teardown() + + def test_faststream_logging_teardown_runs_super_when_broker_write_raises(broker: RedisBroker) -> None: bootstrap_config = build_faststream_config(broker=broker) instrument = FastStreamLoggingInstrument(bootstrap_config=bootstrap_config) From 7150b1ae5727e1f98c09783e886eef3989249cf0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 14:44:31 +0300 Subject: [PATCH 2/2] review: compare against None, parametrize, pin the skip reason --- docs/introduction/configuration.md | 2 +- .../bootstrappers/faststream_bootstrapper.py | 7 ++-- tests/test_faststream_bootstrap.py | 38 +++++-------------- 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 2db72c0..95e3189 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -77,7 +77,7 @@ To bootstrap Prometheus for FastStream, you must provide at least one of: - `prometheus_middleware_cls` - the broker metrics middleware, e.g. `faststream.redis.prometheus.RedisPrometheusMiddleware`. It is constructed with the instrument's - registry and added to the broker. + registry and added to the broker, when the application has one. - `prometheus_collector_registry` - a `prometheus_client.CollectorRegistry` of your own, used in place of the fresh one the instrument would otherwise build. diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index 5bdc3b1..5dc7655 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -163,7 +163,7 @@ class FastStreamPrometheusInstrument(PrometheusInstrument): collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field(init=False) not_configured_reason = ( PrometheusInstrument.not_configured_reason - + " or neither prometheus_middleware_cls nor prometheus_collector_registry is set" + + ", or neither prometheus_middleware_cls nor prometheus_collector_registry is set" ) missing_dependency_message = "prometheus_client is not installed" @@ -173,8 +173,9 @@ def __post_init__(self) -> None: @classmethod def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: # ty: ignore[invalid-method-override] - return super().is_configured(bootstrap_config) and bool( - bootstrap_config.prometheus_middleware_cls or bootstrap_config.prometheus_collector_registry + return super().is_configured(bootstrap_config) and ( + bootstrap_config.prometheus_middleware_cls is not None + or bootstrap_config.prometheus_collector_registry is not None ) @staticmethod diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index a534789..96562b6 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -255,31 +255,10 @@ def test_faststream_build_excluded_urls_covers_prometheus_and_health_paths(broke assert config_with_health_spans.health_checks_path not in excluded_with # kept when health spans are on -async def test_faststream_prometheus_uses_injected_registry(broker: RedisBroker) -> None: - custom_registry = prometheus_client.CollectorRegistry() - counter_name = f"injected_counter_{uuid.uuid4().hex}_total" - counter = prometheus_client.Counter(counter_name, "Injected registry counter", registry=custom_registry) - counter.inc() - - bootstrap_config = dataclasses.replace( - build_faststream_config(broker=broker), - prometheus_collector_registry=custom_registry, - ) - bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) - application = bootstrapper.bootstrap() - try: - with TestClient(app=application) as test_client: - async with TestRedisBroker(broker): - response = test_client.get(bootstrap_config.prometheus_metrics_path) - assert response.status_code == status.HTTP_200_OK - assert counter_name.encode() in response.content - finally: - bootstrapper.teardown() - - -async def test_faststream_prometheus_mounts_metrics_for_an_injected_registry_without_middleware( - broker: RedisBroker, -) -> None: +@pytest.mark.parametrize( + "middleware_cls", [RedisPrometheusMiddleware, None], ids=["with_middleware", "without_middleware"] +) +async def test_faststream_prometheus_uses_injected_registry(broker: RedisBroker, middleware_cls: type | None) -> None: custom_registry = prometheus_client.CollectorRegistry() counter_name = f"injected_counter_{uuid.uuid4().hex}_total" counter = prometheus_client.Counter(counter_name, "Injected registry counter", registry=custom_registry) @@ -287,7 +266,7 @@ async def test_faststream_prometheus_mounts_metrics_for_an_injected_registry_wit bootstrap_config = dataclasses.replace( build_faststream_config(broker=broker), - prometheus_middleware_cls=None, + prometheus_middleware_cls=middleware_cls, prometheus_collector_registry=custom_registry, ) bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) @@ -302,15 +281,16 @@ async def test_faststream_prometheus_mounts_metrics_for_an_injected_registry_wit bootstrapper.teardown() -async def test_faststream_prometheus_mounts_nothing_without_middleware_or_registry(broker: RedisBroker) -> None: +async def test_faststream_prometheus_is_skipped_without_middleware_or_registry(broker: RedisBroker) -> None: bootstrap_config = dataclasses.replace( build_faststream_config(broker=broker), prometheus_middleware_cls=None, prometheus_collector_registry=None, ) bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) - skipped = [one for one, _ in bootstrapper.skipped_instruments if one is FastStreamPrometheusInstrument] - assert len(skipped) == 1 + skipped = dict(bootstrapper.skipped_instruments) + assert FastStreamPrometheusInstrument in skipped + assert "prometheus_collector_registry" in skipped[FastStreamPrometheusInstrument] application = bootstrapper.bootstrap() try: