Coverage for benefits/core/analytics.py: 72%

99 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-10-21 19:31 +0000

1""" 

2The core application: analytics implementation. 

3""" 

4 

5import itertools 

6import json 

7import logging 

8import re 

9import time 

10import uuid 

11 

12from django.conf import settings 

13import requests 

14 

15from benefits import VERSION 

16from . import models, session 

17 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22class Event: 

23 """Base analytics event of a given type, including attributes from request's session.""" 

24 

25 _counter = itertools.count() 

26 _domain_re = re.compile(r"^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n?]+)", re.IGNORECASE) 

27 

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) 

44 

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 

49 

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 ) 

56 

57 uagent = request.headers.get("user-agent") 

58 

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 

62 

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 ) 

71 

72 if flow: 

73 self.update_enrollment_flows(flow) 

74 

75 # event is initialized, consume next counter 

76 self.event_id = next(Event._counter) 

77 

78 def __str__(self): 

79 return json.dumps(self.__dict__) 

80 

81 def update_event_properties(self, **kwargs): 

82 """Merge kwargs into the self.event_properties dict.""" 

83 self.event_properties.update(kwargs) 

84 

85 def update_user_properties(self, **kwargs): 

86 """Merge kwargs into the self.user_properties dict.""" 

87 self.user_properties.update(kwargs) 

88 

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 ) 

97 

98 

99class ViewedPageEvent(Event): 

100 """Analytics event representing a single page view.""" 

101 

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 ) 

116 

117 

118class ChangedLanguageEvent(Event): 

119 """Analytics event representing a change in the app's language.""" 

120 

121 def __init__(self, request, new_lang): 

122 super().__init__(request, "changed language") 

123 self.update_event_properties(language=new_lang) 

124 

125 

126class Client: 

127 """Analytics API client""" 

128 

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}") 

134 

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]} 

139 

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") 

144 

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 

148 

149 try: 

150 payload = self._payload(event) 

151 logger.debug(f"Sending event payload: {payload}") 

152 

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()}") 

169 

170 except Exception: 

171 logger.error(f"Failed to send event: {event}") 

172 

173 

174client = Client(settings.ANALYTICS_KEY) 

175 

176 

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")