diff --git a/CONTEXT.md b/CONTEXT.md index 02cae3a..3b60e3e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,7 +29,8 @@ per-framework guides, and Sentry's word for its own `Integration` objects. **Configured**: An instrument is configured when the user's config asks for it: `is_configured(config)`, a classmethod evaluated before anything is instantiated. Unconfigured is the normal case, not a fault: -an empty `sentry_dsn` means the user did not want Sentry. +an empty `sentry_dsn` means the user did not want Sentry. Prometheus at its default metrics path +is also unconfigured when `prometheus_client` is missing, treating an absent extra as an opt-out. _Avoid_: enabled. `logging_enabled` and `health_checks_enabled` are two config fields among the several inputs `is_configured` reads; most instruments have no enabled flag at all. @@ -45,7 +46,8 @@ An instrument the bootstrapper decided not to instantiate. Two paths, deliberate signals: not configured is silent and lands in `skipped_instruments`, so it shows in `build_summary()`; configured but with its optional package missing is a deployment surprise, so it raises `InstrumentDependencyMissingWarning` plus a `logger.warning` and lands in neither list. The -quiet skip is the one you have to go looking for; the loud one finds you. +quiet skip includes Prometheus at its default path without `prometheus_client`; a custom path +with the same missing dependency takes the warning path. **Health probe**: One check of something the service depends on, run each time the health check route is requested. A diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index b735811..5bd0d03 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -119,9 +119,15 @@ the option [here](https://docs.sentry.io/platforms/python/data-management/data-c Prometheus is the cheapest of the three non-logging instruments; see [Performance](performance.md). -Prometheus is on by default when its extra is installed, serving metrics at `prometheus_metrics_path` -(default: `/metrics`). Set `prometheus_metrics_path` to an empty string to disable it. FastStream is -the exception: its default is empty; see [Prometheus FastStream](#prometheus-faststream). +On FastAPI, Litestar and FastMCP, Prometheus is on by default when its extra is installed, +serving metrics at `prometheus_metrics_path` (default: `/metrics`). Set the path to an empty +string to disable it. Without `prometheus_client`, the default path is a silent opt-out; +a custom path still emits a missing-dependency warning. Explicitly setting `/metrics` +is indistinguishable from leaving the default and is also silent. + +FastStream is opt-in: its default path is empty because broker metrics require a +broker-specific middleware class that lite-bootstrap cannot choose automatically. +See [Prometheus FastStream](#prometheus-faststream). Additional parameters: diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 146f4ff..f995e1b 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -245,7 +245,10 @@ class FastAPIPrometheusInstrument(PrometheusInstrument[FastAPIConfig]): @staticmethod def dependencies_installed() -> bool: - return import_checker.is_prometheus_fastapi_instrumentator_installed + return ( + import_checker.is_prometheus_client_installed + and import_checker.is_prometheus_fastapi_instrumentator_installed + ) def bootstrap(self) -> None: config = self.bootstrap_config diff --git a/lite_bootstrap/instruments/prometheus_instrument.py b/lite_bootstrap/instruments/prometheus_instrument.py index 491814b..30ecfcd 100644 --- a/lite_bootstrap/instruments/prometheus_instrument.py +++ b/lite_bootstrap/instruments/prometheus_instrument.py @@ -4,6 +4,7 @@ import re import typing +from lite_bootstrap import import_checker from lite_bootstrap.exceptions import ConfigurationError from lite_bootstrap.helpers.path import is_valid_path from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -38,11 +39,15 @@ def is_prometheus_http_metrics_on(self, *, opentelemetry_records_http_metrics: b @dataclasses.dataclass(kw_only=True, slots=True) class PrometheusInstrument(BaseInstrument[PrometheusConfigT]): - not_configured_reason = "prometheus_metrics_path is empty" + not_configured_reason = "prometheus_metrics_path is empty or uses its default without prometheus_client" @classmethod def is_configured(cls, bootstrap_config: "PrometheusConfig") -> bool: - return bool(bootstrap_config.prometheus_metrics_path) + path = bootstrap_config.prometheus_metrics_path + default_path = next( + field.default for field in dataclasses.fields(bootstrap_config) if field.name == "prometheus_metrics_path" + ) + return bool(path) and (path != default_path or import_checker.is_prometheus_client_installed) def infrastructure_excluded_patterns(self) -> list[str]: config = self.bootstrap_config diff --git a/tests/instruments/test_prometheus_instrument.py b/tests/instruments/test_prometheus_instrument.py index 556cb26..113432b 100644 --- a/tests/instruments/test_prometheus_instrument.py +++ b/tests/instruments/test_prometheus_instrument.py @@ -9,7 +9,10 @@ def test_prometheus_instrument_configured_with_default_path() -> None: def test_prometheus_instrument_not_configured_with_empty_path() -> None: config = PrometheusConfig(prometheus_metrics_path="") assert not PrometheusInstrument.is_configured(config) - assert PrometheusInstrument.not_configured_reason == "prometheus_metrics_path is empty" + assert ( + PrometheusInstrument.not_configured_reason + == "prometheus_metrics_path is empty or uses its default without prometheus_client" + ) def test_prometheus_instrument_configured_with_custom_valid_path() -> None: diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 1ad185a..08366aa 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -253,6 +253,34 @@ def test_fastapi_bootstrapper_with_missing_instrument_dependency( FastAPIBootstrapper(bootstrap_config=fastapi_config) +@pytest.mark.parametrize("metrics_path", [None, "/custom"]) +def test_fastapi_missing_prometheus_is_quiet_only_for_the_default_path( + metrics_path: str | None, caplog: pytest.LogCaptureFixture +) -> None: + config = FastAPIConfig() if metrics_path is None else FastAPIConfig(prometheus_metrics_path=metrics_path) + with emulate_package_missing("prometheus_client"), warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bootstrapper = FastAPIBootstrapper(bootstrap_config=config) + try: + assert not any( + isinstance(instrument, fastapi_bootstrapper.FastAPIPrometheusInstrument) + for instrument in bootstrapper.instruments + ) + if metrics_path is None: + assert not caught + assert not caplog.records + assert any( + instrument is fastapi_bootstrapper.FastAPIPrometheusInstrument + for instrument, _reason in bootstrapper.skipped_instruments + ) + else: + assert [warning.category for warning in caught] == [InstrumentDependencyMissingWarning] + assert "prometheus" in str(caught[0].message) + assert "prometheus" in caplog.text + finally: + bootstrapper.teardown() + + def test_fastapi_bootstrapper_runs_without_sentry_sdk(fastapi_config: FastAPIConfig) -> None: with emulate_package_missing_with_module_reload( "sentry_sdk", diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index f925b75..b326eaa 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -341,7 +341,7 @@ def test_fastmcp_logging_config_warns_nothing(overrides: dict[str, bool]) -> Non [ ("sentry_sdk", {"sentry_dsn": "https://testdsn@localhost/1"}), ("structlog", {}), - ("prometheus_client", {}), + ("prometheus_client", {"prometheus_metrics_path": "/custom"}), ], ) def test_fastmcp_bootstrapper_with_missing_instrument_dependency( @@ -372,8 +372,10 @@ def test_fastmcp_bootstrap_without_prometheus_client() -> None: "prometheus_client", ["lite_bootstrap.bootstrappers.fastmcp_bootstrapper"], ): - with pytest.warns(UserWarning, match="prometheus_client"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + assert not caught bootstrapper.bootstrap() bootstrapper.teardown() diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index a63523d..cbb9940 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -512,7 +512,7 @@ def test_faststream_prometheus_is_skipped_without_metrics_path(broker: RedisBrok ) bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) skipped = dict(bootstrapper.skipped_instruments) - assert skipped[FastStreamPrometheusInstrument] == "prometheus_metrics_path is empty" + assert skipped[FastStreamPrometheusInstrument] == FastStreamPrometheusInstrument.not_configured_reason async def test_faststream_prometheus_serves_the_default_registry(broker: RedisBroker) -> None: