Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/content/exporting/http/fastapi-gunicorn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion prometheus_client/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
52 changes: 51 additions & 1 deletion tests/test_asgi.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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)