Skip to content

Commit

Permalink
Add option to group similar status codes
Browse files Browse the repository at this point in the history
  • Loading branch information
claws committed Sep 21, 2021
1 parent 1d84221 commit c60d91c
Show file tree
Hide file tree
Showing 5 changed files with 341 additions and 6 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@
- Updated CI to support uploading code coverage results to CodeCov.
Updated documentation to display codecov status badge.

- Added option to ASGI middleware that allows response status codes to
be grouped. For example, status codes 200, 201, etc will all be reported
under the group 2xx. Similar situation for 3xx, 4xx, 5xx.

- Added test that confirms the default ASGI metrics tracking exceptions
raised by user handler functions does not work for Quart. Added information
to user guide stating this.

- Added information to user guide for developers writing unit tests to be
familiar with ``REGISTRY.clear()`` that will reset the default metrics
registry to an empty state to avoid errors related to identical metrics
attempting to be registered with the default registry.

## 21.9.0

- Streamline the aioprometheus API so that metrics are automatically registered
Expand Down
18 changes: 18 additions & 0 deletions docs/user/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,11 @@ The ASGI middleware provides a default set of metrics that include counters
for total requests received, total responses sent, exceptions raised and
response status codes for route handlers.

.. note::

Exceptions are not propagated to the ASGI layer by the Quart framework
so the default metric tracking exceptions does not work for Quart.

The middleware excludes a set of common paths such as '/favicon.ico',
'/metrics' and some others from triggering updates to the default metrics.
The complete set is defined in ``aioprometheus.agsi.middleware.EXCLUDE_PATHS``.
Expand Down Expand Up @@ -525,3 +530,16 @@ Run Prometheus and pass it the configuration file.
Once Prometheus is running you can access at `localhost:9090 <http://localhost:9090/>`_
and can observe the metrics from the example.


Testing
-------

When producing unit tests for software that uses `aioprometheus` it will
likely be necessary to clear the default registry between test runs to get
it back to a clean state. Failing to do this will likely result in an error
being raised reporting that a metric by the same name already exists.

Reseting the deafult registry is easily achieved by calling
``REGISTRY.clear()``. See the unit tests of this project for examples of
where this is done.
26 changes: 20 additions & 6 deletions src/aioprometheus/asgi/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

