Coverage for benefits/core/analytics.py: 72%
99 statements
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-22 18:00 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-22 18:00 +0000
1"""
2The core application: analytics implementation.
3"""
5import itertools
6import json
7import logging
8import re
9import time
10import uuid
12from django.conf import settings
13import requests
15from benefits import VERSION
16from . import models, session
19logger = logging.getLogger(__name__)
22class Event:
23 """Base analytics event of a given type, including attributes from request's session."""
25 _counter = itertools.count()
26 _domain_re = re.compile(r"^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n?]+)", re.IGNORECASE)
28 def __init__(self, request, event_type, enrollment_method=models.EnrollmentMethods.DIGITAL, **kwargs):
29 self.app_version = VERSION
30 # device_id is generated based on the user_id, and both are set explicitly (per session)
31 self.device_id = session.did(request)
32 self.event_properties = {}
33 self.event_type = str(event_type).lower()
34 self.insert_id = str(uuid.uuid4())
35 self.language = session.language(request)
36 # Amplitude tracks sessions using the start time as the session_id
37 self.session_id = session.start(request)
38 self.time = int(time.time() * 1000)
39 # Although Amplitude advises *against* setting user_id for anonymous users, here a value is set on anonymous
40 # users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude.
41 self.user_id = session.uid(request)
42 self.user_properties = {}
43 self.__dict__.update(kwargs)
45 agency = session.agency(request)
46 agency_name = agency.long_name if agency else None
47 flow = session.flow(request)
48 verifier_name = flow.eligibility_verifier if flow else None
50 self.update_event_properties(
51 path=request.path,
52 transit_agency=agency_name,
53 eligibility_verifier=verifier_name,
54 enrollment_method=enrollment_method,
55 )
57 uagent = request.headers.get("user-agent")
59 ref = request.headers.get("referer")
60 match = Event._domain_re.match(ref) if ref else None
61 refdom = match.group(1) if match else None
63 self.update_user_properties(
64 referrer=ref,
65 referring_domain=refdom,
66 user_agent=uagent,
67 transit_agency=agency_name,
68 eligibility_verifier=verifier_name,
69 enrollment_method=enrollment_method,
70 )
72 if flow:
73 self.update_enrollment_flows(flow)
75 # event is initialized, consume next counter
76 self.event_id = next(Event._counter)
78 def __str__(self):
79 return json.dumps(self.__dict__)
81 def update_event_properties(self, **kwargs):
82 """Merge kwargs into the self.event_properties dict."""
83 self.event_properties.update(kwargs)
85 def update_user_properties(self, **kwargs):
86 """Merge kwargs into the self.user_properties dict."""
87 self.user_properties.update(kwargs)
89 def update_enrollment_flows(self, flow: models.EnrollmentFlow):
90 enrollment_flows = [flow.system_name]
91 self.update_event_properties(
92 enrollment_flows=enrollment_flows,
93 )
94 self.update_user_properties(
95 enrollment_flows=enrollment_flows,
96 )
99class ViewedPageEvent(Event):
100 """Analytics event representing a single page view."""
102 def __init__(self, request):
103 super().__init__(request, "viewed page")
104 # Add UTM codes
105 utm_campaign = request.GET.get("utm_campaign")
106 utm_source = request.GET.get("utm_source")
107 utm_medium = request.GET.get("utm_medium")
108 utm_content = request.GET.get("utm_content")
109 utm_id = request.GET.get("utm_id")
110 self.update_event_properties(
111 utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id
112 )
113 self.update_user_properties(
114 utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id
115 )
118class ChangedLanguageEvent(Event):
119 """Analytics event representing a change in the app's language."""
121 def __init__(self, request, new_lang):
122 super().__init__(request, "changed language")
123 self.update_event_properties(language=new_lang)
126class Client:
127 """Analytics API client"""
129 def __init__(self, api_key):
130 self.api_key = api_key
131 self.headers = {"Accept": "*/*", "Content-type": "application/json"}
132 self.url = "https://api2.amplitude.com/2/httpapi"
133 logger.debug(f"Initialize Client for {self.url}")
135 def _payload(self, events):
136 if not isinstance(events, list):
137 events = [events]
138 return {"api_key": self.api_key, "events": [e.__dict__ for e in events]}
140 def send(self, event):
141 """Send an analytics event."""
142 if not isinstance(event, Event): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 raise ValueError("event must be an Event instance")
145 if not self.api_key: 145 ↛ 149line 145 didn't jump to line 149 because the condition on line 145 was always true
146 logger.warning(f"api_key is not configured, cannot send event: {event}")
147 return
149 try:
150 payload = self._payload(event)
151 logger.debug(f"Sending event payload: {payload}")
153 r = requests.post(
154 self.url,
155 headers=self.headers,
156 json=payload,
157 timeout=settings.REQUESTS_TIMEOUT,
158 )
159 if r.status_code == 200:
160 logger.debug(f"Event sent successfully: {r.json()}")
161 elif r.status_code == 400:
162 logger.error(f"Event request was invalid: {r.json()}")
163 elif r.status_code == 413:
164 logger.error(f"Event payload was too large: {r.json()}")
165 elif r.status_code == 429:
166 logger.error(f"Event contained too many requests for some users: {r.json()}")
167 else:
168 logger.error(f"Failed to send event: {r.json()}")
170 except Exception:
171 logger.error(f"Failed to send event: {event}")
174client = Client(settings.ANALYTICS_KEY)
177def send_event(event):
178 """Send an analytics event."""
179 if isinstance(event, Event): 179 ↛ 182line 179 didn't jump to line 182 because the condition on line 179 was always true
180 client.send(event)
181 else:
182 raise ValueError("event must be an Event instance")