Skip to content

Commit 883a4f8

Browse files
Sign up analytics (#767)
* Swap enum SignUpSteps to SIGN_UP_STEPS add a SIGN_UP_STEPS_TO_STR map. * Add telemetry calls to the frontend * Add the telemetry endpoint to the backend * Capture the first sign-up step in posthog and fixing capture call for non-auth users --------- Co-authored-by: Davi Nakano <davinakanoca@gmail.com>
1 parent 41e463f commit 883a4f8

9 files changed

Lines changed: 126 additions & 23 deletions

File tree

assets/app/vue/defines.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export const IOS_SUPPORT_URL = 'https://support.tb.pro/hc/en-us/articles/5105366
99
export const STATUS_PAGE_URL = 'https://status.tb.pro/';
1010
export const TERMS_OF_SERVICE_URL = 'https://tb.pro/terms/';
1111
export const PRIVACY_POLICY_URL = 'https://tb.pro/privacy/';
12+
export const CAPTURE_TELEMETRY = true;

assets/app/vue/types.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export enum SERVER_MESSAGE_LEVEL {
55
SUCCESS = 25,
66
WARNING = 30,
77
ERROR = 40,
8-
}
8+
};
99

1010
export type ServerMessage = {
1111
level: SERVER_MESSAGE_LEVEL;
@@ -15,9 +15,15 @@ export type ServerMessage = {
1515
export enum FeatureFlag {
1616
SHOW_CONNECT_NOW = 'feature.show-connect-now',
1717
PHASE = 'feature.phase',
18-
}
18+
};
1919

2020
export enum FeatureFlagValue {
2121
TRUE = 'true',
2222
PHASE_TWO = '2',
23-
}
23+
};
24+
25+
export enum TELEMETRY_EVENTS {
26+
SIGN_UP_SUPPORT = 'accounts.sign-up.support',
27+
SIGN_UP_ERROR = 'accounts.sign-up.error',
28+
SIGN_UP_STEP = 'accounts.sign-up.step',
29+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { TELEMETRY_EVENTS } from "@/types";
2+
import { SIGN_UP_STEPS, SIGN_UP_STEPS_TO_STR } from "./stores/signUpFlowStore";
3+
import { CAPTURE_TELEMETRY } from "@/defines";
4+
5+
/**
6+
* Send the telemetry event to the server. This should not block!
7+
*/
8+
const sendTelemetryEvent = (event: TELEMETRY_EVENTS, properties: object | null = null) => {
9+
if (!CAPTURE_TELEMETRY) {
10+
return;
11+
}
12+
fetch('/api/v1/telemetry/event', {
13+
method: 'POST',
14+
body: JSON.stringify({
15+
event,
16+
event_properties: properties || null,
17+
}),
18+
headers: {
19+
'Content-Type': 'application/json',
20+
'X-CSRFToken': window._page?.csrfToken,
21+
},
22+
});
23+
}
24+
25+
export const captureSupportLinkClick = () => {
26+
sendTelemetryEvent(TELEMETRY_EVENTS.SIGN_UP_SUPPORT)
27+
};
28+
29+
export const captureError = (error_string: string) => {
30+
sendTelemetryEvent(TELEMETRY_EVENTS.SIGN_UP_ERROR, { 'error': error_string });
31+
};
32+
33+
export const captureStep = (sign_up_step: SIGN_UP_STEPS) => {
34+
sendTelemetryEvent(TELEMETRY_EVENTS.SIGN_UP_STEP, { 'step_num': sign_up_step, 'step_str': SIGN_UP_STEPS_TO_STR[sign_up_step] || SIGN_UP_STEPS_TO_STR[SIGN_UP_STEPS.INVALID] })
35+
};

assets/app/vue/views/SignUpView/index.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import CsrfToken from '@/components/forms/CsrfToken.vue';
1515
import { NoticeBar, NoticeBarTypes } from '@thunderbirdops/services-ui';
1616
import { useRoute } from 'vue-router';
1717
import { useI18n } from 'vue-i18n';
18-
import { SignUpSteps, useSignUpFlowStore } from './stores/signUpFlowStore';
18+
import { SIGN_UP_STEPS, useSignUpFlowStore } from './stores/signUpFlowStore';
1919
import { storeToRefs } from 'pinia';
2020
import { PhX } from '@phosphor-icons/vue';
2121
@@ -46,9 +46,9 @@ window._page.pageId = route.name.toString();
4646
4747
// Map of enum steps and SFC
4848
const stepSFCMap = {
49-
[SignUpSteps.USERNAME]: Step1Username,
50-
[SignUpSteps.PASSWORD]: Step2Password,
51-
[SignUpSteps.VERIFY]: Step3Verify,
49+
[SIGN_UP_STEPS.USERNAME]: Step1Username,
50+
[SIGN_UP_STEPS.PASSWORD]: Step2Password,
51+
[SIGN_UP_STEPS.VERIFY]: Step3Verify,
5252
}
5353
</script>
5454

assets/app/vue/views/SignUpView/stores/signUpFlowStore.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import { useSessionStorage } from '@vueuse/core';
22
import { defineStore } from 'pinia';
33
import { computed, ref } from 'vue';
4+
import { captureError, captureStep } from '../api';
45

5-
export const enum SignUpSteps {
6+
export const enum SIGN_UP_STEPS {
67
INVALID = 0,
78
USERNAME = 10,
89
PASSWORD = 20,
910
VERIFY = 30,
10-
DONE = 100,
11+
DONE = 100, // Not currently used
12+
}
13+
14+
export const SIGN_UP_STEPS_TO_STR = {
15+
[SIGN_UP_STEPS.INVALID]: 'INVALID',
16+
[SIGN_UP_STEPS.USERNAME]: 'USERNAME',
17+
[SIGN_UP_STEPS.PASSWORD]: 'PASSWORD',
18+
[SIGN_UP_STEPS.VERIFY]: 'VERIFY',
19+
[SIGN_UP_STEPS.DONE]: 'DONE'
1120
}
1221

1322
export const useSignUpFlowStore = defineStore('signUpFlow', () => {
@@ -20,7 +29,7 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
2029
const timezone = useSessionStorage(`${sessionStorageKeyPrefix}/timezone`, null);
2130
const lang = useSessionStorage(`${sessionStorageKeyPrefix}/lang`, null);
2231
// In-memory only!
23-
const step = ref(SignUpSteps.USERNAME);
32+
const step = ref(SIGN_UP_STEPS.USERNAME);
2433
const password = ref(null);
2534
const confirmPassword = ref(null);
2635

@@ -71,8 +80,8 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
7180
if (!error) {
7281
return false;
7382
}
83+
7484
errorMessage.value = error['error'] ?? 'Unknown Error';
75-
7685
const type: string = error['type'] ?? 'unknown-error';
7786
if (type === 'go-to-wait-list') {
7887
// Don't show this error, just redirect!
@@ -83,15 +92,20 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
8392

8493
// What step are we moving the user back to?
8594
if (type.indexOf('invalidPassword') === 0) {
86-
step.value = SignUpSteps.PASSWORD;
95+
step.value = SIGN_UP_STEPS.PASSWORD;
8796
} else if (type === 'status-409') { // User already exists error (this comes from keycloak)
88-
step.value = SignUpSteps.VERIFY;
97+
step.value = SIGN_UP_STEPS.VERIFY;
8998
} else {
90-
step.value = SignUpSteps.USERNAME;
99+
step.value = SIGN_UP_STEPS.USERNAME;
91100
}
101+
102+
// Capture the step we send them back to
103+
captureStep(step.value);
92104
} catch {
93105
errorMessage.value = 'Unknown error';
94106
}
107+
108+
captureError(errorMessage.value);
95109
return false;
96110
};
97111

@@ -101,17 +115,18 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
101115
* Note: There's no previousStep because we intentionally do not have a back button.
102116
*/
103117
const nextStep = () => {
104-
let nextStepValue = SignUpSteps.INVALID;
118+
let nextStepValue = SIGN_UP_STEPS.INVALID;
105119
switch (step.value) {
106-
case SignUpSteps.USERNAME:
107-
nextStepValue = SignUpSteps.PASSWORD;
120+
case SIGN_UP_STEPS.USERNAME:
121+
nextStepValue = SIGN_UP_STEPS.PASSWORD;
108122
break;
109-
case SignUpSteps.PASSWORD:
110-
nextStepValue = SignUpSteps.VERIFY;
123+
case SIGN_UP_STEPS.PASSWORD:
124+
nextStepValue = SIGN_UP_STEPS.VERIFY;
111125
break;
112-
case SignUpSteps.VERIFY:
126+
case SIGN_UP_STEPS.VERIFY:
113127
return;
114128
}
129+
captureStep(nextStepValue);
115130
step.value = nextStepValue;
116131
}
117132

@@ -129,7 +144,7 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
129144
lang.value = null;
130145
errorMessage.value = null;
131146
if (resetStep) {
132-
step.value = SignUpSteps.USERNAME;
147+
step.value = SIGN_UP_STEPS.USERNAME;
133148
}
134149
};
135150

assets/app/vue/views/SignUpView/views/Step1Username/index.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { onMounted, ref } from 'vue';
66
import { useDebounceFn } from '@vueuse/core';
77
import { isUsernameAvailable } from './api';
88
import { storeToRefs } from 'pinia';
9-
import { useSignUpFlowStore } from '../../stores/signUpFlowStore';
9+
import { useSignUpFlowStore, SIGN_UP_STEPS } from '../../stores/signUpFlowStore';
1010
import SignUpLayout from '../../components/SignUpLayout.vue';
11+
import { captureStep } from '../../api';
1112
1213
const USERNAME_MIN_LENGTH = 3;
1314
const USERNAME_MAX_LENGTH = 150;
@@ -86,6 +87,9 @@ onMounted(() => {
8687
loading.value = true;
8788
usernameCheckDebounced();
8889
}
90+
91+
// Capture the very first step to PostHog
92+
captureStep(SIGN_UP_STEPS.USERNAME);
8993
});
9094
9195
</script>

src/thunderbird_accounts/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ def before_send(event: Event, hint: Hint) -> Event | None:
278278
'is_username_available': '30/minute',
279279
'sign_up': '10/minute',
280280
'check_email_is_on_allow_list': '10/minute',
281+
'analytics': '1000/minute', # Just in case
281282
},
282283
}
283284

