Repository: cal-itp/benefits

Issue:
Don't send events for healthchecks
We use Amplitude to gather event analytics for specific user events in the app, like visiting a page and clicking a button. 

Amplitude recently announced a change in their billing model, away from straight per-event to a "Monthly Tracked Users" (MTU) metric. Read more on their blog post: https://help.amplitude.com/hc/en-us/articles/12217810210331

This presents a problem for us, because every time the various healthchecks (`AlwaysOn`, `Edge Health Probe`, maybe others?) ping the app, a pageview event is sent to Amplitude, and each of these pings is counted as a distinct MTU. 

## Expected behavior

- [ ] Amplitude does not receive events for views from the various healthchecks.

## Options

1. Reconfigure all the healthchecks to hit our `/healthcheck` endpoint, which already bypasses Amplitude events
2. Amend the Amplitude event sending logic to skip when e.g. certain user agents are present
3. Something else?

## Additional context

Right now within Amplitude, we filter events from certain User Agents so these don't show up in our charts etc. But they are still counted towards our plan's MTU metric:

<img width="712" alt="image" src="https://user-images.githubusercontent.com/1783439/219169446-902e8b62-efae-44c0-81ed-d0154966c8ed.png">

Unfortunately the "Block" filters available in our plan don't allow blocking events based on anything other than event name or IP address. 



Discussion:
From Azure docs on [Configure apps](https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#configure-general-settings), it appears we cannot reconfigure the endpoint for `AlwaysOn`.

> - **Always On**: Keeps the app loaded even when there's no traffic. When Always On is not turned on (default), the app is unloaded after 20 minutes without any incoming requests. The unloaded app can cause high latency for new requests because of its warm-up time. _When Always On is turned on, the front-end load balancer sends a GET request to the application root every five minutes_. The continuous ping prevents the app from being unloaded.

Resolving patch:
diff --git a/benefits/__init__.py b/benefits/__init__.py
--- a/benefits/__init__.py
+++ b/benefits/__init__.py
@@ -1,3 +1,3 @@
-__version__ = "2023.01.1"
+__version__ = "2023.02.1"
 
 VERSION = __version__
diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py
--- a/benefits/core/middleware.py
+++ b/benefits/core/middleware.py
@@ -109,6 +109,18 @@ def __call__(self, request):
         return self.get_response(request)
 
 
+class HealthcheckUserAgents(MiddlewareMixin):
+    """Middleware to return healthcheck for user agents specified in HEALTHCHECK_USER_AGENTS."""
+
+    def process_request(self, request):
+        if hasattr(request, "META"):
+            user_agent = request.META.get("HTTP_USER_AGENT", "")
+            if user_agent in settings.HEALTHCHECK_USER_AGENTS:
+                return HttpResponse("Healthy", content_type="text/plain")
+
+        return self.get_response(request)
+
+
 class VerifierSessionRequired(MiddlewareMixin):
     """Middleware raises an exception for sessions lacking an eligibility verifier configuration."""
 
diff --git a/benefits/settings.py b/benefits/settings.py
--- a/benefits/settings.py
+++ b/benefits/settings.py
@@ -49,6 +49,7 @@ def _filter_empty(ls):
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.locale.LocaleMiddleware",
     "benefits.core.middleware.Healthcheck",
+    "benefits.core.middleware.HealthcheckUserAgents",
     "django.middleware.common.CommonMiddleware",
     "django.middleware.csrf.CsrfViewMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -67,6 +68,7 @@ def _filter_empty(ls):
 if DEBUG:
     MIDDLEWARE.append("benefits.core.middleware.DebugSession")
 
+HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
 
 # Azure Insights
 # https://docs.microsoft.com/en-us/azure/azure-monitor/app/opencensus-python-request#tracking-django-applications


Test patch:
diff --git a/tests/pytest/core/test_middleware_healthcheck_user_agent.py b/tests/pytest/core/test_middleware_healthcheck_user_agent.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/core/test_middleware_healthcheck_user_agent.py
@@ -0,0 +1,36 @@
+from django.test import Client
+from django.urls import reverse
+
+import pytest
+
+from benefits.core import session
+from benefits.core.models import TransitAgency
+from benefits.core.views import ROUTE_INDEX, TEMPLATE_PAGE
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("user_agent", ["AlwaysOn", "Edge Health Probe"])
+def test_healthcheck_user_agent(mocker, user_agent):
+    mocker.patch.object(session.settings, "HEALTHCHECK_USER_AGENTS", [user_agent])
+    client = Client(HTTP_USER_AGENT=user_agent)
+
+    response = client.get(reverse(ROUTE_INDEX))
+
+    assert response.status_code == 200
+    assert response.content.decode("UTF-8") == "Healthy"
+
+
+@pytest.mark.django_db
+def test_non_healthcheck_user_agent(mocker, model_TransitAgency, client):
+    # create another Transit Agency by cloning the original to ensure there are multiple
+    # https://stackoverflow.com/a/48149675/453168
+    new_agency = TransitAgency.objects.get(pk=model_TransitAgency.id)
+    new_agency.pk = None
+    new_agency.save()
+
+    mocker.patch.object(session.settings, "HEALTHCHECK_USER_AGENTS", ["AlwaysOn"])
+
+    response = client.get(reverse(ROUTE_INDEX))
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_PAGE