Skip to content

Commit

Permalink
Feat: analytics for in-person eligibility/enrollment (#2402)
Browse files Browse the repository at this point in the history
  • Loading branch information
lalver1 authored Oct 1, 2024
2 parents 4545e70 + 0ca7d95 commit 14369bb
Show file tree
Hide file tree
Showing 9 changed files with 85 additions and 35 deletions.
4 changes: 3 additions & 1 deletion benefits/core/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class Event:
_counter = itertools.count()
_domain_re = re.compile(r"^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n?]+)", re.IGNORECASE)

def __init__(self, request, event_type, **kwargs):
def __init__(self, request, event_type, enrollment_method=models.EnrollmentMethods.DIGITAL, **kwargs):
self.app_version = VERSION
# device_id is generated based on the user_id, and both are set explicitly (per session)
self.device_id = session.did(request)
Expand All @@ -51,6 +51,7 @@ def __init__(self, request, event_type, **kwargs):
path=request.path,
transit_agency=agency_name,
eligibility_verifier=verifier_name,
enrollment_method=enrollment_method,
)

uagent = request.headers.get("user-agent")
Expand All @@ -65,6 +66,7 @@ def __init__(self, request, event_type, **kwargs):
user_agent=uagent,
transit_agency=agency_name,
eligibility_verifier=verifier_name,
enrollment_method=enrollment_method,
)

if flow:
Expand Down
30 changes: 16 additions & 14 deletions benefits/eligibility/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,46 @@
class EligibilityEvent(core.Event):
"""Base analytics event for eligibility verification."""

def __init__(self, request, event_type, flow: models.EnrollmentFlow):
super().__init__(request, event_type)
def __init__(self, request, event_type, flow: models.EnrollmentFlow, enrollment_method=models.EnrollmentMethods.DIGITAL):
super().__init__(request, event_type, enrollment_method)
# overwrite core.Event enrollment flow
self.update_enrollment_flows(flow)


class SelectedVerifierEvent(EligibilityEvent):
"""Analytics event representing the user selecting an enrollment flow."""

def __init__(self, request, flow: models.EnrollmentFlow):
super().__init__(request, "selected enrollment flow", flow)
def __init__(self, request, flow: models.EnrollmentFlow, enrollment_method=models.EnrollmentMethods.DIGITAL):
super().__init__(request, "selected enrollment flow", flow, enrollment_method)


class StartedEligibilityEvent(EligibilityEvent):
"""Analytics event representing the beginning of an eligibility verification check."""

def __init__(self, request, flow: models.EnrollmentFlow):
super().__init__(request, "started eligibility", flow)
def __init__(self, request, flow: models.EnrollmentFlow, enrollment_method=models.EnrollmentMethods.DIGITAL):
super().__init__(request, "started eligibility", flow, enrollment_method)


class ReturnedEligibilityEvent(EligibilityEvent):
"""Analytics event representing the end of an eligibility verification check."""

def __init__(self, request, flow: models.EnrollmentFlow, status, error=None):
super().__init__(request, "returned eligibility", flow)
def __init__(
self, request, flow: models.EnrollmentFlow, status, error=None, enrollment_method=models.EnrollmentMethods.DIGITAL
):
super().__init__(request, "returned eligibility", flow, enrollment_method)
status = str(status).lower()
if status in ("error", "fail", "success"):
self.update_event_properties(status=status, error=error)


