From c608c4eeaea5ce74fafaf1fb931fc6ec556e667f Mon Sep 17 00:00:00 2001 From: mvanderlee <918128+mvanderlee@users.noreply.github.com> Date: Sun, 10 Mar 2024 19:47:07 +0100 Subject: [PATCH] feat: Make instrumentator work with just Starlette (#288) * Replace fastapi with Starlette * build(deps): Move fastapi to dev dependencies * fix: Make it work * docs: Add entry to changelog --------- Co-authored-by: Tim Schwenke --- CHANGELOG.md | 10 +++++ poetry.lock | 2 +- pyproject.toml | 3 +- .../instrumentation.py | 38 ++++++++++++++----- .../metrics.py | 2 +- .../middleware.py | 4 +- ...strumentation_expose.py => test_expose.py} | 36 +++++++++++++++++- 7 files changed, 78 insertions(+), 17 deletions(-) rename tests/{test_instrumentation_expose.py => test_expose.py} (71%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f594e..72bd2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0). ### Added +- Instrumentator now works without FastAPI. This is possible because every + FastAPI app is also a Starlette app (but not the other way around). Or to be + more specific: FastAPI uses Starlette for things like routing and middleware + this package relies on. The change is backwards compatible, even type checkers + like mypy should continue working. Thanks to + [@mvanderlee](https://github.com/mvanderlee) for proposing this in + [#280](https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/280) + and implementing it in + [#288](https://github.com/trallnag/prometheus-fastapi-instrumentator/pull/288). + - Relaxed type of `get_route_name` argument to `HTTPConnection`. This allows developers to use the `get_route_name` function for getting the name of websocket routes as well. Thanks to [@pajowu](https://github.com/pajowu) for diff --git a/poetry.lock b/poetry.lock index f41767d..b34f95d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1016,4 +1016,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = ">= 3.8.1, < 4.0.0" -content-hash = "9fa95a9001d61d2495ab5ad872c445f7383edb7e7cf625ecbccabb352b1b9b33" +content-hash = "c1da1ecccd66457f0cb6e9244ca5ad4136431ae2221ef4ba8f1eace5ac5e2ff3" diff --git a/pyproject.toml b/pyproject.toml index 89a90a7..91a51c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ keywords = ["prometheus", "instrumentation", "fastapi", "exporter", "metrics"] [tool.poetry.dependencies] python = ">= 3.8.1, < 4.0.0" -fastapi = ">= 0.38.1, < 1.0.0" +starlette = ">= 0.30.0, < 1.0.0" prometheus-client = ">= 0.8.0, < 1.0.0" [tool.poetry.group.dev.dependencies] @@ -32,6 +32,7 @@ asgiref = "^3.7.2" uvicorn = ">=0.28.0" gunicorn = "^21.2.0" pytest-asyncio = ">=0.23.5.post1" +fastapi = "^0.110.0" [tool.black] line-length = 90 diff --git a/src/prometheus_fastapi_instrumentator/instrumentation.py b/src/prometheus_fastapi_instrumentator/instrumentation.py index fcd4f75..0400adf 100644 --- a/src/prometheus_fastapi_instrumentator/instrumentation.py +++ b/src/prometheus_fastapi_instrumentator/instrumentation.py @@ -1,12 +1,12 @@ import asyncio import gzip +import importlib.util import os import re import warnings from enum import Enum from typing import Any, Awaitable, Callable, List, Optional, Sequence, Union, cast -from fastapi import FastAPI from prometheus_client import ( CONTENT_TYPE_LATEST, REGISTRY, @@ -14,6 +14,7 @@ generate_latest, multiprocess, ) +from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response @@ -40,7 +41,7 @@ def __init__( inprogress_labels: bool = False, registry: Union[CollectorRegistry, None] = None, ) -> None: - """Create a Prometheus FastAPI Instrumentator. + """Create a Prometheus FastAPI (and Starlette) Instrumentator. Args: should_group_status_codes (bool): Should status codes be grouped into @@ -149,7 +150,7 @@ def __init__( def instrument( self, - app: FastAPI, + app: Starlette, metric_namespace: str = "", metric_subsystem: str = "", should_only_respect_2xx_for_highr: bool = False, @@ -183,10 +184,11 @@ def instrument( The middleware iterates through all `instrumentations` and executes them. Args: - app (FastAPI): FastAPI app instance. + app: Starlette app instance. Note that every FastAPI app is a + Starlette app. Raises: - e: Only raised if FastAPI itself throws an exception. + e: Only raised if app itself throws an exception. Returns: self: Instrumentator. Builder Pattern. @@ -222,7 +224,7 @@ def instrument( def expose( self, - app: FastAPI, + app: Starlette, should_gzip: bool = False, endpoint: str = "/metrics", include_in_schema: bool = True, @@ -232,7 +234,9 @@ def expose( """Exposes endpoint for metrics. Args: - app: FastAPI app instance. Endpoint will be added to this app. + app: App instance. Endpoint will be added to this app. This can be + a Starlette app or a FastAPI app. If it is a Starlette app, `tags` + `kwargs` will be ignored. should_gzip: Should the endpoint return compressed data? It will also check for `gzip` in the `Accept-Encoding` header. @@ -245,9 +249,9 @@ def expose( include_in_schema: Should the endpoint show up in the documentation? tags (List[str], optional): If you manage your routes with tags. - Defaults to None. + Defaults to None. Only passed to FastAPI app. - kwargs: Will be passed to FastAPI route annotation. + kwargs: Will be passed to app. Only passed to FastAPI app. Returns: self: Instrumentator. Builder Pattern. @@ -256,7 +260,6 @@ def expose( if self.should_respect_env_var and not self._should_instrumentate(): return self - @app.get(endpoint, include_in_schema=include_in_schema, tags=tags, **kwargs) def metrics(request: Request) -> Response: """Endpoint that serves Prometheus metrics.""" @@ -277,6 +280,21 @@ def metrics(request: Request) -> Response: return resp + route_configured = False + if importlib.util.find_spec("fastapi"): + from fastapi import FastAPI + + if isinstance(app, FastAPI): + fastapi_app: FastAPI = app + fastapi_app.get( + endpoint, include_in_schema=include_in_schema, tags=tags, **kwargs + )(metrics) + route_configured = True + if not route_configured: + app.add_route( + path=endpoint, route=metrics, include_in_schema=include_in_schema + ) + return self def add( diff --git a/src/prometheus_fastapi_instrumentator/metrics.py b/src/prometheus_fastapi_instrumentator/metrics.py index abd5ec8..959e8ee 100644 --- a/src/prometheus_fastapi_instrumentator/metrics.py +++ b/src/prometheus_fastapi_instrumentator/metrics.py @@ -117,7 +117,7 @@ def latency( buckets: Sequence[Union[float, str]] = Histogram.DEFAULT_BUCKETS, registry: CollectorRegistry = REGISTRY, ) -> Optional[Callable[[Info], None]]: - """Default metric for the Prometheus FastAPI Instrumentator. + """Default metric for the Prometheus Starlette Instrumentator. Args: metric_name (str, optional): Name of the metric to be created. Must be diff --git a/src/prometheus_fastapi_instrumentator/middleware.py b/src/prometheus_fastapi_instrumentator/middleware.py index e0d9a6b..9eae350 100644 --- a/src/prometheus_fastapi_instrumentator/middleware.py +++ b/src/prometheus_fastapi_instrumentator/middleware.py @@ -6,8 +6,8 @@ from timeit import default_timer from typing import Awaitable, Callable, Optional, Sequence, Tuple, Union -from fastapi import FastAPI from prometheus_client import REGISTRY, CollectorRegistry, Gauge +from starlette.applications import Starlette from starlette.datastructures import Headers from starlette.requests import Request from starlette.responses import Response @@ -19,7 +19,7 @@ class PrometheusInstrumentatorMiddleware: def __init__( self, - app: FastAPI, + app: Starlette, *, should_group_status_codes: bool = True, should_ignore_untemplated: bool = False, diff --git a/tests/test_instrumentation_expose.py b/tests/test_expose.py similarity index 71% rename from tests/test_instrumentation_expose.py rename to tests/test_expose.py index b30ed8d..9bf1342 100644 --- a/tests/test_instrumentation_expose.py +++ b/tests/test_expose.py @@ -2,6 +2,9 @@ from typing import Any, Dict, Optional from fastapi import FastAPI, HTTPException +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route from starlette.testclient import TestClient from prometheus_fastapi_instrumentator import Instrumentator @@ -10,7 +13,7 @@ # Setup -def create_app() -> FastAPI: +def create_fastapi_app() -> FastAPI: app = FastAPI() @app.get("/") @@ -45,6 +48,13 @@ def create_item(item: Dict[Any, Any]): return app +def create_starlette_app() -> Starlette: + async def homepage(request): + return PlainTextResponse("Homepage") + + return Starlette(routes=[Route("/", endpoint=homepage)]) + + def reset_prometheus() -> None: from prometheus_client import REGISTRY @@ -70,7 +80,7 @@ def reset_prometheus() -> None: def test_expose_default_content_type(): reset_prometheus() - app = create_app() + app = create_fastapi_app() Instrumentator().instrument(app).expose(app) client = TestClient(app) @@ -80,3 +90,25 @@ def test_expose_default_content_type(): "text/plain; version=0.0.4; charset=utf-8; charset=utf-8" not in response.headers.values() ) + + +def test_fastapi_app_expose(): + reset_prometheus() + app = create_fastapi_app() + Instrumentator().instrument(app).expose(app) + client = TestClient(app) + + response = client.get("/metrics") + + assert response.status_code == 200 + + +def test_starlette_app_expose(): + reset_prometheus() + app = create_starlette_app() + Instrumentator().instrument(app).expose(app) + client = TestClient(app) + + response = client.get("/metrics") + + assert response.status_code == 200