EXCLUDE_PATHS = (
"/metrics",
"/metrics/",
"/docs",
"/openapi.json",
"/docs/oauth2-redirect",
Expand Down Expand Up @@ -44,6 +45,11 @@ class MetricsMiddleware:
actual route url as they allow the route handler to be easily
identified. This feature is only supported with Starlette / FastAPI
currently.
:param group_status_codes: A boolean that defines whether status codes
should be grouped under a value representing that code kind. For
example, 200, 201, etc will all be grouped under 2xx. The default value
is False which means that status codes are not grouped.
"""

def __init__(
Expand All @@ -52,6 +58,7 @@ def __init__(
registry: Registry = REGISTRY,
exclude_paths: Sequence[str] = EXCLUDE_PATHS,
use_template_urls: bool = True,
group_status_codes: bool = False,
) -> None:
# The 'app' argument really represents an ASGI framework callable.
self.asgi_callable = app
Expand All @@ -64,6 +71,7 @@ def __init__(

self.exclude_paths = exclude_paths if exclude_paths else []
self.use_template_urls = use_template_urls
self.group_status_codes = group_status_codes

if registry is not None and not isinstance(registry, Registry):
raise Exception(f"registry must be a Registry, got: {type(registry)}")
Expand All @@ -72,20 +80,21 @@ def __init__(
# Create default metrics

self.requests_counter = Counter(
"requests_total_counter", "Total requests by method and path"
"requests_total_counter", "Total number of requests received"
)

self.responses_counter = Counter(
"responses_total_counter", "Total responses by method and path"
"responses_total_counter", "Total number of responses sent"
)

self.exceptions_counter = Counter(
"exceptions_total_counter", "Total exceptions by method and path"
"exceptions_total_counter",
"Total number of requested which generated an exception",
)

self.status_codes_counter = Counter(
"status_codes_counter",
"Total count of response status codes by method and path",
"Total number of response status codes",
)

async def __call__(self, scope: Scope, receive: Receive, send: Send):
Expand All @@ -108,7 +117,10 @@ def wrapped_send(response):

if response["type"] == "http.response.start":
status_code_labels = labels.copy()
status_code_labels["status_code"] = response["status"]
status_code = str(response["status"])
status_code_labels["status_code"] = (
f"{status_code[0]}xx" if self.group_status_codes else status_code
)
self.status_codes_counter.inc(status_code_labels)
self.responses_counter.inc(labels)

Expand All @@ -133,7 +145,9 @@ def wrapped_send(response):
self.exceptions_counter.inc(labels)

status_code_labels = labels.copy()
status_code_labels["status_code"] = 500
status_code_labels["status_code"] = (
"5xx" if self.group_status_codes else "500"
)
self.status_codes_counter.inc(status_code_labels)
self.responses_counter.inc(labels)

Expand Down
156 changes: 156 additions & 0 deletions tests/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import List

from aioprometheus import REGISTRY, Counter, MetricsMiddleware, formats, render
from aioprometheus.asgi.middleware import EXCLUDE_PATHS
from aioprometheus.asgi.starlette import metrics

try:
Expand Down Expand Up @@ -328,3 +329,158 @@ async def get_user(user_id: str):
self.assertIn(
'responses_total_counter{method="GET",path="/users/alice"} 1', response.text
)

def test_asgi_middleware_group_status_codes_enabled(self):
"""check ASGI middleware group status codes usage in FastAPI app"""

app = FastAPI()

app.add_middleware(MetricsMiddleware, group_status_codes=True)
app.add_route("/metrics", metrics)

@app.get("/users/{user_id}")
async def get_user(user_id: str):
return f"{user_id}"

# Add a route that always generates an exception
@app.get("/boom")
async def hello():
raise Exception("Boom")

# The test client also starts the web service
test_client = TestClient(app)

# Access root to increment metric counter
response = test_client.get("/users/bob")
self.assertEqual(response.status_code, 200)

# Get default format
response = test_client.get("/metrics", headers={"accept": "*/*"})
self.assertEqual(response.status_code, 200)
self.assertIn(
formats.text.TEXT_CONTENT_TYPE,
response.headers.get("content-type"),
)

# Check content
self.assertIn(
'requests_total_counter{method="GET",path="/users/bob"} 1', response.text
)
self.assertIn(
'status_codes_counter{method="GET",path="/users/bob",status_code="2xx"} 1',
response.text,
)
self.assertIn(
'responses_total_counter{method="GET",path="/users/bob"} 1', response.text
)

# Access it again to confirm default metrics get incremented
response = test_client.get("/users/alice")
self.assertEqual(response.status_code, 200)

# Get text format
response = test_client.get("/metrics", headers={"accept": "*/*"})
self.assertEqual(response.status_code, 200)
self.assertIn(
formats.text.TEXT_CONTENT_TYPE,
response.headers.get("content-type"),
)

# Check content
self.assertIn(
'requests_total_counter{method="GET",path="/users/bob"} 1', response.text
)
self.assertIn(
'requests_total_counter{method="GET",path="/users/alice"} 1', response.text
)
self.assertIn(
'status_codes_counter{method="GET",path="/users/bob",status_code="2xx"} 1',
response.text,
)
self.assertIn(
'status_codes_counter{method="GET",path="/users/alice",status_code="2xx"} 1',
response.text,
)
self.assertIn(
'responses_total_counter{method="GET",path="/users/bob"} 1', response.text
)
self.assertIn(
'responses_total_counter{method="GET",path="/users/alice"} 1', response.text
)

# Access boom route to trigger exception metric update
with self.assertRaises(Exception):
response = test_client.get("/boom")
self.assertEqual(response.status_code, 500)

response = test_client.get("/metrics", headers={"accept": "*/*"})
self.assertEqual(response.status_code, 200)
self.assertIn(
formats.text.TEXT_CONTENT_TYPE,
response.headers.get("content-type"),
)

# Check exception counter was incremented
self.assertIn(
'exceptions_total_counter{method="GET",path="/boom"} 1', response.text
)
self.assertIn(
'status_codes_counter{method="GET",path="/boom",status_code="5xx"} 1',
response.text,
)

def test_asgi_middleware_default_exclude_paths(self):
"""check ASGI middleware default exclude paths usage in FastAPI app"""

app = FastAPI()

app.add_middleware(MetricsMiddleware)
app.add_route("/metrics", metrics)

# The test client also starts the web service
test_client = TestClient(app)

# Access each of the paths that are ignored by the default metrics
for path in EXCLUDE_PATHS:
response = test_client.get("/metrics", headers={"accept": "*/*"})
self.assertEqual(response.status_code, 200)

# Check that ignored content is not present
response = test_client.get("/metrics", headers={"accept": "*/*"})
self.assertEqual(response.status_code, 200)
self.assertIn(
formats.text.TEXT_CONTENT_TYPE,
response.headers.get("content-type"),
)

for path in EXCLUDE_PATHS:
self.assertNotIn(f'path="{path}"', response.text)

def test_asgi_middleware_disable_exclude_paths(self):
"""check ASGI middleware with exclude paths disabled in FastAPI app"""

app = FastAPI()

app.add_middleware(MetricsMiddleware, exclude_paths=None)
app.add_route("/metrics", metrics)

# The test client also starts the web service
test_client = TestClient(app)

# Access each of the paths that are ignored by the default metrics
for path in EXCLUDE_PATHS:
print(path)
response = test_client.get(path, headers={"accept": "*/*"})
# items such as favicon.ico are not present so will return a 404
self.assertIn(response.status_code, [200, 404])

# Check that ignored content is not present
response = test_client.get("/metrics", headers={"accept": "*/*"})
self.assertEqual(response.status_code, 200)
self.assertIn(
formats.text.TEXT_CONTENT_TYPE,
response.headers.get("content-type"),
)

for path in EXCLUDE_PATHS:
self.assertIn(f'path="{path}"', response.text)

0 comments on commit c60d91c

Please sign in to comment.