def selected_verifier(request, flow: models.EnrollmentFlow):
def selected_verifier(request, flow: models.EnrollmentFlow, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "selected eligibility verifier" analytics event."""
core.send_event(SelectedVerifierEvent(request, flow))
core.send_event(SelectedVerifierEvent(request, flow, enrollment_method=enrollment_method))


def started_eligibility(request, flow: models.EnrollmentFlow):
def started_eligibility(request, flow: models.EnrollmentFlow, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "started eligibility" analytics event."""
core.send_event(StartedEligibilityEvent(request, flow))
core.send_event(StartedEligibilityEvent(request, flow, enrollment_method=enrollment_method))


def returned_error(request, flow: models.EnrollmentFlow, error):
Expand All @@ -58,6 +60,6 @@ def returned_fail(request, flow: models.EnrollmentFlow):
core.send_event(ReturnedEligibilityEvent(request, flow, status="fail"))


def returned_success(request, flow: models.EnrollmentFlow):
def returned_success(request, flow: models.EnrollmentFlow, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "returned eligibility" analytics event with a success status."""
core.send_event(ReturnedEligibilityEvent(request, flow, status="success"))
core.send_event(ReturnedEligibilityEvent(request, flow, enrollment_method=enrollment_method, status="success"))
30 changes: 17 additions & 13 deletions benefits/enrollment/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
The enrollment application: analytics implementation.
"""

from benefits.core import analytics as core
from benefits.core import analytics as core, models


class ReturnedEnrollmentEvent(core.Event):
"""Analytics event representing the end of transit processor enrollment request."""

def __init__(self, request, status, error=None, enrollment_group=None):
super().__init__(request, "returned enrollment")
def __init__(self, request, status, error=None, enrollment_group=None, enrollment_method=models.EnrollmentMethods.DIGITAL):
super().__init__(request, "returned enrollment", enrollment_method)
if str(status).lower() in ("error", "retry", "success"):
self.update_event_properties(status=status, error=error)
if enrollment_group is not None:
Expand All @@ -19,27 +19,31 @@ def __init__(self, request, status, error=None, enrollment_group=None):
class FailedAccessTokenRequestEvent(core.Event):
"""Analytics event representing a failure to acquire an access token for card tokenization."""

def __init__(self, request, status_code=None):
super().__init__(request, "failed access token request")
def __init__(self, request, status_code=None, enrollment_method=models.EnrollmentMethods.DIGITAL):
super().__init__(request, "failed access token request", enrollment_method)
if status_code is not None:
self.update_event_properties(status_code=status_code)


def returned_error(request, error):
def returned_error(request, error, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "returned enrollment" analytics event with an error status and message."""
core.send_event(ReturnedEnrollmentEvent(request, status="error", error=error))
core.send_event(ReturnedEnrollmentEvent(request, status="error", error=error, enrollment_method=enrollment_method))


def returned_retry(request):
def returned_retry(request, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "returned enrollment" analytics event with a retry status."""
core.send_event(ReturnedEnrollmentEvent(request, status="retry"))
core.send_event(ReturnedEnrollmentEvent(request, status="retry", enrollment_method=enrollment_method))


def returned_success(request, enrollment_group):
def returned_success(request, enrollment_group, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "returned enrollment" analytics event with a success status."""
core.send_event(ReturnedEnrollmentEvent(request, status="success", enrollment_group=enrollment_group))
core.send_event(
ReturnedEnrollmentEvent(
request, status="success", enrollment_group=enrollment_group, enrollment_method=enrollment_method
)
)


def failed_access_token_request(request, status_code=None):
def failed_access_token_request(request, status_code=None, enrollment_method: str = models.EnrollmentMethods.DIGITAL):
"""Send the "failed access token request" analytics event with the response status code."""
core.send_event(FailedAccessTokenRequestEvent(request, status_code=status_code))
core.send_event(FailedAccessTokenRequestEvent(request, status_code=status_code, enrollment_method=enrollment_method))
11 changes: 6 additions & 5 deletions benefits/enrollment/templates/enrollment/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ <h1 class="pb-lg-8 pb-4">
$("#{{ cta_button }}").on("click", function() {
amplitude.getInstance().logEvent(startedEvent, {
card_tokenize_url: "{{ card_tokenize_url }}",
card_tokenize_func: "{{ card_tokenize_func }}"
card_tokenize_func: "{{ card_tokenize_func }}",
enrollment_method: "{{ enrollment_method }}"
});
$(this).addClass("disabled").attr("aria-disabled", "true").text("{{ loading_text }}");
});
Expand All @@ -73,7 +74,7 @@ <h1 class="pb-lg-8 pb-4">
/* This function executes when the
/* card verification returns
/* successfully with a token from enrollment server */
amplitude.getInstance().logEvent(closedEvent, {status: "success"});
amplitude.getInstance().logEvent(closedEvent, {status: "success", enrollment_method: "{{ enrollment_method }}"});

var form = $("form#{{ form_success }}");
$("#{{ token_field }}", form).val(response.token);
Expand All @@ -83,7 +84,7 @@ <h1 class="pb-lg-8 pb-4">
/* This function executes when the
/* card verification fails and server
/* return verification failure message */
amplitude.getInstance().logEvent(closedEvent, {status: "fail"});
amplitude.getInstance().logEvent(closedEvent, {status: "fail", enrollment_method: "{{ enrollment_method }}"});

var form = $("form#{{ form_retry }}");
form.submit();
Expand All @@ -92,7 +93,7 @@ <h1 class="pb-lg-8 pb-4">
/* This function executes when the
/* server returns error or token is invalid.
/* 400 or 500 will return. */
amplitude.getInstance().logEvent(closedEvent, {status: "error", error: response});
amplitude.getInstance().logEvent(closedEvent, {status: "error", error: response, enrollment_method: "{{ enrollment_method }}"});

if (response.status >= 500) {
var form = $("form#{{ form_system_error }}");
Expand All @@ -105,7 +106,7 @@ <h1 class="pb-lg-8 pb-4">
/* This function executes when the
/* user cancels and closes the window
/* and returns to home page. */
amplitude.getInstance().logEvent(closedEvent, {status: "cancel"});
amplitude.getInstance().logEvent(closedEvent, {status: "cancel", enrollment_method: "{{ enrollment_method }}"});

return location.reload();
}
Expand Down
1 change: 1 addition & 0 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def index(request):
"card_tokenize_env": agency.transit_processor.card_tokenize_env,
"card_tokenize_func": agency.transit_processor.card_tokenize_func,
"card_tokenize_url": agency.transit_processor.card_tokenize_url,
"enrollment_method": models.EnrollmentMethods.DIGITAL,
"token_field": "card_token",
"form_retry": tokenize_retry_form.id,
"form_server_error": tokenize_server_error_form.id,
Expand Down
14 changes: 14 additions & 0 deletions benefits/in_person/templates/in_person/enrollment.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ <h2 class="p-0 m-0 text-left">In-person enrollment</h2>
</div>

<script nonce="{{ request.csp_nonce }}">
var startedEvent = "started card tokenization", closedEvent = "ended card tokenization";

$.ajax({ dataType: "script", attrs: { nonce: "{{ request.csp_nonce }}"}, url: "{{ card_tokenize_url }}" })
.done(function() {
Expand All @@ -55,6 +56,15 @@ <h2 class="p-0 m-0 text-left">In-person enrollment</h2>
$(".loading").addClass("d-none");
$(".invisible").removeClass("invisible").addClass("visible");

$("#{{ cta_button }}").on("click", function() {
amplitude.getInstance().logEvent(startedEvent, {
card_tokenize_url: "{{ card_tokenize_url }}",
card_tokenize_func: "{{ card_tokenize_func }}",
enrollment_method: "{{ enrollment_method }}"
});
$(this).addClass("disabled").attr("aria-disabled", "true").text("{{ loading_text }}");
});

new Promise((resolve) => {
{{ card_tokenize_func }}({
authorization: data.token,
Expand All @@ -68,6 +78,7 @@ <h2 class="p-0 m-0 text-left">In-person enrollment</h2>
/* This function executes when the
/* card verification returns
/* successfully with a token from enrollment server */
amplitude.getInstance().logEvent(closedEvent, {status: "success", enrollment_method: "{{ enrollment_method }}"});

// hide the iframe
$(".visible").removeClass("visible").addClass("invisible");
Expand All @@ -84,6 +95,7 @@ <h2 class="p-0 m-0 text-left">In-person enrollment</h2>
/* This function executes when the
/* card verification fails and server
/* return verification failure message */
amplitude.getInstance().logEvent(closedEvent, {status: "fail", enrollment_method: "{{ enrollment_method }}"});

var form = $("form#{{ form_retry }}");
form.submit();
Expand All @@ -92,6 +104,7 @@ <h2 class="p-0 m-0 text-left">In-person enrollment</h2>
/* This function executes when the
/* server returns error or token is invalid.
/* 400 or 500 will return. */
amplitude.getInstance().logEvent(closedEvent, {status: "error", error: response, enrollment_method: "{{ enrollment_method }}"});

if (response.status >= 500) {
var form = $("form#{{ form_system_error }}");
Expand All @@ -104,6 +117,7 @@ <h2 class="p-0 m-0 text-left">In-person enrollment</h2>
/* This function executes when the
/* user cancels and closes the window
/* and returns to home page. */
amplitude.getInstance().logEvent(closedEvent, {status: "cancel", enrollment_method: "{{ enrollment_method }}"});

return location.reload();
}
Expand Down
27 changes: 26 additions & 1 deletion benefits/in_person/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from benefits.routes import routes
from benefits.core import models, session
from benefits.eligibility import analytics as eligibility_analytics
from benefits.enrollment import analytics as enrollment_analytics
from benefits.enrollment.enrollment import Status, request_card_tokenization_access, enroll

from benefits.in_person import forms
Expand All @@ -34,6 +36,8 @@ def eligibility(request):
flow_id = form.cleaned_data.get("flow")
flow = models.EnrollmentFlow.objects.get(id=flow_id)
session.update(request, flow=flow)
eligibility_analytics.selected_verifier(request, flow, enrollment_method=models.EnrollmentMethods.IN_PERSON)
eligibility_analytics.started_eligibility(request, flow, enrollment_method=models.EnrollmentMethods.IN_PERSON)

in_person_enrollment = reverse(routes.IN_PERSON_ENROLLMENT)
response = redirect(in_person_enrollment)
Expand All @@ -56,6 +60,9 @@ def token(request):
elif response.status is Status.SYSTEM_ERROR or response.status is Status.EXCEPTION:
logger.debug("Error occurred while requesting access token", exc_info=response.exception)
sentry_sdk.capture_exception(response.exception)
enrollment_analytics.failed_access_token_request(
request, response.status_code, enrollment_method=models.EnrollmentMethods.IN_PERSON
)

if response.status is Status.SYSTEM_ERROR:
redirect = reverse(routes.IN_PERSON_ENROLLMENT_SYSTEM_ERROR)
Expand All @@ -78,13 +85,14 @@ def enrollment(request):
if not form.is_valid():
raise Exception("Invalid card token form")

flow = session.flow(request)
eligibility_analytics.returned_success(request, flow, enrollment_method=models.EnrollmentMethods.IN_PERSON)
card_token = form.cleaned_data.get("card_token")
status, exception = enroll(request, card_token)

match (status):
case Status.SUCCESS:
agency = session.agency(request)
flow = session.flow(request)
expiry = session.enrollment_expiry(request)
verified_by = f"{request.user.first_name} {request.user.last_name}"
event = models.EnrollmentEvent.objects.create(
Expand All @@ -95,17 +103,29 @@ def enrollment(request):
expiration_datetime=expiry,
)
event.save()
enrollment_analytics.returned_success(
request, flow.group_id, enrollment_method=models.EnrollmentMethods.IN_PERSON
)
return redirect(routes.IN_PERSON_ENROLLMENT_SUCCESS)

case Status.SYSTEM_ERROR:
enrollment_analytics.returned_error(
request, str(exception), enrollment_method=models.EnrollmentMethods.IN_PERSON
)
sentry_sdk.capture_exception(exception)
return redirect(routes.IN_PERSON_ENROLLMENT_SYSTEM_ERROR)

case Status.EXCEPTION:
enrollment_analytics.returned_error(
request, str(exception), enrollment_method=models.EnrollmentMethods.IN_PERSON
)
sentry_sdk.capture_exception(exception)
return redirect(routes.IN_PERSON_SERVER_ERROR)

case Status.REENROLLMENT_ERROR:
enrollment_analytics.returned_error(
request, "Re-enrollment error.", enrollment_method=models.EnrollmentMethods.IN_PERSON
)
return redirect(routes.IN_PERSON_ENROLLMENT_REENROLLMENT_ERROR)
# GET enrollment index
else:
Expand All @@ -129,6 +149,7 @@ def enrollment(request):
"card_tokenize_env": agency.transit_processor.card_tokenize_env,
"card_tokenize_func": agency.transit_processor.card_tokenize_func,
"card_tokenize_url": agency.transit_processor.card_tokenize_url,
"enrollment_method": models.EnrollmentMethods.IN_PERSON,
"token_field": "card_token",
"form_retry": tokenize_retry_form.id,
"form_server_error": tokenize_server_error_form.id,
Expand Down Expand Up @@ -157,6 +178,10 @@ def reenrollment_error(request):

def retry(request):
"""View handler for card verification failure."""
# enforce POST-only route for sending analytics
if request.method == "POST":
enrollment_analytics.returned_retry(request, enrollment_method=models.EnrollmentMethods.IN_PERSON)

agency = session.agency(request)
context = {
**admin_site.each_context(request),
Expand Down
1 change: 1 addition & 0 deletions benefits/templates/admin/agency-base.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">
<link href="{% static "css/admin/styles.css" %}" rel="stylesheet">
{% include "core/includes/analytics.html" with api_key=analytics.api_key uid=analytics.uid did=analytics.did %}
{% endblock extrastyle %}

{% block branding %}
Expand Down
2 changes: 1 addition & 1 deletion tests/pytest/in_person/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def test_token_connection_error(mocker, admin_client, mocked_sentry_sdk_module):


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency")
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow")
def test_enrollment_logged_in_get(admin_client):
path = reverse(routes.IN_PERSON_ENROLLMENT)

Expand Down

0 comments on commit 14369bb

Please sign in to comment.