From 272700dbe1d430874500e3862057151af422f0b0 Mon Sep 17 00:00:00 2001 From: Bhaskar Gurram Date: Fri, 9 Oct 2026 18:59:56 +0000 Subject: [PATCH 1/3] Allow make_asgi_app to be added as a Starlette/FastAPI route make_asgi_app() returned a plain function. Starlette (and therefore FastAPI) treats a plain function passed to add_route() as a request -> response endpoint rather than an ASGI app, so app.add_route("/metrics", make_asgi_app()) failed with a TypeError. The only working option was app.mount("/metrics", ...), which is a path prefix: metrics are served at /metrics/ and requests for /metrics get a 307 redirect. Return a callable ASGI application object instead. Starlette passes non-function callables through to Route as raw ASGI apps, so add_route("/metrics", make_asgi_app()) now serves /metrics directly. The returned object is called with the same (scope, receive, send) signature, so mounting it or using it as a standalone ASGI app keeps working. Update the FastAPI docs to use add_route and explain the mount behaviour. Fixes #1016 Signed-off-by: Bhaskar Gurram --- .../exporting/http/fastapi-gunicorn.md | 9 +++- prometheus_client/asgi.py | 20 ++++++- tests/test_asgi.py | 52 ++++++++++++++++++- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/content/exporting/http/fastapi-gunicorn.md b/docs/content/exporting/http/fastapi-gunicorn.md index 6d0ec329..dfcd884a 100644 --- a/docs/content/exporting/http/fastapi-gunicorn.md +++ b/docs/content/exporting/http/fastapi-gunicorn.md @@ -16,9 +16,14 @@ 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`. + For Multiprocessing support, use this modified code snippet. Full multiprocessing instructions are provided [here]({{< ref "/multiprocess" >}}). ```python @@ -35,7 +40,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) From f671e5a8e24e023ccda65c3424a233e670676e2b Mon Sep 17 00:00:00 2001 From: Bhaskar Gurram Date: Sat, 10 Oct 2026 09:57:20 +0000 Subject: [PATCH 2/3] docs: note that add_route needs a release newer than 0.26.0 Signed-off-by: Bhaskar Gurram --- docs/content/exporting/http/fastapi-gunicorn.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/content/exporting/http/fastapi-gunicorn.md b/docs/content/exporting/http/fastapi-gunicorn.md index dfcd884a..acf5312a 100644 --- a/docs/content/exporting/http/fastapi-gunicorn.md +++ b/docs/content/exporting/http/fastapi-gunicorn.md @@ -24,6 +24,10 @@ 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 From 5b088acd2b05f4742f693040e22b651980c5ead5 Mon Sep 17 00:00:00 2001 From: Bhaskar Gurram Date: Thu, 15 Oct 2026 02:09:07 +0000 Subject: [PATCH 3/3] tox: install starlette in the py3.9 and pypy3.9 envs Runs the Starlette add_route and mount tests in CI instead of skipping them, alongside the other optional framework dependencies. Signed-off-by: Bhaskar Gurram --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 992bd0a7..6a006d0d 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ deps = {py3.9,pypy3.9}: twisted {py3.9,pypy3.9}: aiohttp {py3.9,pypy3.9}: django + {py3.9,pypy3.9}: starlette {py3.9}: python-snappy commands = coverage run --parallel -m pytest {posargs}