Skip to content

Commit c2e78d5

Browse files
committed
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 <lexi@lexi.org.uk>
1 parent 5a1d580 commit c2e78d5

File tree

7 files changed

+249
-2
lines changed

7 files changed

+249
-2
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 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+
```

prometheus_client/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from .exposition import (
88
CONTENT_TYPE_LATEST, CONTENT_TYPE_PLAIN_0_0_4, CONTENT_TYPE_PLAIN_1_0_0,
99
delete_from_gateway, generate_latest, instance_ip_grouping_key,
10-
make_asgi_app, make_wsgi_app, MetricsHandler, push_to_gateway,
11-
pushadd_to_gateway, start_http_server, start_wsgi_server,
10+
make_aiohttp_handler, make_asgi_app, make_wsgi_app, MetricsHandler,
11+
push_to_gateway, pushadd_to_gateway, start_http_server, start_wsgi_server,
1212
write_to_textfile,
1313
)
1414
from .gc_collector import GC_COLLECTOR, GCCollector
@@ -39,6 +39,7 @@
3939
'generate_latest',
4040
'MetricsHandler',
4141
'make_wsgi_app',
42+
'make_aiohttp_handler',
4243
'make_asgi_app',
4344
'start_http_server',
4445
'start_wsgi_server',

prometheus_client/aiohttp.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from .exposition import _bake_output
6+
from .registry import CollectorRegistry, REGISTRY
7+
8+
try:
9+
from aiohttp import hdrs, web
10+
from aiohttp.typedefs import Handler
11+
except ImportError:
12+
if TYPE_CHECKING:
13+
assert False, "thanks mypy"
14+
15+
16+
def make_aiohttp_handler(
17+
registry: CollectorRegistry = REGISTRY,
18+
disable_compression: bool = False,
19+
) -> Handler:
20+
"""Create a aiohttp handler which serves the metrics from a registry."""
21+
22+
async def prometheus_handler(request: web.Request) -> web.Response:
23+
# Prepare parameters
24+
params = {key: request.query.getall(key) for key in request.query.keys()}
25+
accept_header = ",".join(request.headers.getall(hdrs.ACCEPT, []))
26+
accept_encoding_header = ""
27+
# Bake output
28+
status, headers, output = _bake_output(
29+
registry,
30+
accept_header,
31+
accept_encoding_header,
32+
params,
33+
# use AIOHTTP's compression
34+
disable_compression=True,
35+
)
36+
response = web.Response(
37+
status=int(status.split(" ")[0]),
38+
headers=headers,
39+
body=output,
40+
)
41+
if not disable_compression:
42+
response.enable_compression()
43+
return response
44+
45+
return prometheus_handler

prometheus_client/exposition.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
'delete_from_gateway',
3232
'generate_latest',
3333
'instance_ip_grouping_key',
34+
'make_aiohttp_handler',
3435
'make_asgi_app',
3536
'make_wsgi_app',
3637
'MetricsHandler',
@@ -762,4 +763,5 @@ def instance_ip_grouping_key() -> Dict[str, Any]:
762763
return {'instance': s.getsockname()[0]}
763764

764765

766+
from .aiohttp import make_aiohttp_handler # noqa
765767
from .asgi import make_asgi_app # noqa

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

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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+
aiohttp
67
asgiref
78
coverage
89
pytest
@@ -45,6 +46,7 @@ commands =
4546
[testenv:mypy]
4647
deps =
4748
pytest
49+
aiohttp
4850
asgiref
4951
mypy==0.991
5052
skip_install = true

0 commit comments

Comments
 (0)