Skip to content
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

Merged
merged 8 commits into from
Jun 4, 2024
Merged
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
50 changes: 38 additions & 12 deletions privaterelay/middleware.py
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
Expand Down Expand Up @@ -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)
Comment on lines +66 to +67
Copy link
Member Author

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.


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,
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Copy link
Contributor

@say-yawn say-yawn Jun 3, 2024

Choose a reason for hiding this comment

The 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 shlex.quote on path_info.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static_file = self.find_file(path_info)
else:
static_file = self.files.get(path_info)
return static_file is not None
11 changes: 1 addition & 10 deletions privaterelay/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Copy link
Contributor

Choose a reason for hiding this comment

The 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 ResponseMetrics should be used in all environment. STATSD_ENABLED is False in local:

DJANGO_STATSD_ENABLED=False
STATSD_DEBUG=False

Suggested change
MIDDLEWARE = ["privaterelay.middleware.ResponseMetrics"]
if RELAY_CHANNEL == "local":
MIDDLEWARE = []
else:
MIDDLEWARE = ["privaterelay.middleware.ResponseMetrics"]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I retained the spirit of the old logic by making the ResponseMetrics middleware do nothing if STATSD_ENABLED is False. This reminds me, I have a TODO to enable STATSD_DEBUG locally..


if USE_SILK:
MIDDLEWARE.append("silk.middleware.SilkyMiddleware")
Expand Down
147 changes: 147 additions & 0 deletions privaterelay/tests/middleware_tests.py
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()
30 changes: 24 additions & 6 deletions privaterelay/tests/views_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,10 @@ def test_fxa_rp_events_password_change(
with MetricsMock() as mm:
response = client.get("/fxa-rp-events", HTTP_AUTHORIZATION=auth_header)

assert mm.get_records() == []
mm.assert_timing_once(
"fx.private.relay.response",
tags=["status:200", "view:privaterelay.views.fxa_rp_events", "method:GET"],
)
assert caplog.record_tuples == [
("request.summary", logging.INFO, ""),
]
Expand All @@ -403,7 +406,10 @@ def test_fxa_rp_events_password_change_slight_future_iat(
with MetricsMock() as mm:
response = client.get("/fxa-rp-events", HTTP_AUTHORIZATION=auth_header)

assert mm.get_records() == []
mm.assert_timing_once(
"fx.private.relay.response",
tags=["status:200", "view:privaterelay.views.fxa_rp_events", "method:GET"],
)
assert caplog.record_tuples == [
("request.summary", logging.INFO, ""),
]
Expand Down Expand Up @@ -434,7 +440,10 @@ def test_fxa_rp_events_password_change_far_future_iat(
with MetricsMock() as mm, pytest.raises(jwt.ImmatureSignatureError):
client.get("/fxa-rp-events", HTTP_AUTHORIZATION=auth_header)

assert mm.get_records() == []
mm.assert_timing_once(
"fx.private.relay.response",
tags=["status:500", "view:privaterelay.views.fxa_rp_events", "method:GET"],
)
assert caplog.record_tuples == [
("eventsinfo", logging.WARNING, "fxa_rp_event.future_iat"),
("request.summary", logging.ERROR, "The token is not yet valid (iat)"),
Expand Down Expand Up @@ -462,7 +471,10 @@ def test_fxa_rp_events_profile_change(
with MetricsMock() as mm:
response = client.get("/fxa-rp-events", HTTP_AUTHORIZATION=auth_header)

assert mm.get_records() == []
mm.assert_timing_once(
"fx.private.relay.response",
tags=["status:200", "view:privaterelay.views.fxa_rp_events", "method:GET"],
)
assert caplog.record_tuples == [
("eventsinfo", logging.INFO, "fxa_rp_event"),
("request.summary", logging.INFO, ""),
Expand Down Expand Up @@ -500,7 +512,10 @@ def test_fxa_rp_events_subscription_change(

with MetricsMock() as mm:
response = client.get("/fxa-rp-events", HTTP_AUTHORIZATION=auth_header)
assert mm.get_records() == []
mm.assert_timing_once(
"fx.private.relay.response",
tags=["status:200", "view:privaterelay.views.fxa_rp_events", "method:GET"],
)
assert caplog.record_tuples == [
("eventsinfo", logging.INFO, "fxa_rp_event"),
("request.summary", logging.INFO, ""),
Expand Down Expand Up @@ -538,7 +553,10 @@ def test_fxa_rp_events_delete_user(

with MetricsMock() as mm:
response = client.get("/fxa-rp-events", HTTP_AUTHORIZATION=auth_header)
assert mm.get_records() == []
mm.assert_timing_once(
"fx.private.relay.response",
tags=["status:200", "view:privaterelay.views.fxa_rp_events", "method:GET"],
)
assert caplog.record_tuples == [
("eventsinfo", logging.INFO, "fxa_rp_event"),
("request.summary", logging.INFO, ""),
Expand Down