@@ -414,6 +415,9 @@ def oidc_logout(request):
414415
STALWART_USER_CACHE_PREFIX = 'stalwart_uid:'
415416
STALWART_USER_CACHE_TTL = 3600
416417

418+
# List of acceptable frontend events for the frontend telemetry route.
419+
FRONTEND_EVENTS = ['accounts.sign-up.support', 'accounts.sign-up.error', 'accounts.sign-up.step']
420+
417421
# Internationalization
418422
# https://docs.djangoproject.com/en/5.1/topics/i18n/
419423

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import uuid
2+
from thunderbird_accounts.telemetry import capture
3+
from rest_framework.permissions import AllowAny
4+
from django.conf import settings
5+
from rest_framework.throttling import UserRateThrottle
6+
from rest_framework.decorators import api_view, throttle_classes, permission_classes
7+
from rest_framework.exceptions import ValidationError
8+
from rest_framework.request import Request
9+
from rest_framework.response import Response
10+
from django.utils.translation import gettext_lazy as _
11+
12+
13+
class AnalyticsThrottle(UserRateThrottle):
14+
scope = 'analytics'
15+
16+
17+
@api_view(['POST'])
18+
@permission_classes([AllowAny])
19+
@throttle_classes([AnalyticsThrottle])
20+
def capture_frontend_event(request: Request):
21+
"""This function simply takes the log event,
22+
checks it against acceptable values and if it's valid then we log it.
23+
24+
This is a fire and forget function,
25+
it's not vital that they actually capture but this function should not block the frontend either."""
26+
27+
event = request.data.get('event')
28+
event_properties = request.data.get('event_properties')
29+
if not event or event not in settings.FRONTEND_EVENTS:
30+
raise ValidationError(_('The event passed is not valid.'))
31+
32+
if request.user.is_authenticated:
33+
capture(event, request.user.oidc_id, event_properties)
34+
else:
35+
capture(event, str(f'unauthenticated-{uuid.uuid4().hex}'), event_properties)
36+
37+
return Response(status=200)

src/thunderbird_accounts/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
from thunderbird_accounts.subscription import views as subscription_views
2020

2121
from thunderbird_accounts.authentication.api import get_user_profile, sign_up
22-
from thunderbird_accounts.telemetry import views as telemetry_views
2322
from thunderbird_accounts.legal import views as legal_views
23+
from thunderbird_accounts.telemetry import views as telemetry_views, api as telemetry_api
2424

2525
# Error handler overrides
2626
handler500 = 'thunderbird_accounts.core.views.handle_500'
@@ -82,6 +82,7 @@
8282
),
8383
# Stalwart telemetry webhook
8484
path('api/v1/telemetry/stalwart/webhook/', telemetry_views.stalwart_webhook, name='stalwart_webhook'),
85+
path('api/v1/telemetry/event', telemetry_api.capture_frontend_event, name='api_capture_frontend_event'),
8586
# Health check
8687
path('health', infra_views.health_check),
8788
]

0 commit comments

Comments
 (0)