From a3245d50f0f5894d85f1758226158469bec711e8 Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:17:11 +0100 Subject: [PATCH] docs: clarify metrics module usage (#117) --- docs/module/metrics.md | 37 ++++++++++--- src/fastapi_toolsets/metrics/handler.py | 2 +- src/fastapi_toolsets/metrics/registry.py | 66 ++++++++---------------- tests/test_metrics.py | 36 +++++++++++++ 4 files changed, 88 insertions(+), 53 deletions(-) diff --git a/docs/module/metrics.md b/docs/module/metrics.md index 84bc2cf..ffabe80 100644 --- a/docs/module/metrics.md +++ b/docs/module/metrics.md @@ -36,7 +36,13 @@ This mounts the `/metrics` endpoint that Prometheus can scrape. ### Providers -Providers are called once at startup and register metrics that are updated externally (e.g. counters, histograms): +Providers are called once at startup by `init_metrics`. The return value (the Prometheus metric object) is stored in the registry and can be retrieved later with [`registry.get(name)`](../reference/metrics.md#fastapi_toolsets.metrics.registry.MetricsRegistry.get). + +Use providers when you want **deferred initialization**: the Prometheus metric is not registered with the global `CollectorRegistry` until `init_metrics` runs, not at import time. This is particularly useful for testing — importing the module in a test suite without calling `init_metrics` leaves no metrics registered, avoiding cross-test pollution. + +It is also useful when metrics are defined across multiple modules and merged with `include_registry`: any code that needs a metric can call `metrics.get()` on the shared registry instead of importing the metric directly from its origin module. + +If neither of these applies to you, declaring metrics at module level (e.g. `HTTP_REQUESTS = Counter(...)`) is simpler and equally valid. ```python from prometheus_client import Counter, Histogram @@ -50,15 +56,32 @@ def request_duration(): return Histogram("request_duration_seconds", "Request duration") ``` -### Collectors - -Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges): +To use a provider's metric elsewhere (e.g. in a middleware), call `metrics.get()` inside the handler — **not** at module level, as providers are only initialized when `init_metrics` runs: ```python +async def metrics_middleware(request: Request, call_next): + response = await call_next(request) + metrics.get("http_requests").labels( + method=request.method, status=response.status_code + ).inc() + return response +``` + +### Collectors + +Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges). + +!!! warning "Declare the metric at module level" + Do **not** instantiate the Prometheus metric inside the collector function. Doing so recreates it on every scrape, raising `ValueError: Duplicated timeseries in CollectorRegistry`. Declare it once at module level instead: + +```python +from prometheus_client import Gauge + +_queue_depth = Gauge("queue_depth", "Current queue depth") + @metrics.register(collect=True) -def queue_depth(): - gauge = Gauge("queue_depth", "Current queue depth") - gauge.set(get_current_queue_depth()) +def collect_queue_depth(): + _queue_depth.set(get_current_queue_depth()) ``` ## Merging registries diff --git a/src/fastapi_toolsets/metrics/handler.py b/src/fastapi_toolsets/metrics/handler.py index 1625205..e2e7ea9 100644 --- a/src/fastapi_toolsets/metrics/handler.py +++ b/src/fastapi_toolsets/metrics/handler.py @@ -51,7 +51,7 @@ def init_metrics( """ for provider in registry.get_providers(): logger.debug("Initialising metric provider '%s'", provider.name) - provider.func() + registry._instances[provider.name] = provider.func() # Partition collectors and cache env check at startup — both are stable for the app lifetime. async_collectors = [ diff --git a/src/fastapi_toolsets/metrics/registry.py b/src/fastapi_toolsets/metrics/registry.py index 9995c03..be511dc 100644 --- a/src/fastapi_toolsets/metrics/registry.py +++ b/src/fastapi_toolsets/metrics/registry.py @@ -19,31 +19,11 @@ class Metric: class MetricsRegistry: - """Registry for managing Prometheus metric providers and collectors. - - Example: - ```python - from prometheus_client import Counter, Gauge - from fastapi_toolsets.metrics import MetricsRegistry - - metrics = MetricsRegistry() - - @metrics.register - def http_requests(): - return Counter("http_requests_total", "Total HTTP requests", ["method", "status"]) - - @metrics.register(name="db_pool") - def database_pool_size(): - return Gauge("db_pool_size", "Database connection pool size") - - @metrics.register(collect=True) - def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")): - gauge.set(get_current_queue_depth()) - ``` - """ + """Registry for managing Prometheus metric providers and collectors.""" def __init__(self) -> None: self._metrics: dict[str, Metric] = {} + self._instances: dict[str, Any] = {} def register( self, @@ -61,17 +41,6 @@ class MetricsRegistry: name: Metric name (defaults to function name). collect: If ``True``, the function is called on every scrape. If ``False`` (default), called once at init time. - - Example: - ```python - @metrics.register - def my_counter(): - return Counter("my_counter", "A counter") - - @metrics.register(collect=True, name="queue") - def collect_queue_depth(): - gauge.set(compute_depth()) - ``` """ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: @@ -87,6 +56,25 @@ class MetricsRegistry: return decorator(func) return decorator + def get(self, name: str) -> Any: + """Return the metric instance created by a provider. + + Args: + name: The metric name (defaults to the provider function name). + + Raises: + KeyError: If the metric name is unknown or ``init_metrics`` has not + been called yet. + """ + if name not in self._instances: + if name in self._metrics: + raise KeyError( + f"Metric '{name}' exists but has not been initialized yet. " + "Ensure init_metrics() has been called before accessing metric instances." + ) + raise KeyError(f"Unknown metric '{name}'.") + return self._instances[name] + def include_registry(self, registry: "MetricsRegistry") -> None: """Include another :class:`MetricsRegistry` into this one. @@ -95,18 +83,6 @@ class MetricsRegistry: Raises: ValueError: If a metric name already exists in the current registry. - - Example: - ```python - main = MetricsRegistry() - sub = MetricsRegistry() - - @sub.register - def sub_metric(): - return Counter("sub_total", "Sub counter") - - main.include_registry(sub) - ``` """ for metric_name, definition in registry._metrics.items(): if metric_name in self._metrics: diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 380c5db..ace11b0 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -159,6 +159,42 @@ class TestMetricsRegistry: assert registry.get_all()[0].func is second +class TestGet: + """Tests for MetricsRegistry.get method.""" + + def test_get_returns_instance_after_init(self): + """get() returns the metric instance stored by init_metrics.""" + app = FastAPI() + registry = MetricsRegistry() + + @registry.register + def my_gauge(): + return Gauge("get_test_gauge", "A test gauge") + + init_metrics(app, registry) + + instance = registry.get("my_gauge") + assert isinstance(instance, Gauge) + + def test_get_raises_for_registered_but_not_initialized(self): + """get() raises KeyError with an informative message when init_metrics was not called.""" + registry = MetricsRegistry() + + @registry.register + def my_counter(): + return Counter("get_uninit_counter", "A counter") + + with pytest.raises(KeyError, match="not been initialized yet"): + registry.get("my_counter") + + def test_get_raises_for_unknown_name(self): + """get() raises KeyError when the metric name is not registered at all.""" + registry = MetricsRegistry() + + with pytest.raises(KeyError, match="Unknown metric"): + registry.get("nonexistent") + + class TestIncludeRegistry: """Tests for MetricsRegistry.include_registry method."""