diff --git a/docs/content/exporting/http/fastapi-gunicorn.md b/docs/content/exporting/http/fastapi-gunicorn.md index 6d0ec329..acf5312a 100644 --- a/docs/content/exporting/http/fastapi-gunicorn.md +++ b/docs/content/exporting/http/fastapi-gunicorn.md @@ -16,9 +16,18 @@ app = FastAPI(debug=False) # Add prometheus asgi middleware to route /metrics requests metrics_app = make_asgi_app() -app.mount("/metrics", metrics_app) +app.add_route("/metrics", metrics_app) ``` +`add_route` serves the metrics at exactly `/metrics`. Alternatively, the app can +be mounted with `app.mount("/metrics", metrics_app)`. A mount is a path prefix, +so FastAPI serves the metrics at `/metrics/` and redirects requests for +`/metrics` there with a `307 Temporary Redirect`. + +Using `make_asgi_app()` with `add_route` requires a `prometheus_client` release +newer than 0.26.0. On 0.26.0 and earlier, `add_route` fails with a `TypeError`, +so use `app.mount("/metrics", metrics_app)` instead. + For Multiprocessing support, use this modified code snippet. Full multiprocessing instructions are provided [here]({{< ref "/multiprocess" >}}). ```python @@ -35,7 +44,7 @@ def make_metrics_app(): metrics_app = make_metrics_app() -app.mount("/metrics", metrics_app) +app.add_route("/metrics", metrics_app) ``` Run the example web application like this diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index 6e527ca9..e67ec1be 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -5,6 +5,24 @@ from .registry import Collector, REGISTRY +class _ASGIApp: + """ASGI application object wrapping the metrics handler. + + Frameworks such as Starlette and FastAPI treat a plain function passed to + ``add_route`` as a ``request -> response`` endpoint, but any other callable + as a raw ASGI application. Returning an instance rather than a bare + function allows ``app.add_route("/metrics", make_asgi_app())`` to serve + metrics at ``/metrics`` exactly, without the trailing-slash redirect that + ``app.mount("/metrics", ...)`` produces. + """ + + def __init__(self, app: Callable) -> None: + self._app = app + + async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None: + await self._app(scope, receive, send) + + def make_asgi_app(registry: Collector = REGISTRY, disable_compression: bool = False) -> Callable: """Create a ASGI app which serves the metrics from a registry.""" @@ -37,4 +55,4 @@ async def prometheus_app(scope, receive, send): ) await send({"type": "http.response.body", "body": output}) - return prometheus_app + return _ASGIApp(prometheus_app) diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 028dac2b..803f6847 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,12 +1,20 @@ import asyncio import gzip -from unittest import TestCase +import inspect +from unittest import skipUnless, TestCase from asgiref.testing import ApplicationCommunicator from prometheus_client import CollectorRegistry, Counter, make_asgi_app from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4 +try: + from starlette.applications import Starlette # type: ignore[import] + + HAVE_STARLETTE = True +except ImportError: + HAVE_STARLETTE = False + def setup_testing_defaults(scope): scope.update( @@ -257,3 +265,45 @@ def test_qs_parsing_multi(self): self.loop.run_until_complete( self.communicator.wait() ) + + def test_app_is_asgi_application_object(self): + """make_asgi_app returns a callable object rather than a plain function. + + Frameworks such as Starlette treat plain functions passed to add_route + as request/response endpoints, and any other callable as an ASGI app. + """ + app = make_asgi_app(self.registry) + self.assertFalse(inspect.isfunction(app)) + self.assertFalse(inspect.ismethod(app)) + self.assertTrue(inspect.iscoroutinefunction(app.__call__)) + # It is still a valid ASGI application when called directly. + self.increment_metrics("counter", "A counter", 2) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.assert_outputs(outputs, "counter", "A counter", 2, compressed=False) + + def _run_starlette(self, starlette_app, path): + self.scope["path"] = path + self.scope["raw_path"] = path.encode("utf-8") + self.seed_app(starlette_app) + self.send_default_request() + return self.get_all_output() + + @skipUnless(HAVE_STARLETTE, "Don't have starlette installed.") + def test_starlette_add_route(self): + """add_route serves metrics at the exact path, without a redirect.""" + self.increment_metrics("counter", "A counter", 2) + starlette_app = Starlette() + starlette_app.add_route("/metrics", make_asgi_app(self.registry)) + outputs = self._run_starlette(starlette_app, "/metrics") + self.assert_outputs(outputs, "counter", "A counter", 2, compressed=False) + + @skipUnless(HAVE_STARLETTE, "Don't have starlette installed.") + def test_starlette_mount(self): + """Mounting the app as a path prefix keeps working.""" + self.increment_metrics("counter", "A counter", 2) + starlette_app = Starlette() + starlette_app.mount("/metrics", make_asgi_app(self.registry)) + outputs = self._run_starlette(starlette_app, "/metrics/") + self.assert_outputs(outputs, "counter", "A counter", 2, compressed=False)