Skip to content

Commit 10db862

Browse files
authored
Add an AIOHTTP exporter (#1139)
* Always run the asgi tests Since the client now requires a minimum of Python 3.9, we don't need to have this feature gate in place any more Signed-off-by: Lexi Robinson <[email protected]> * Add an AIOHTTP exporter Unfortunately the AIOHTTP library doesn't support ASGI and apparently has no plans to do so which makes the ASGI exporter not suitable for anyone using it to run their python server. Where possible this commit follows the existing ASGI implementation and runs the same tests for consistency. Signed-off-by: Lexi Robinson <[email protected]> --------- Signed-off-by: Lexi Robinson <[email protected]>
1 parent 8746c49 commit 10db862

File tree

7 files changed

+270
-14
lines changed

7 files changed

+270
-14
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
title: AIOHTTP
3+
weight: 6
4+
---
5+
6+
To use Prometheus with a [AIOHTTP server](https://docs.aiohttp.org/en/stable/web.html),
7+
there is `make_aiohttp_handler` which creates a handler.
8+
9+
```python
10+
from aiohttp import web
11+
from prometheus_client.aiohttp import make_aiohttp_handler
12+
13+
app = web.Application()
14+
app.router.add_get("/metrics", make_aiohttp_handler())
15+
```
16+
17+
By default, this handler will instruct AIOHTTP to automatically compress the
18+
response if requested by the client. This behaviour can be disabled by passing
19+
`disable_compression=True` when creating the app, like this:
20+
21+
```python
22+
app.router.add_get("/metrics", make_aiohttp_handler(disable_compression=True))
23+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .exposition import make_aiohttp_handler
2+
3+
__all__ = [
4+
"make_aiohttp_handler",
5+
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from aiohttp import hdrs, web
4+
from aiohttp.typedefs import Handler
5+
6+
from ..exposition import _bake_output
7+
from ..registry import CollectorRegistry, REGISTRY
8+
9+
10+
def make_aiohttp_handler(
11+
registry: CollectorRegistry = REGISTRY,
12+
disable_compression: bool = False,
13+
) -> Handler:
14+
"""Create a aiohttp handler which serves the metrics from a registry."""
15+
16+
async def prometheus_handler(request: web.Request) -> web.Response:
17+
# Prepare parameters
18+
params = {key: request.query.getall(key) for key in request.query.keys()}
19+
accept_header = ",".join(request.headers.getall(hdrs.ACCEPT, []))
20+
accept_encoding_header = ""
21+
# Bake output
22+
status, headers, output = _bake_output(
23+
registry,
24+
accept_header,
25+
accept_encoding_header,
26+
params,
27+
# use AIOHTTP's compression
28+
disable_compression=True,
29+
)
30+
response = web.Response(
31+
status=int(status.split(" ")[0]),
32+
headers=headers,
33+
body=output,
34+
)
35+
if not disable_compression:
36+
response.enable_compression()
37+
return response
38+
39+
return prometheus_handler

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ classifiers = [
4343
twisted = [
4444
"twisted",
4545
]
46+
aiohttp = [
47+
"aiohttp",
48+
]
4649

4750
[project.urls]
4851
Homepage = "https://github.com/prometheus/client_python"

tests/test_aiohttp.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
from __future__ import annotations
2+
3+
import gzip
4+
from typing import TYPE_CHECKING
5+
from unittest import skipUnless
6+
7+
from prometheus_client import CollectorRegistry, Counter
8+
from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4
9+
10+
try:
11+
from aiohttp import ClientResponse, hdrs, web
12+
from aiohttp.test_utils import AioHTTPTestCase
13+
14+
from prometheus_client.aiohttp import make_aiohttp_handler
15+
16+
AIOHTTP_INSTALLED = True
17+
except ImportError:
18+
if TYPE_CHECKING:
19+
assert False
20+
21+
from unittest import IsolatedAsyncioTestCase as AioHTTPTestCase
22+
23+
AIOHTTP_INSTALLED = False
24+
25+
26+
class AioHTTPTest(AioHTTPTestCase):
27+
@skipUnless(AIOHTTP_INSTALLED, "AIOHTTP is not installed")
28+
def setUp(self) -> None:
29+
self.registry = CollectorRegistry()
30+
31+
async def get_application(self) -> web.Application:
32+
app = web.Application()
33+
# The AioHTTPTestCase requires that applications be static, so we need
34+
# both versions to be available so the test can choose between them
35+
app.router.add_get("/metrics", make_aiohttp_handler(self.registry))
36+
app.router.add_get(
37+
"/metrics_uncompressed",
38+
make_aiohttp_handler(self.registry, disable_compression=True),
39+
)
40+
return app
41+
42+
def increment_metrics(
43+
self,
44+
metric_name: str,
45+
help_text: str,
46+
increments: int,
47+
) -> None:
48+
c = Counter(metric_name, help_text, registry=self.registry)
49+
for _ in range(increments):
50+
c.inc()
51+
52+
def assert_metrics(
53+
self,
54+
output: str,
55+
metric_name: str,
56+
help_text: str,
57+
increments: int,
58+
) -> None:
59+
self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output)
60+
self.assertIn("# TYPE " + metric_name + "_total counter\n", output)
61+
self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output)
62+
63+
def assert_not_metrics(
64+
self,
65+
output: str,
66+
metric_name: str,
67+
help_text: str,
68+
increments: int,
69+
) -> None:
70+
self.assertNotIn("# HELP " + metric_name + "_total " + help_text + "\n", output)
71+
self.assertNotIn("# TYPE " + metric_name + "_total counter\n", output)
72+
self.assertNotIn(metric_name + "_total " + str(increments) + ".0\n", output)
73+
74+
async def assert_outputs(
75+
self,
76+
response: ClientResponse,
77+
metric_name: str,
78+
help_text: str,
79+
increments: int,
80+
) -> None:
81+
self.assertIn(
82+
CONTENT_TYPE_PLAIN_0_0_4,
83+
response.headers.getall(hdrs.CONTENT_TYPE),
84+
)
85+
output = await response.text()
86+
self.assert_metrics(output, metric_name, help_text, increments)
87+
88+
async def validate_metrics(
89+
self,
90+
metric_name: str,
91+
help_text: str,
92+
increments: int,
93+
) -> None:
94+
"""
95+
AIOHTTP handler serves the metrics from the provided registry.
96+
"""
97+
self.increment_metrics(metric_name, help_text, increments)
98+
async with self.client.get("/metrics") as response:
99+
response.raise_for_status()
100+
await self.assert_outputs(response, metric_name, help_text, increments)
101+
102+
async def test_report_metrics_1(self):
103+
await self.validate_metrics("counter", "A counter", 2)
104+
105+
async def test_report_metrics_2(self):
106+
await self.validate_metrics("counter", "Another counter", 3)
107+
108+
async def test_report_metrics_3(self):
109+
await self.validate_metrics("requests", "Number of requests", 5)
110+
111+
async def test_report_metrics_4(self):
112+
await self.validate_metrics("failed_requests", "Number of failed requests", 7)
113+
114+
async def test_gzip(self):
115+
# Increment a metric.
116+
metric_name = "counter"
117+
help_text = "A counter"
118+
increments = 2
119+
self.increment_metrics(metric_name, help_text, increments)
120+
121+
async with self.client.get(
122+
"/metrics",
123+
auto_decompress=False,
124+
headers={hdrs.ACCEPT_ENCODING: "gzip"},
125+
) as response:
126+
response.raise_for_status()
127+
self.assertIn(hdrs.CONTENT_ENCODING, response.headers)
128+
self.assertIn("gzip", response.headers.getall(hdrs.CONTENT_ENCODING))
129+
body = await response.read()
130+
output = gzip.decompress(body).decode("utf8")
131+
self.assert_metrics(output, metric_name, help_text, increments)
132+
133+
async def test_gzip_disabled(self):
134+
# Increment a metric.
135+
metric_name = "counter"
136+
help_text = "A counter"
137+
increments = 2
138+
self.increment_metrics(metric_name, help_text, increments)
139+
140+
async with self.client.get(
141+
"/metrics_uncompressed",
142+
auto_decompress=False,
143+
headers={hdrs.ACCEPT_ENCODING: "gzip"},
144+
) as response:
145+
response.raise_for_status()
146+
self.assertNotIn(hdrs.CONTENT_ENCODING, response.headers)
147+
output = await response.text()
148+
self.assert_metrics(output, metric_name, help_text, increments)
149+
150+
async def test_openmetrics_encoding(self):
151+
"""Response content type is application/openmetrics-text when appropriate Accept header is in request"""
152+
async with self.client.get(
153+
"/metrics",
154+
auto_decompress=False,
155+
headers={hdrs.ACCEPT: "application/openmetrics-text; version=1.0.0"},
156+
) as response:
157+
response.raise_for_status()
158+
self.assertEqual(
159+
response.headers.getone(hdrs.CONTENT_TYPE).split(";", maxsplit=1)[0],
160+
"application/openmetrics-text",
161+
)
162+
163+
async def test_plaintext_encoding(self):
164+
"""Response content type is text/plain when Accept header is missing in request"""
165+
async with self.client.get("/metrics") as response:
166+
response.raise_for_status()
167+
self.assertEqual(
168+
response.headers.getone(hdrs.CONTENT_TYPE).split(";", maxsplit=1)[0],
169+
"text/plain",
170+
)
171+
172+
async def test_qs_parsing(self):
173+
"""Only metrics that match the 'name[]' query string param appear"""
174+
175+
metrics = [("asdf", "first test metric", 1), ("bsdf", "second test metric", 2)]
176+
177+
for m in metrics:
178+
self.increment_metrics(*m)
179+
180+
for i_1 in range(len(metrics)):
181+
async with self.client.get(
182+
"/metrics",
183+
params={"name[]": f"{metrics[i_1][0]}_total"},
184+
) as response:
185+
output = await response.text()
186+
self.assert_metrics(output, *metrics[i_1])
187+
188+
for i_2 in range(len(metrics)):
189+
if i_1 == i_2:
190+
continue
191+
192+
self.assert_not_metrics(output, *metrics[i_2])

tests/test_asgi.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
1+
import asyncio
12
import gzip
2-
from unittest import skipUnless, TestCase
3+
from unittest import TestCase
34

4-
from prometheus_client import CollectorRegistry, Counter
5-
from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4
6-
7-
try:
8-
# Python >3.5 only
9-
import asyncio
5+
from asgiref.testing import ApplicationCommunicator
106

11-
from asgiref.testing import ApplicationCommunicator
12-
13-
from prometheus_client import make_asgi_app
14-
HAVE_ASYNCIO_AND_ASGI = True
15-
except ImportError:
16-
HAVE_ASYNCIO_AND_ASGI = False
7+
from prometheus_client import CollectorRegistry, Counter, make_asgi_app
8+
from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4
179

1810

1911
def setup_testing_defaults(scope):
@@ -33,7 +25,6 @@ def setup_testing_defaults(scope):
3325

3426

3527
class ASGITest(TestCase):
36-
@skipUnless(HAVE_ASYNCIO_AND_ASGI, "Don't have asyncio/asgi installed.")
3728
def setUp(self):
3829
self.registry = CollectorRegistry()
3930
self.captured_status = None

tox.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ envlist = coverage-clean,py{3.9,3.10,3.11,3.12,3.13,py3.9,3.9-nooptionals},cover
33

44
[testenv]
55
deps =
6+
asgiref
67
coverage
78
pytest
89
pytest-benchmark
910
attrs
1011
{py3.9,pypy3.9}: twisted
12+
{py3.9,pypy3.9}: aiohttp
1113
commands = coverage run --parallel -m pytest {posargs}
1214

1315
[testenv:py3.9-nooptionals]
@@ -44,6 +46,7 @@ commands =
4446
[testenv:mypy]
4547
deps =
4648
pytest
49+
aiohttp
4750
asgiref
4851
mypy==0.991
4952
skip_install = true

0 commit comments

Comments
 (0)