-
Notifications
You must be signed in to change notification settings - Fork 184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
For ResponseMetrics
middleware, improve view
tag and add tests
#4746
Changes from all commits
bbd3083
5ccee52
0647760
3579f66
95ffb36
134bc90
de59cc2
480236e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,10 @@ | ||
import re | ||
import time | ||
from collections.abc import Callable | ||
from datetime import UTC, datetime | ||
|
||
from django.conf import settings | ||
from django.http import HttpRequest, HttpResponse | ||
from django.shortcuts import redirect | ||
|
||
import markus | ||
|
@@ -51,24 +54,22 @@ def __call__(self, request): | |
return response | ||
|
||
|
||
def _get_metric_view_name(request): | ||
if request.resolver_match: | ||
view = request.resolver_match.func | ||
return f"{view.__module__}.{view.__name__}" | ||
return "<unknown_view>" | ||
class ResponseMetrics: | ||
|
||
re_dockerflow = re.compile(r"/__(version|heartbeat|lbheartbeat)__/?$") | ||
|
||
class ResponseMetrics: | ||
def __init__(self, get_response): | ||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: | ||
self.get_response = get_response | ||
self.middleware = RelayStaticFilesMiddleware() | ||
|
||
def __call__(self, request: HttpRequest) -> HttpResponse: | ||
if not settings.STATSD_ENABLED: | ||
return self.get_response(request) | ||
|
||
def __call__(self, request): | ||
start_time = time.time() | ||
response = self.get_response(request) | ||
delta = time.time() - start_time | ||
|
||
view_name = _get_metric_view_name(request) | ||
|
||
view_name = self._get_metric_view_name(request) | ||
metrics.timing( | ||
"response", | ||
value=delta * 1000.0, | ||
|
@@ -78,9 +79,21 @@ def __call__(self, request): | |
f"method:{request.method}", | ||
], | ||
) | ||
|
||
return response | ||
|
||
def _get_metric_view_name(self, request: HttpRequest) -> str: | ||
if request.resolver_match: | ||
view = request.resolver_match.func | ||
if hasattr(view, "view_class"): | ||
# Wrapped with rest_framework.decorators.api_view | ||
return f"{view.__module__}.{view.view_class.__name__}" | ||
return f"{view.__module__}.{view.__name__}" | ||
if match := self.re_dockerflow.match(request.path_info): | ||
return f"dockerflow.django.views.{match[1]}" | ||
if self.middleware.is_staticfile(request.path_info): | ||
return "<static_file>" | ||
return "<unknown_view>" | ||
|
||
|
||
class StoreFirstVisit: | ||
def __init__(self, get_response): | ||
|
@@ -120,3 +133,16 @@ def immutable_file_test(self, path, url): | |
return True | ||
else: | ||
return super().immutable_file_test(path, url) | ||
|
||
def is_staticfile(self, path_info: str) -> bool: | ||
""" | ||
Returns True if this file is served by the middleware. | ||
|
||
This uses the logic from whitenoise.middleware.WhiteNoiseMiddleware.__call__: | ||
https://github.com/evansd/whitenoise/blob/220a98894495d407424e80d85d49227a5cf97e1b/src/whitenoise/middleware.py#L117-L124 | ||
""" | ||
if self.autorefresh: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (suggestion, non-blocking): Since we are using data that can come from user input I recommend using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, that would change the logic from |
||
static_file = self.find_file(path_info) | ||
else: | ||
static_file = self.files.get(path_info) | ||
return static_file is not None |
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -349,16 +349,7 @@ | |||||||||||||||
] | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
# statsd middleware has to be first to catch errors in everything else | ||||||||||||||||
def _get_initial_middleware() -> list[str]: | ||||||||||||||||
if STATSD_ENABLED: | ||||||||||||||||
return [ | ||||||||||||||||
"privaterelay.middleware.ResponseMetrics", | ||||||||||||||||
] | ||||||||||||||||
return [] | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
MIDDLEWARE = _get_initial_middleware() | ||||||||||||||||
MIDDLEWARE = ["privaterelay.middleware.ResponseMetrics"] | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (suggestion, non-blocking) I agree with more simple logic on initial list of middlewares but not sure if the Lines 14 to 15 in 2d8973e
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I retained the spirit of the old logic by making the |
||||||||||||||||
|
||||||||||||||||
if USE_SILK: | ||||||||||||||||
MIDDLEWARE.append("silk.middleware.SilkyMiddleware") | ||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
"""Tests for Relay middlewares used in API and other server requests.""" | ||
|
||
from django.test import Client | ||
|
||
import pytest | ||
from markus.testing import MetricsMock | ||
from pytest_django.fixtures import SettingsWrapper | ||
|
||
|
||
@pytest.fixture | ||
def response_metrics_settings(settings: SettingsWrapper) -> SettingsWrapper: | ||
"""Setup settings for ResponseMetrics tests.""" | ||
# Use some middleware in the declared order | ||
use_middleware = { | ||
"privaterelay.middleware.ResponseMetrics", | ||
"privaterelay.middleware.RelayStaticFilesMiddleware", | ||
"dockerflow.django.middleware.DockerflowMiddleware", | ||
} | ||
settings.MIDDLEWARE = [mw for mw in settings.MIDDLEWARE if mw in use_middleware] | ||
settings.STATSD_ENABLED = True | ||
return settings | ||
|
||
|
||
def test_response_metrics_django_view( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""Django views emit the expected metric.""" | ||
with MetricsMock() as mm: | ||
response = client.get("/metrics-event") | ||
assert response.status_code == 405 | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=["status:405", "view:privaterelay.views.metrics_event", "method:GET"], | ||
) | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_response_metrics_dockerflow_heartbeat( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""The Dockerflow __heartbeat__ endpoint emits the expected metric.""" | ||
with MetricsMock() as mm: | ||
response = client.get("/__heartbeat__") | ||
# __heartbeat__ runs some security and connection tests | ||
# In some environments, a critical error results in a 500 | ||
# In others, a warning or better results in a 200 | ||
assert response.status_code in [200, 500] | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=[ | ||
f"status:{response.status_code}", | ||
"view:dockerflow.django.views.heartbeat", | ||
"method:GET", | ||
], | ||
) | ||
|
||
|
||
@pytest.mark.parametrize("viewname", ["version", "lbheartbeat"]) | ||
def test_response_metrics_other_dockerflow_view( | ||
client: Client, response_metrics_settings: SettingsWrapper, viewname: str | ||
) -> None: | ||
"""The other Dockerflow views emit the expected metrics.""" | ||
with MetricsMock() as mm: | ||
response = client.get(f"/__{viewname}__") | ||
assert response.status_code == 200 | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=[ | ||
"status:200", | ||
f"view:dockerflow.django.views.{viewname}", | ||
"method:GET", | ||
], | ||
) | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_response_metrics_api_viewset( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""API viewsets emit the expected metrics.""" | ||
with MetricsMock() as mm: | ||
response = client.get("/api/v1/users/") | ||
assert response.status_code == 401 | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=["status:401", "view:api.views.privaterelay.UserViewSet", "method:GET"], | ||
) | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_response_metrics_api_view( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""API functions wrapped in @api_view emit the expected metrics.""" | ||
with MetricsMock() as mm: | ||
response = client.get("/api/v1/runtime_data") | ||
assert response.status_code == 200 | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=["status:200", "view:api.views.privaterelay.runtime_data", "method:GET"], | ||
) | ||
|
||
|
||
def test_response_metrics_frontend_path( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""Frontend views emit the expected metrics.""" | ||
with MetricsMock() as mm: | ||
response = client.get("/faq/") | ||
# The file is found if the frontend build was done, the DEBUG setting, | ||
# if files were collected, etc. | ||
assert response.status_code in [200, 404] | ||
# Metrics are only emitted if found. | ||
if response.status_code == 200: | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=["status:200", "view:<static_file>", "method:GET"], | ||
) | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_response_metrics_frontend_file( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""Frontend files emit the expected metrics.""" | ||
with MetricsMock() as mm: | ||
response = client.get("/favicon.svg") | ||
# The file is found if the frontend build was done, the DEBUG setting, | ||
# if files were collected, etc. | ||
assert response.status_code in [200, 404] | ||
# Metrics are only emitted if found. | ||
if response.status_code == 200: | ||
mm.assert_timing_once( | ||
"fx.private.relay.response", | ||
tags=["status:200", "view:<static_file>", "method:GET"], | ||
) | ||
|
||
|
||
def test_response_metrics_disabled( | ||
client: Client, response_metrics_settings: SettingsWrapper | ||
) -> None: | ||
"""ResponseMetrics does not emit metrics when metrics are disabled.""" | ||
response_metrics_settings.STATSD_ENABLED = False | ||
with MetricsMock() as mm: | ||
response = client.get("/metrics-event") | ||
assert response.status_code == 405 | ||
assert not mm.get_records() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is where the middleware is disabled at run-time, if
STATSD_ENABLED
is False.