We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Add custom properties for UTM code information
We are proposing to use UTM codes in query strings for various Benefits outreach campaigns. Read more about UTM codes in this article from Hubspot: https://blog.hubspot.com/marketing/what-are-utm-tracking-codes-ht

We need to implement these properties into the Amplitude analytics tracking to be able to later gauge campaign effectiveness.

The proposal is to add them as `event_properties` and `user_properties` in the `viewed page` event. This is likely the first event that a user following a tracked URL will generate. And having the properties on both the event and the user makes later analysis in Amplitude more flexible.

## Acceptance Criteria

<!-- Remember to consider edge cases -->

- [x] The [`viewed page` event](https://github.com/cal-itp/benefits/blob/dev/benefits/core/analytics.py#L90) is modified to capture UTM code information
- [x] Custom `event_properties` are added for each of the UTM codes:
  - [ ] `utm_campaign`
  - [ ] `utm_source`
  - [ ] `utm_medium`
  - [ ] `utm_content`
  - [ ] `utm_id`
- [x] Custom `user_properties` are added for each of the UTM codes:
  - [ ] `utm_campaign`
  - [ ] `utm_source`
  - [ ] `utm_medium`
  - [ ] `utm_content`
  - [ ] `utm_id`

## Additional context

UTM code information comes from the query string, accessible via the Django [`request.GET`](https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.HttpRequest.GET) attribute, an instance of [`QueryDict`](https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.QueryDict).


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `benefits/core/analytics.py`
Content:
```
1 """
2 The core application: analytics implementation.
3 """
4 
5 import itertools
6 import json
7 import logging
8 import re
9 import time
10 import uuid
11 
12 from django.conf import settings
13 import requests
14 
15 from benefits import VERSION
16 from benefits.core.models import EligibilityType
17 from . import session
18 
19 
20 logger = logging.getLogger(__name__)
21 
22 
23 class Event:
24     """Base analytics event of a given type, including attributes from request's session."""
25 
26     _counter = itertools.count()
27     _domain_re = re.compile(r"^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n?]+)", re.IGNORECASE)
28 
29     def __init__(self, request, event_type, **kwargs):
30         self.app_version = VERSION
31         # device_id is generated based on the user_id, and both are set explicitly (per session)
32         self.device_id = session.did(request)
33         self.event_properties = {}
34         self.event_type = str(event_type).lower()
35         self.insert_id = str(uuid.uuid4())
36         self.language = session.language(request)
37         # Amplitude tracks sessions using the start time as the session_id
38         self.session_id = session.start(request)
39         self.time = int(time.time() * 1000)
40         # Although Amplitude advises *against* setting user_id for anonymous users, here a value is set on anonymous
41         # users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude.
42         self.user_id = session.uid(request)
43         self.user_properties = {}
44         self.__dict__.update(kwargs)
45 
46         agency = session.agency(request)
47         agency_name = agency.long_name if agency else None
48         verifier = session.verifier(request)
49         verifier_name = verifier.name if verifier else None
50         eligibility_types = session.eligibility(request)
51         eligibility_types = EligibilityType.get_names(eligibility_types) if eligibility_types else None
52 
53         self.update_event_properties(
54             path=request.path,
55             transit_agency=agency_name,
56             eligibility_types=eligibility_types,
57             eligibility_verifier=verifier_name,
58         )
59 
60         uagent = request.headers.get("user-agent")
61 
62         ref = request.headers.get("referer")
63         match = Event._domain_re.match(ref) if ref else None
64         refdom = match.group(1) if match else None
65 
66         self.update_user_properties(
67             referrer=ref,
68             referring_domain=refdom,
69             user_agent=uagent,
70             transit_agency=agency_name,
71             eligibility_types=eligibility_types,
72             eligibility_verifier=verifier_name,
73         )
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 
90 class ViewedPageEvent(Event):
91     """Analytics event representing a single page view."""
92 
93     def __init__(self, request):
94         super().__init__(request, "viewed page")
95 
96 
97 class ChangedLanguageEvent(Event):
98     """Analytics event representing a change in the app's language."""
99 
100     def __init__(self, request, new_lang):
101         super().__init__(request, "changed language")
102         self.update_event_properties(language=new_lang)
103 
104 
105 class Client:
106     """Analytics API client"""
107 
108     def __init__(self, api_key):
109         self.api_key = api_key
110         self.headers = {"Accept": "*/*", "Content-type": "application/json"}
111         self.url = "https://api2.amplitude.com/2/httpapi"
112         logger.debug(f"Initialize Client for {self.url}")
113 
114     def _payload(self, events):
115         if not isinstance(events, list):
116             events = [events]
117         return {"api_key": self.api_key, "events": [e.__dict__ for e in events]}
118 
119     def send(self, event):
120         """Send an analytics event."""
121         if not isinstance(event, Event):
122             raise ValueError("event must be an Event instance")
123 
124         if not self.api_key:
125             logger.warning(f"api_key is not configured, cannot send event: {event}")
126             return
127 
128         try:
129             payload = self._payload(event)
130             logger.debug(f"Sending event payload: {payload}")
131 
132             r = requests.post(
133                 self.url,
134                 headers=self.headers,
135                 json=payload,
136                 timeout=settings.REQUESTS_TIMEOUT,
137             )
138             if r.status_code == 200:
139                 logger.debug(f"Event sent successfully: {r.json()}")
140             elif r.status_code == 400:
141                 logger.error(f"Event request was invalid: {r.json()}")
142             elif r.status_code == 413:
143                 logger.error(f"Event payload was too large: {r.json()}")
144             elif r.status_code == 429:
145                 logger.error(f"Event contained too many requests for some users: {r.json()}")
146             else:
147                 logger.error(f"Failed to send event: {r.json()}")
148 
149         except Exception:
150             logger.error(f"Failed to send event: {event}")
151 
152 
153 client = Client(settings.ANALYTICS_KEY)
154 
155 
156 def send_event(event):
157     """Send an analytics event."""
158     if isinstance(event, Event):
159         client.send(event)
160     else:
161         raise ValueError("event must be an Event instance")
162
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/benefits/core/analytics.py b/benefits/core/analytics.py
--- a/benefits/core/analytics.py
+++ b/benefits/core/analytics.py
@@ -92,6 +92,18 @@
 
     def __init__(self, request):
         super().__init__(request, "viewed page")
+        # Add UTM codes
+        utm_campaign = request.GET.get("utm_campaign")
+        utm_source = request.GET.get("utm_source")
+        utm_medium = request.GET.get("utm_medium")
+        utm_content = request.GET.get("utm_content")
+        utm_id = request.GET.get("utm_id")
+        self.update_event_properties(
+            utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id
+        )
+        self.update_user_properties(
+            utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id
+        )
 
 
 class ChangedLanguageEvent(Event):

{"golden_diff": "diff --git a/benefits/core/analytics.py b/benefits/core/analytics.py\n--- a/benefits/core/analytics.py\n+++ b/benefits/core/analytics.py\n@@ -92,6 +92,18 @@\n \n     def __init__(self, request):\n         super().__init__(request, \"viewed page\")\n+        # Add UTM codes\n+        utm_campaign = request.GET.get(\"utm_campaign\")\n+        utm_source = request.GET.get(\"utm_source\")\n+        utm_medium = request.GET.get(\"utm_medium\")\n+        utm_content = request.GET.get(\"utm_content\")\n+        utm_id = request.GET.get(\"utm_id\")\n+        self.update_event_properties(\n+            utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id\n+        )\n+        self.update_user_properties(\n+            utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id\n+        )\n \n \n class ChangedLanguageEvent(Event):\n", "issue": "Add custom properties for UTM code information\nWe are proposing to use UTM codes in query strings for various Benefits outreach campaigns. Read more about UTM codes in this article from Hubspot: https://blog.hubspot.com/marketing/what-are-utm-tracking-codes-ht\r\n\r\nWe need to implement these properties into the Amplitude analytics tracking to be able to later gauge campaign effectiveness.\r\n\r\nThe proposal is to add them as `event_properties` and `user_properties` in the `viewed page` event. This is likely the first event that a user following a tracked URL will generate. And having the properties on both the event and the user makes later analysis in Amplitude more flexible.\r\n\r\n## Acceptance Criteria\r\n\r\n<!-- Remember to consider edge cases -->\r\n\r\n- [x] The [`viewed page` event](https://github.com/cal-itp/benefits/blob/dev/benefits/core/analytics.py#L90) is modified to capture UTM code information\r\n- [x] Custom `event_properties` are added for each of the UTM codes:\r\n  - [ ] `utm_campaign`\r\n  - [ ] `utm_source`\r\n  - [ ] `utm_medium`\r\n  - [ ] `utm_content`\r\n  - [ ] `utm_id`\r\n- [x] Custom `user_properties` are added for each of the UTM codes:\r\n  - [ ] `utm_campaign`\r\n  - [ ] `utm_source`\r\n  - [ ] `utm_medium`\r\n  - [ ] `utm_content`\r\n  - [ ] `utm_id`\r\n\r\n## Additional context\r\n\r\nUTM code information comes from the query string, accessible via the Django [`request.GET`](https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.HttpRequest.GET) attribute, an instance of [`QueryDict`](https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.QueryDict).\r\n\n", "before_files": [{"content": "\"\"\"\nThe core application: analytics implementation.\n\"\"\"\n\nimport itertools\nimport json\nimport logging\nimport re\nimport time\nimport uuid\n\nfrom django.conf import settings\nimport requests\n\nfrom benefits import VERSION\nfrom benefits.core.models import EligibilityType\nfrom . import session\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Event:\n    \"\"\"Base analytics event of a given type, including attributes from request's session.\"\"\"\n\n    _counter = itertools.count()\n    _domain_re = re.compile(r\"^(?:https?:\\/\\/)?(?:[^@\\n]+@)?(?:www\\.)?([^:\\/\\n?]+)\", re.IGNORECASE)\n\n    def __init__(self, request, event_type, **kwargs):\n        self.app_version = VERSION\n        # device_id is generated based on the user_id, and both are set explicitly (per session)\n        self.device_id = session.did(request)\n        self.event_properties = {}\n        self.event_type = str(event_type).lower()\n        self.insert_id = str(uuid.uuid4())\n        self.language = session.language(request)\n        # Amplitude tracks sessions using the start time as the session_id\n        self.session_id = session.start(request)\n        self.time = int(time.time() * 1000)\n        # Although Amplitude advises *against* setting user_id for anonymous users, here a value is set on anonymous\n        # users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude.\n        self.user_id = session.uid(request)\n        self.user_properties = {}\n        self.__dict__.update(kwargs)\n\n        agency = session.agency(request)\n        agency_name = agency.long_name if agency else None\n        verifier = session.verifier(request)\n        verifier_name = verifier.name if verifier else None\n        eligibility_types = session.eligibility(request)\n        eligibility_types = EligibilityType.get_names(eligibility_types) if eligibility_types else None\n\n        self.update_event_properties(\n            path=request.path,\n            transit_agency=agency_name,\n            eligibility_types=eligibility_types,\n            eligibility_verifier=verifier_name,\n        )\n\n        uagent = request.headers.get(\"user-agent\")\n\n        ref = request.headers.get(\"referer\")\n        match = Event._domain_re.match(ref) if ref else None\n        refdom = match.group(1) if match else None\n\n        self.update_user_properties(\n            referrer=ref,\n            referring_domain=refdom,\n            user_agent=uagent,\n            transit_agency=agency_name,\n            eligibility_types=eligibility_types,\n            eligibility_verifier=verifier_name,\n        )\n\n        # event is initialized, consume next counter\n        self.event_id = next(Event._counter)\n\n    def __str__(self):\n        return json.dumps(self.__dict__)\n\n    def update_event_properties(self, **kwargs):\n        \"\"\"Merge kwargs into the self.event_properties dict.\"\"\"\n        self.event_properties.update(kwargs)\n\n    def update_user_properties(self, **kwargs):\n        \"\"\"Merge kwargs into the self.user_properties dict.\"\"\"\n        self.user_properties.update(kwargs)\n\n\nclass ViewedPageEvent(Event):\n    \"\"\"Analytics event representing a single page view.\"\"\"\n\n    def __init__(self, request):\n        super().__init__(request, \"viewed page\")\n\n\nclass ChangedLanguageEvent(Event):\n    \"\"\"Analytics event representing a change in the app's language.\"\"\"\n\n    def __init__(self, request, new_lang):\n        super().__init__(request, \"changed language\")\n        self.update_event_properties(language=new_lang)\n\n\nclass Client:\n    \"\"\"Analytics API client\"\"\"\n\n    def __init__(self, api_key):\n        self.api_key = api_key\n        self.headers = {\"Accept\": \"*/*\", \"Content-type\": \"application/json\"}\n        self.url = \"https://api2.amplitude.com/2/httpapi\"\n        logger.debug(f\"Initialize Client for {self.url}\")\n\n    def _payload(self, events):\n        if not isinstance(events, list):\n            events = [events]\n        return {\"api_key\": self.api_key, \"events\": [e.__dict__ for e in events]}\n\n    def send(self, event):\n        \"\"\"Send an analytics event.\"\"\"\n        if not isinstance(event, Event):\n            raise ValueError(\"event must be an Event instance\")\n\n        if not self.api_key:\n            logger.warning(f\"api_key is not configured, cannot send event: {event}\")\n            return\n\n        try:\n            payload = self._payload(event)\n            logger.debug(f\"Sending event payload: {payload}\")\n\n            r = requests.post(\n                self.url,\n                headers=self.headers,\n                json=payload,\n                timeout=settings.REQUESTS_TIMEOUT,\n            )\n            if r.status_code == 200:\n                logger.debug(f\"Event sent successfully: {r.json()}\")\n            elif r.status_code == 400:\n                logger.error(f\"Event request was invalid: {r.json()}\")\n            elif r.status_code == 413:\n                logger.error(f\"Event payload was too large: {r.json()}\")\n            elif r.status_code == 429:\n                logger.error(f\"Event contained too many requests for some users: {r.json()}\")\n            else:\n                logger.error(f\"Failed to send event: {r.json()}\")\n\n        except Exception:\n            logger.error(f\"Failed to send event: {event}\")\n\n\nclient = Client(settings.ANALYTICS_KEY)\n\n\ndef send_event(event):\n    \"\"\"Send an analytics event.\"\"\"\n    if isinstance(event, Event):\n        client.send(event)\n    else:\n        raise ValueError(\"event must be an Event instance\")\n", "path": "benefits/core/analytics.py"}], "after_files": [{"content": "\"\"\"\nThe core application: analytics implementation.\n\"\"\"\n\nimport itertools\nimport json\nimport logging\nimport re\nimport time\nimport uuid\n\nfrom django.conf import settings\nimport requests\n\nfrom benefits import VERSION\nfrom benefits.core.models import EligibilityType\nfrom . import session\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Event:\n    \"\"\"Base analytics event of a given type, including attributes from request's session.\"\"\"\n\n    _counter = itertools.count()\n    _domain_re = re.compile(r\"^(?:https?:\\/\\/)?(?:[^@\\n]+@)?(?:www\\.)?([^:\\/\\n?]+)\", re.IGNORECASE)\n\n    def __init__(self, request, event_type, **kwargs):\n        self.app_version = VERSION\n        # device_id is generated based on the user_id, and both are set explicitly (per session)\n        self.device_id = session.did(request)\n        self.event_properties = {}\n        self.event_type = str(event_type).lower()\n        self.insert_id = str(uuid.uuid4())\n        self.language = session.language(request)\n        # Amplitude tracks sessions using the start time as the session_id\n        self.session_id = session.start(request)\n        self.time = int(time.time() * 1000)\n        # Although Amplitude advises *against* setting user_id for anonymous users, here a value is set on anonymous\n        # users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude.\n        self.user_id = session.uid(request)\n        self.user_properties = {}\n        self.__dict__.update(kwargs)\n\n        agency = session.agency(request)\n        agency_name = agency.long_name if agency else None\n        verifier = session.verifier(request)\n        verifier_name = verifier.name if verifier else None\n        eligibility_types = session.eligibility(request)\n        eligibility_types = EligibilityType.get_names(eligibility_types) if eligibility_types else None\n\n        self.update_event_properties(\n            path=request.path,\n            transit_agency=agency_name,\n            eligibility_types=eligibility_types,\n            eligibility_verifier=verifier_name,\n        )\n\n        uagent = request.headers.get(\"user-agent\")\n\n        ref = request.headers.get(\"referer\")\n        match = Event._domain_re.match(ref) if ref else None\n        refdom = match.group(1) if match else None\n\n        self.update_user_properties(\n            referrer=ref,\n            referring_domain=refdom,\n            user_agent=uagent,\n            transit_agency=agency_name,\n            eligibility_types=eligibility_types,\n            eligibility_verifier=verifier_name,\n        )\n\n        # event is initialized, consume next counter\n        self.event_id = next(Event._counter)\n\n    def __str__(self):\n        return json.dumps(self.__dict__)\n\n    def update_event_properties(self, **kwargs):\n        \"\"\"Merge kwargs into the self.event_properties dict.\"\"\"\n        self.event_properties.update(kwargs)\n\n    def update_user_properties(self, **kwargs):\n        \"\"\"Merge kwargs into the self.user_properties dict.\"\"\"\n        self.user_properties.update(kwargs)\n\n\nclass ViewedPageEvent(Event):\n    \"\"\"Analytics event representing a single page view.\"\"\"\n\n    def __init__(self, request):\n        super().__init__(request, \"viewed page\")\n        # Add UTM codes\n        utm_campaign = request.GET.get(\"utm_campaign\")\n        utm_source = request.GET.get(\"utm_source\")\n        utm_medium = request.GET.get(\"utm_medium\")\n        utm_content = request.GET.get(\"utm_content\")\n        utm_id = request.GET.get(\"utm_id\")\n        self.update_event_properties(\n            utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id\n        )\n        self.update_user_properties(\n            utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id\n        )\n\n\nclass ChangedLanguageEvent(Event):\n    \"\"\"Analytics event representing a change in the app's language.\"\"\"\n\n    def __init__(self, request, new_lang):\n        super().__init__(request, \"changed language\")\n        self.update_event_properties(language=new_lang)\n\n\nclass Client:\n    \"\"\"Analytics API client\"\"\"\n\n    def __init__(self, api_key):\n        self.api_key = api_key\n        self.headers = {\"Accept\": \"*/*\", \"Content-type\": \"application/json\"}\n        self.url = \"https://api2.amplitude.com/2/httpapi\"\n        logger.debug(f\"Initialize Client for {self.url}\")\n\n    def _payload(self, events):\n        if not isinstance(events, list):\n            events = [events]\n        return {\"api_key\": self.api_key, \"events\": [e.__dict__ for e in events]}\n\n    def send(self, event):\n        \"\"\"Send an analytics event.\"\"\"\n        if not isinstance(event, Event):\n            raise ValueError(\"event must be an Event instance\")\n\n        if not self.api_key:\n            logger.warning(f\"api_key is not configured, cannot send event: {event}\")\n            return\n\n        try:\n            payload = self._payload(event)\n            logger.debug(f\"Sending event payload: {payload}\")\n\n            r = requests.post(\n                self.url,\n                headers=self.headers,\n                json=payload,\n                timeout=settings.REQUESTS_TIMEOUT,\n            )\n            if r.status_code == 200:\n                logger.debug(f\"Event sent successfully: {r.json()}\")\n            elif r.status_code == 400:\n                logger.error(f\"Event request was invalid: {r.json()}\")\n            elif r.status_code == 413:\n                logger.error(f\"Event payload was too large: {r.json()}\")\n            elif r.status_code == 429:\n                logger.error(f\"Event contained too many requests for some users: {r.json()}\")\n            else:\n                logger.error(f\"Failed to send event: {r.json()}\")\n\n        except Exception:\n            logger.error(f\"Failed to send event: {event}\")\n\n\nclient = Client(settings.ANALYTICS_KEY)\n\n\ndef send_event(event):\n    \"\"\"Send an analytics event.\"\"\"\n    if isinstance(event, Event):\n        client.send(event)\n    else:\n        raise ValueError(\"event must be an Event instance\")\n", "path": "benefits/core/analytics.py"}]}