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

Refactor: analytics for EnrollmentFlow #2379

Merged
merged 6 commits into from
Sep 23, 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
19 changes: 14 additions & 5 deletions benefits/core/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import requests

from benefits import VERSION
from . import session
from . import models, session


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -45,13 +45,11 @@ def __init__(self, request, event_type, **kwargs):
agency = session.agency(request)
agency_name = agency.long_name if agency else None
flow = session.flow(request)
verifier_name = flow.system_name if flow else None
eligibility_types = [flow.system_name] if flow else None
verifier_name = flow.eligibility_verifier if flow else None

self.update_event_properties(
path=request.path,
transit_agency=agency_name,
eligibility_types=eligibility_types,
eligibility_verifier=verifier_name,
)

Expand All @@ -66,10 +64,12 @@ def __init__(self, request, event_type, **kwargs):
referring_domain=refdom,
user_agent=uagent,
transit_agency=agency_name,
eligibility_types=eligibility_types,
eligibility_verifier=verifier_name,
)

if flow:
self.update_enrollment_flows(flow)

# event is initialized, consume next counter
self.event_id = next(Event._counter)

Expand All @@ -84,6 +84,15 @@ def update_user_properties(self, **kwargs):
"""Merge kwargs into the self.user_properties dict."""
self.user_properties.update(kwargs)

def update_enrollment_flows(self, flow: models.EnrollmentFlow):
enrollment_flows = [flow.system_name]
self.update_event_properties(
enrollment_flows=enrollment_flows,
)
self.update_user_properties(
enrollment_flows=enrollment_flows,
)


class ViewedPageEvent(Event):
"""Analytics event representing a single page view."""
Expand Down
11 changes: 11 additions & 0 deletions benefits/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ def uses_claims_verification(self):
"""True if this flow verifies via the claims provider and has a scope and claim. False otherwise."""
return self.claims_provider is not None and bool(self.claims_scope) and bool(self.claims_claim)

@property
def eligibility_verifier(self):
"""A str representing the entity that verifies eligibility for this flow.

Either the client name of the flow's claims provider, or the URL to the eligibility API.
"""
if self.uses_claims_verification:
return self.claims_provider.client_name
else:
return self.eligibility_api_url

