Skip to content

Commit e433c2f

Browse files
Zaimwa9matthewelwellpre-commit-ci[bot]
authored
feat: collect and track utms at signup (#5630)
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1e580e5 commit e433c2f

File tree

16 files changed

+282
-63
lines changed

16 files changed

+282
-63
lines changed

api/integrations/lead_tracking/hubspot/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,12 @@ def get_contact(self, user: "FFAdminUser") -> None | dict[str, Any]:
5757
return results[0] # type: ignore[no-any-return]
5858

5959
def create_lead_form(
60-
self, user: "FFAdminUser", hubspot_cookie: str | None = None
60+
self,
61+
user: "FFAdminUser",
62+
hubspot_cookie: str | None = None,
63+
utm_data: dict[str, str] | None = None,
6164
) -> dict[str, Any]:
65+
utm_data = utm_data or {}
6266
logger.info(
6367
f"Creating Hubspot lead form for user {user.email} with hubspot cookie {hubspot_cookie}"
6468
)
@@ -72,6 +76,10 @@ def create_lead_form(
7276
{"objectTypeId": "0-1", "name": "lastname", "value": user.last_name},
7377
]
7478

79+
fields.extend(
80+
{"objectTypeId": "0-1", "name": k, "value": v} for k, v in utm_data.items()
81+
)
82+
7583
context = {}
7684
if hubspot_cookie:
7785
context = {

api/integrations/lead_tracking/hubspot/lead_tracker.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,15 @@ def update_company_active_subscription(
6767

6868
def create_user_hubspot_contact(self, user: FFAdminUser) -> str | None:
6969
tracker = HubspotTracker.objects.filter(user=user).first()
70-
tracker_cookie = tracker.hubspot_cookie if tracker else None
71-
self.client.create_lead_form(user=user, hubspot_cookie=tracker_cookie)
70+
create_lead_form_kwargs: dict[str, Any] = {"user": user}
71+
if tracker:
72+
create_lead_form_kwargs.update(
73+
{
74+
"hubspot_cookie": tracker.hubspot_cookie,
75+
"utm_data": tracker.utm_data,
76+
}
77+
)
78+
self.client.create_lead_form(**create_lead_form_kwargs)
7279

7380
# Create lead form creates a contact asynchronously in hubspot but does not return the contact id
7481
# We need to get the contact id separately and retry 3 times

api/integrations/lead_tracking/hubspot/services.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,29 @@
88
HUBSPOT_COOKIE_NAME,
99
)
1010
from users.models import FFAdminUser, HubspotTracker
11+
from users.serializers import UTMDataSerializer
1112

1213
logger = logging.getLogger(__name__)
1314

1415

15-
def register_hubspot_tracker(request: Request, user: FFAdminUser | None = None) -> None:
16+
def register_hubspot_tracker(
17+
request: Request,
18+
user: FFAdminUser | None = None,
19+
) -> None:
1620
hubspot_cookie = request.data.get(HUBSPOT_COOKIE_NAME)
21+
raw_utm_data = request.data.get("utm_data")
1722
track_user = user if user else request.user
18-
if not hubspot_cookie:
19-
logger.info(f"Request did not included Hubspot data for user {track_user.id}")
23+
24+
serializer = UTMDataSerializer(data=raw_utm_data)
25+
utm_data = serializer.validated_data if serializer.is_valid() else None
26+
27+
if not (hubspot_cookie or utm_data):
28+
logger.info(f"Request did not include Hubspot data for user {track_user.id}")
2029
return
2130

2231
if (
23-
HubspotTracker.objects.filter(hubspot_cookie=hubspot_cookie) # type: ignore[misc]
32+
hubspot_cookie
33+
and HubspotTracker.objects.filter(hubspot_cookie=hubspot_cookie) # type: ignore[misc]
2434
.exclude(user=track_user)
2535
.exists()
2636
):
@@ -34,6 +44,7 @@ def register_hubspot_tracker(request: Request, user: FFAdminUser | None = None)
3444
user=track_user,
3545
defaults={
3646
"hubspot_cookie": hubspot_cookie,
47+
"utm_data": utm_data,
3748
},
3849
)
3950
logger.info(

api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,17 @@ def test_create_lead_form(
6060
status=status.HTTP_200_OK,
6161
json={"inlineMessage": "Thanks for submitting the form."},
6262
)
63-
63+
utms_data = {
64+
"utm_source": "test_source",
65+
"utm_medium": "test_medium",
66+
"utm_campaign": "test_campaign",
67+
"utm_content": "test_content",
68+
"utm_term": "test_term",
69+
}
6470
# When
65-
response = hubspot_client.create_lead_form(staff_user, hubspot_cookie_body)
71+
response = hubspot_client.create_lead_form(
72+
staff_user, hubspot_cookie_body, utms_data
73+
)
6674

6775
# Then
6876
assert len(responses.calls) == 1
@@ -85,6 +93,29 @@ def test_create_lead_form(
8593
"value": staff_user.last_name,
8694
} in fields
8795

96+
# Test UTMs
97+
assert {
98+
"objectTypeId": "0-1",
99+
"name": "utm_source",
100+
"value": "test_source",
101+
} in fields
102+
assert {
103+
"objectTypeId": "0-1",
104+
"name": "utm_medium",
105+
"value": "test_medium",
106+
} in fields
107+
assert {
108+
"objectTypeId": "0-1",
109+
"name": "utm_campaign",
110+
"value": "test_campaign",
111+
} in fields
112+
assert {
113+
"objectTypeId": "0-1",
114+
"name": "utm_content",
115+
"value": "test_content",
116+
} in fields
117+
assert {"objectTypeId": "0-1", "name": "utm_term", "value": "test_term"} in fields
118+
88119
context = request_body.get("context", {})
89120
assert context == expected_context
90121

api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_lead_tracking.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def test_create_organisation_lead_creates_contact_when_not_found(
189189
assert hubspot_lead.hubspot_id == HUBSPOT_USER_ID
190190

191191
assert mock_client.get_contact.call_count == 2
192-
mock_client.create_lead_form.assert_called_once_with(user=user, hubspot_cookie=None)
192+
mock_client.create_lead_form.assert_called_once_with(user=user)
193193
mock_client.create_company.assert_called_once_with(
194194
name=organisation.name,
195195
active_subscription="free",
@@ -234,7 +234,7 @@ def test_create_organisation_lead_creates_contact_for_existing_org(
234234
assert HubspotLead.objects.filter(user=user, hubspot_id=HUBSPOT_USER_ID).exists()
235235
mock_client.create_company.assert_not_called()
236236
assert mock_client.get_contact.call_count == 2
237-
mock_client.create_lead_form.assert_called_once_with(user=user, hubspot_cookie=None)
237+
mock_client.create_lead_form.assert_called_once_with(user=user)
238238
mock_client.associate_contact_to_company.assert_called_once_with(
239239
contact_id=HUBSPOT_USER_ID,
240240
company_id=HUBSPOT_COMPANY_ID,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.22 on 2025-07-02 13:35
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("users", "0041_add_onboarding_field"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="hubspottracker",
15+
name="utm_data",
16+
field=models.JSONField(blank=True, default=None, null=True),
17+
),
18+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.22 on 2025-06-19 14:28
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("users", "0042_add_utm_data_json_field"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="hubspottracker",
15+
name="hubspot_cookie",
16+
field=models.CharField(blank=True, max_length=100, null=True, unique=True),
17+
),
18+
]

api/users/models.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from django_lifecycle.conditions import ( # type: ignore[import-untyped]
1919
WhenFieldHasChanged,
2020
)
21+
from pydantic import BaseModel
2122

2223
from integrations.lead_tracking.hubspot.tasks import (
2324
create_hubspot_contact_for_user,
@@ -44,6 +45,15 @@
4445
from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE
4546
from users.exceptions import InvalidInviteError
4647

48+
49+
class UTMDataModel(BaseModel):
50+
utm_source: typing.Optional[str] = None
51+
utm_medium: typing.Optional[str] = None
52+
utm_campaign: typing.Optional[str] = None
53+
utm_term: typing.Optional[str] = None
54+
utm_content: typing.Optional[str] = None
55+
56+
4757
if typing.TYPE_CHECKING:
4858
from environments.models import Environment
4959
from organisations.invites.models import (
@@ -463,12 +473,29 @@ class HubspotLead(models.Model):
463473
updated_at = models.DateTimeField(auto_now=True)
464474

465475

476+
class HubspotTrackerUTMData(typing.TypedDict, total=False):
477+
utm_source: str
478+
utm_medium: str
479+
utm_campaign: str
480+
utm_term: str
481+
utm_content: str
482+
483+
466484
class HubspotTracker(models.Model):
467485
user = models.OneToOneField(
468486
FFAdminUser,
469487
related_name="hubspot_tracker",
470488
on_delete=models.CASCADE,
471489
)
472-
hubspot_cookie = models.CharField(unique=True, max_length=100, null=False)
490+
hubspot_cookie = models.CharField(
491+
unique=True,
492+
max_length=100,
493+
null=True,
494+
blank=True,
495+
)
496+
utm_data: HubspotTrackerUTMData = models.JSONField(
497+
default=None, blank=True, null=True
498+
) # type: ignore[assignment]
499+
473500
created_at = models.DateTimeField(auto_now_add=True)
474501
updated_at = models.DateTimeField(auto_now=True)

api/users/serializers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,11 @@ class Meta(DjoserUserSerializer.Meta): # type: ignore[misc]
224224

225225
class ListUsersQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
226226
exclude_current = serializers.BooleanField(default=False)
227+
228+
229+
class UTMDataSerializer(serializers.Serializer[None]):
230+
utm_source = serializers.CharField(required=False)
231+
utm_medium = serializers.CharField(required=False)
232+
utm_campaign = serializers.CharField(required=False)
233+
utm_term = serializers.CharField(required=False)
234+
utm_content = serializers.CharField(required=False)

frontend/common/types/requests.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
StageTrigger,
2323
StageActionType,
2424
} from './responses'
25+
import { UtmsType } from './utms'
2526

2627
export type PagedRequest<T> = T & {
2728
page?: number
@@ -70,6 +71,7 @@ export type RegisterRequest = {
7071
superuser?: boolean
7172
organisation_name?: string
7273
marketing_consent_given?: boolean
74+
utm_data?: UtmsType
7375
}
7476

7577
export interface StageActionRequest {

0 commit comments

Comments
 (0)