def eligibility_form_instance(self, *args, **kwargs):
"""Return an instance of this flow's EligibilityForm, or None."""
if not bool(self.eligibility_form_class):
Expand Down
47 changes: 21 additions & 26 deletions benefits/eligibility/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,62 @@
The eligibility application: analytics implementation.
"""

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


class EligibilityEvent(core.Event):
"""Base analytics event for eligibility verification."""

def __init__(self, request, event_type, flow_name):
def __init__(self, request, event_type, flow: models.EnrollmentFlow):
super().__init__(request, event_type)
# pass a (converted from string to list) flow_name to preserve analytics reporting
eligibility_types = [flow_name]
# overwrite core.Event eligibility_types
self.update_event_properties(eligibility_types=eligibility_types)
self.update_user_properties(eligibility_types=eligibility_types)
# overwrite core.Event enrollment flow
self.update_enrollment_flows(flow)


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

def __init__(self, request, eligibility_types):
super().__init__(request, "selected eligibility verifier", eligibility_types)
def __init__(self, request, flow: models.EnrollmentFlow):
super().__init__(request, "selected enrollment flow", flow)


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

def __init__(self, request, eligibility_types):
super().__init__(request, "started eligibility", eligibility_types)
def __init__(self, request, flow: models.EnrollmentFlow):
super().__init__(request, "started eligibility", flow)


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

def __init__(self, request, eligibility_types, status, error=None):
super().__init__(request, "returned eligibility", eligibility_types)
def __init__(self, request, flow: models.EnrollmentFlow, status, error=None):
super().__init__(request, "returned eligibility", flow)
status = str(status).lower()
if status in ("error", "fail", "success"):
self.update_event_properties(status=status, error=error)
if status == "success":
self.update_user_properties(eligibility_types=eligibility_types)
Comment on lines -42 to -43
Copy link
Member

Choose a reason for hiding this comment

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

Just noting for future reference that now we'll capture the enrollment flow as a user property for all ReturnedEligibilityEvents, not just successful ones.

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 logic is (was) already present ... i.e. creating a new EligibilityEvent always updated the event/user properties with the flow (eligibility type in the past).

I couldn't find a reason for this specific case of also updating it on success... it would have already been done by the user selecting their flow.

Good call out though.



def selected_verifier(request, eligibility_types):
def selected_verifier(request, flow: models.EnrollmentFlow):
"""Send the "selected eligibility verifier" analytics event."""
core.send_event(SelectedVerifierEvent(request, eligibility_types))
core.send_event(SelectedVerifierEvent(request, flow))


def started_eligibility(request, eligibility_types):
def started_eligibility(request, flow: models.EnrollmentFlow):
"""Send the "started eligibility" analytics event."""
core.send_event(StartedEligibilityEvent(request, eligibility_types))
core.send_event(StartedEligibilityEvent(request, flow))


def returned_error(request, eligibility_types, error):
def returned_error(request, flow: models.EnrollmentFlow, error):
"""Send the "returned eligibility" analytics event with an error status."""
core.send_event(ReturnedEligibilityEvent(request, eligibility_types, status="error", error=error))
core.send_event(ReturnedEligibilityEvent(request, flow, status="error", error=error))


def returned_fail(request, eligibility_types):
def returned_fail(request, flow: models.EnrollmentFlow):
"""Send the "returned eligibility" analytics event with a fail status."""
core.send_event(ReturnedEligibilityEvent(request, eligibility_types, status="fail"))
core.send_event(ReturnedEligibilityEvent(request, flow, status="fail"))


def returned_success(request, eligibility_types):
def returned_success(request, flow: models.EnrollmentFlow):
"""Send the "returned eligibility" analytics event with a success status."""
core.send_event(ReturnedEligibilityEvent(request, eligibility_types, status="success"))
core.send_event(ReturnedEligibilityEvent(request, flow, status="success"))
12 changes: 6 additions & 6 deletions benefits/eligibility/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def index(request):
flow = EnrollmentFlow.objects.get(id=flow_id)
session.update(request, flow=flow)

analytics.selected_verifier(request, flow.system_name)
analytics.selected_verifier(request, flow)

eligibility_start = reverse(routes.ELIGIBILITY_START)
response = redirect(eligibility_start)
Expand Down Expand Up @@ -83,7 +83,7 @@ def confirm(request):

# GET for OAuth verification
if request.method == "GET" and flow.uses_claims_verification:
analytics.started_eligibility(request, flow.system_name)
analytics.started_eligibility(request, flow)

is_verified = verify.eligibility_from_oauth(flow, session.oauth_claim(request), agency)

Expand All @@ -103,7 +103,7 @@ def confirm(request):
return TemplateResponse(request, TEMPLATE_CONFIRM, context)
# POST form submission, process form data, make Eligibility Verification API call
elif request.method == "POST":
analytics.started_eligibility(request, flow.system_name)
analytics.started_eligibility(request, flow)

form = flow.eligibility_form_instance(data=request.POST)
# form was not valid, allow for correction/resubmission
Expand All @@ -118,7 +118,7 @@ def confirm(request):

# form was not valid, allow for correction/resubmission
if is_verified is None:
analytics.returned_error(request, flow.system_name, form.errors)
analytics.returned_error(request, flow, form.errors)
context["form"] = form
return TemplateResponse(request, TEMPLATE_CONFIRM, context)
# no type was verified
Expand All @@ -135,7 +135,7 @@ def verified(request):
"""View handler for the verified eligibility page."""

flow = session.flow(request)
analytics.returned_success(request, flow.system_name)
analytics.returned_success(request, flow)

session.update(request, eligible=True)

Expand All @@ -149,6 +149,6 @@ def unverified(request):

flow = session.flow(request)

analytics.returned_fail(request, flow.system_name)
analytics.returned_fail(request, flow)

return TemplateResponse(request, flow.eligibility_unverified_template)
3 changes: 1 addition & 2 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,11 @@ def index(request):
agency = session.agency(request)
flow = session.flow(request)
expiry = session.enrollment_expiry(request)
verified_by = flow.claims_provider.client_name if flow.uses_claims_verification else flow.eligibility_api_url
event = models.EnrollmentEvent.objects.create(
transit_agency=agency,
enrollment_flow=flow,
enrollment_method=models.EnrollmentMethods.DIGITAL,
verified_by=verified_by,
verified_by=flow.eligibility_verifier,
expiration_datetime=expiry,
)
event.save()
Expand Down
20 changes: 10 additions & 10 deletions docs/product-and-design/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ Read more about each property on the [Amplitude documentation](https://help.ampl

The following custom user attributes are collected when the user performs specific actions on the application, like selecing an eligibility type or transit agency:

| User property | Description | Example value(s) |
| ---------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `eligibility_types` | Eligibility type chosen by user  | `[veteran]` |
| `eligibility_verifier` | Eligibility verifier used by user  | `VA.gov - Veteran (MST)` |
| `referrer` | URL that the event came from  | `https://benefits.calitp.org/help/` |
| `referring_domain` | Domain that the event came from  | `benefits.calitp.org` |
| `transit_agency` | Agency chosen by the user  | `Monterey-Salinas Transit` |
| `user_agent` | User's browser agent  | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36` |
| User property | Description | Example value(s) |
| ---------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `enrollment_flows` | Enrollment flows chosen by user  | `[veteran]` |
| `eligibility_verifier` | How eligibility for flow is verified | `cdt-logingov` |
| `referrer` | URL that the event came from  | `https://benefits.calitp.org/help/` |
| `referring_domain` | Domain that the event came from  | `benefits.calitp.org` |
| `transit_agency` | Agency chosen by the user  | `Monterey-Salinas Transit` |
| `user_agent` | User's browser agent  | `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36` |

## Event information collected

Expand Down Expand Up @@ -79,9 +79,9 @@ Read more on each of these events on the [Amplitude event documentation for Bene

These events track the progress of a user choosing an eligibility verifier and completing eligibility verification.

- returned eligibility
- selected eligibility verifier
- selected enrollment flow
- started eligibility
- returned eligibility

Read more on each of these events on the [Amplitude event documentation for Benefits, filtered by Eligibility](https://data.amplitude.com/public-doc/hdhfmlby2e?categories=id%3D1702329975970%26group%3Dcategories%26type%3DString%26operator%3Dis%26values%255B0%255D%3Deligibility%26dateValue%255Btype%255D%3DSINCE).

Expand Down
29 changes: 27 additions & 2 deletions tests/pytest/core/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ def test_Event_sets_default_event_properties(app_request, mocker):

assert "path" in update_spy.call_args.kwargs
assert "transit_agency" in update_spy.call_args.kwargs
assert "eligibility_types" in update_spy.call_args.kwargs
assert "eligibility_verifier" in update_spy.call_args.kwargs


Expand All @@ -83,10 +82,36 @@ def test_Event_sets_default_user_properties(app_request, mocker):
assert "referring_domain" in update_spy.call_args.kwargs
assert "user_agent" in update_spy.call_args.kwargs
assert "transit_agency" in update_spy.call_args.kwargs
assert "eligibility_types" in update_spy.call_args.kwargs
assert "eligibility_verifier" in update_spy.call_args.kwargs


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_flow")
def test_Event_calls_update_enrollment_flows(app_request, mocker, model_EnrollmentFlow):
update_spy = mocker.spy(benefits.core.analytics.Event, "update_enrollment_flows")

Event(app_request, "event_type")

assert model_EnrollmentFlow in update_spy.call_args.args


@pytest.mark.django_db
def test_Event_update_enrollment_flows(app_request, mocker, model_EnrollmentFlow):
event = Event(app_request, "event_type")

assert "enrollment_flows" not in event.event_properties
assert "enrollment_flows" not in event.user_properties

event_spy = mocker.spy(benefits.core.analytics.Event, "update_event_properties")
user_spy = mocker.spy(benefits.core.analytics.Event, "update_user_properties")
event.update_enrollment_flows(model_EnrollmentFlow)

event_spy.assert_called_once()
user_spy.assert_called_once()
assert event.event_properties["enrollment_flows"] == [model_EnrollmentFlow.system_name]
assert event.user_properties["enrollment_flows"] == [model_EnrollmentFlow.system_name]


@pytest.mark.django_db
def test_Event_update_event_properties(app_request):
key, value = "key", "value"
Expand Down
12 changes: 12 additions & 0 deletions tests/pytest/core/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ def test_EnrollmentFlow_by_id_nonmatching():
def test_EnrollmentFlow_with_scope_and_claim(model_EnrollmentFlow_with_scope_and_claim):

assert model_EnrollmentFlow_with_scope_and_claim.uses_claims_verification
assert (
model_EnrollmentFlow_with_scope_and_claim.eligibility_verifier
== model_EnrollmentFlow_with_scope_and_claim.claims_provider.client_name
)


@pytest.mark.django_db
Expand All @@ -254,6 +258,14 @@ def test_EnrollmentFlow_no_scope_and_claim_no_sign_out(model_EnrollmentFlow, mod
assert not model_EnrollmentFlow.uses_claims_verification


@pytest.mark.django_db
def test_EnrollmentFlow_eligibility_api(model_EnrollmentFlow_with_eligibility_api):
assert (
model_EnrollmentFlow_with_eligibility_api.eligibility_verifier
== model_EnrollmentFlow_with_eligibility_api.eligibility_api_url
)


@pytest.mark.django_db
def test_EnrollmentFlow_eligibility_api_auth_key(model_EnrollmentFlow_with_eligibility_api, mock_models_get_secret_by_name):
secret_value = model_EnrollmentFlow_with_eligibility_api.eligibility_api_auth_key
Expand Down
12 changes: 7 additions & 5 deletions tests/pytest/eligibility/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@


@pytest.mark.django_db
def test_EligibilityEvent_overwrites_eligibility_types(app_request, mocker, model_EnrollmentFlow):
key, type1, type2 = "eligibility_types", "type1", "type2"
model_EnrollmentFlow.system_name = type1
mocker.patch("benefits.core.analytics.session.flow", return_value=model_EnrollmentFlow)
def test_EligibilityEvent_overwrites_enrollment_flows(app_request, mocker, model_EnrollmentFlow):
key, type1, type2 = "enrollment_flows", "type1", "type2"
mock_flow = mocker.Mock()
mock_flow.system_name = type1
mocker.patch("benefits.core.analytics.session.flow", return_value=mock_flow)

event = EligibilityEvent(app_request, "event_type", type2)
model_EnrollmentFlow.system_name = type2
event = EligibilityEvent(app_request, "event_type", model_EnrollmentFlow)

# event_properties should have been overwritten
assert key in event.event_properties
Expand Down
Loading