Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/app/vue/defines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export const IOS_SUPPORT_URL = 'https://support.tb.pro/hc/en-us/articles/5105366
export const STATUS_PAGE_URL = 'https://status.tb.pro/';
export const TERMS_OF_SERVICE_URL = 'https://tb.pro/terms/';
export const PRIVACY_POLICY_URL = 'https://tb.pro/privacy/';
export const CAPTURE_TELEMETRY = true;
12 changes: 9 additions & 3 deletions assets/app/vue/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export enum SERVER_MESSAGE_LEVEL {
SUCCESS = 25,
WARNING = 30,
ERROR = 40,
}
};

export type ServerMessage = {
level: SERVER_MESSAGE_LEVEL;
Expand All @@ -15,9 +15,15 @@ export type ServerMessage = {
export enum FeatureFlag {
SHOW_CONNECT_NOW = 'feature.show-connect-now',
PHASE = 'feature.phase',
}
};

export enum FeatureFlagValue {
TRUE = 'true',
PHASE_TWO = '2',
}
};

export enum TELEMETRY_EVENTS {
SIGN_UP_SUPPORT = 'accounts.sign-up.support',
SIGN_UP_ERROR = 'accounts.sign-up.error',
SIGN_UP_STEP = 'accounts.sign-up.step',
};
35 changes: 35 additions & 0 deletions assets/app/vue/views/SignUpView/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { TELEMETRY_EVENTS } from "@/types";
import { SIGN_UP_STEPS, SIGN_UP_STEPS_TO_STR } from "./stores/signUpFlowStore";
import { CAPTURE_TELEMETRY } from "@/defines";

/**
* Send the telemetry event to the server. This should not block!
*/
const sendTelemetryEvent = (event: TELEMETRY_EVENTS, properties: object | null = null) => {
if (!CAPTURE_TELEMETRY) {
return;
}
fetch('/api/v1/telemetry/event', {
method: 'POST',
body: JSON.stringify({
event,
event_properties: properties || null,
}),
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': window._page?.csrfToken,
},
});
}

export const captureSupportLinkClick = () => {
sendTelemetryEvent(TELEMETRY_EVENTS.SIGN_UP_SUPPORT)
};

export const captureError = (error_string: string) => {
sendTelemetryEvent(TELEMETRY_EVENTS.SIGN_UP_ERROR, { 'error': error_string });
};

export const captureStep = (sign_up_step: SIGN_UP_STEPS) => {
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] })
};
8 changes: 4 additions & 4 deletions assets/app/vue/views/SignUpView/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import CsrfToken from '@/components/forms/CsrfToken.vue';
import { NoticeBar, NoticeBarTypes } from '@thunderbirdops/services-ui';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { SignUpSteps, useSignUpFlowStore } from './stores/signUpFlowStore';
import { SIGN_UP_STEPS, useSignUpFlowStore } from './stores/signUpFlowStore';
import { storeToRefs } from 'pinia';
import { PhX } from '@phosphor-icons/vue';

Expand Down Expand Up @@ -46,9 +46,9 @@ window._page.pageId = route.name.toString();

// Map of enum steps and SFC
const stepSFCMap = {
[SignUpSteps.USERNAME]: Step1Username,
[SignUpSteps.PASSWORD]: Step2Password,
[SignUpSteps.VERIFY]: Step3Verify,
[SIGN_UP_STEPS.USERNAME]: Step1Username,
[SIGN_UP_STEPS.PASSWORD]: Step2Password,
[SIGN_UP_STEPS.VERIFY]: Step3Verify,
}
</script>

Expand Down
43 changes: 29 additions & 14 deletions assets/app/vue/views/SignUpView/stores/signUpFlowStore.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { useSessionStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { captureError, captureStep } from '../api';

export const enum SignUpSteps {
export const enum SIGN_UP_STEPS {
INVALID = 0,
USERNAME = 10,
PASSWORD = 20,
VERIFY = 30,
DONE = 100,
DONE = 100, // Not currently used
}

export const SIGN_UP_STEPS_TO_STR = {
[SIGN_UP_STEPS.INVALID]: 'INVALID',
[SIGN_UP_STEPS.USERNAME]: 'USERNAME',
[SIGN_UP_STEPS.PASSWORD]: 'PASSWORD',
[SIGN_UP_STEPS.VERIFY]: 'VERIFY',
[SIGN_UP_STEPS.DONE]: 'DONE'
}

export const useSignUpFlowStore = defineStore('signUpFlow', () => {
Expand All @@ -20,7 +29,7 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
const timezone = useSessionStorage(`${sessionStorageKeyPrefix}/timezone`, null);
const lang = useSessionStorage(`${sessionStorageKeyPrefix}/lang`, null);
// In-memory only!
const step = ref(SignUpSteps.USERNAME);
const step = ref(SIGN_UP_STEPS.USERNAME);
const password = ref(null);
const confirmPassword = ref(null);

Expand Down Expand Up @@ -71,8 +80,8 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
if (!error) {
return false;
}

errorMessage.value = error['error'] ?? 'Unknown Error';

const type: string = error['type'] ?? 'unknown-error';
if (type === 'go-to-wait-list') {
// Don't show this error, just redirect!
Expand All @@ -83,15 +92,20 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {

// What step are we moving the user back to?
if (type.indexOf('invalidPassword') === 0) {
step.value = SignUpSteps.PASSWORD;
step.value = SIGN_UP_STEPS.PASSWORD;
} else if (type === 'status-409') { // User already exists error (this comes from keycloak)
step.value = SignUpSteps.VERIFY;
step.value = SIGN_UP_STEPS.VERIFY;
} else {
step.value = SignUpSteps.USERNAME;
step.value = SIGN_UP_STEPS.USERNAME;
}

// Capture the step we send them back to
captureStep(step.value);
} catch {
errorMessage.value = 'Unknown error';
}

captureError(errorMessage.value);
return false;
};

Expand All @@ -101,17 +115,18 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
* Note: There's no previousStep because we intentionally do not have a back button.
*/
const nextStep = () => {
let nextStepValue = SignUpSteps.INVALID;
let nextStepValue = SIGN_UP_STEPS.INVALID;
switch (step.value) {
case SignUpSteps.USERNAME:
nextStepValue = SignUpSteps.PASSWORD;
case SIGN_UP_STEPS.USERNAME:
nextStepValue = SIGN_UP_STEPS.PASSWORD;
break;
case SignUpSteps.PASSWORD:
nextStepValue = SignUpSteps.VERIFY;
case SIGN_UP_STEPS.PASSWORD:
nextStepValue = SIGN_UP_STEPS.VERIFY;
break;
case SignUpSteps.VERIFY:
case SIGN_UP_STEPS.VERIFY:
return;
}
captureStep(nextStepValue);
step.value = nextStepValue;
}
Comment on lines 117 to 131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are only doing the captureStep when the user moves to the nextStep, we are never capturing the initial SIGN_UP_STEPS.USERNAME.

So maybe we should add a captureStep() call in the onMounted of the Step1 component here:

https://github.com/thunderbird/thunderbird-accounts/blob/main/assets/app/vue/views/SignUpView/views/Step1Username/index.vue#L54-L59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Expand All @@ -129,7 +144,7 @@ export const useSignUpFlowStore = defineStore('signUpFlow', () => {
lang.value = null;
errorMessage.value = null;
if (resetStep) {
step.value = SignUpSteps.USERNAME;
step.value = SIGN_UP_STEPS.USERNAME;
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { onMounted, ref } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { isUsernameAvailable } from './api';
import { storeToRefs } from 'pinia';
import { useSignUpFlowStore } from '../../stores/signUpFlowStore';
import { useSignUpFlowStore, SIGN_UP_STEPS } from '../../stores/signUpFlowStore';
import SignUpLayout from '../../components/SignUpLayout.vue';
import { captureStep } from '../../api';

const USERNAME_MIN_LENGTH = 3;
const USERNAME_MAX_LENGTH = 150;
Expand Down Expand Up @@ -86,6 +87,9 @@ onMounted(() => {
loading.value = true;
usernameCheckDebounced();
}

// Capture the very first step to PostHog
captureStep(SIGN_UP_STEPS.USERNAME);
});

</script>
Expand Down
4 changes: 4 additions & 0 deletions src/thunderbird_accounts/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ def before_send(event: Event, hint: Hint) -> Event | None:
'is_username_available': '30/minute',
'sign_up': '10/minute',
'check_email_is_on_allow_list': '10/minute',
'analytics': '1000/minute', # Just in case
},
}

Expand Down Expand Up @@ -414,6 +415,9 @@ def oidc_logout(request):
STALWART_USER_CACHE_PREFIX = 'stalwart_uid:'
STALWART_USER_CACHE_TTL = 3600

# List of acceptable frontend events for the frontend telemetry route.
FRONTEND_EVENTS = ['accounts.sign-up.support', 'accounts.sign-up.error', 'accounts.sign-up.step']

# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/

Expand Down
37 changes: 37 additions & 0 deletions src/thunderbird_accounts/telemetry/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import uuid
from thunderbird_accounts.telemetry import capture
from rest_framework.permissions import AllowAny
from django.conf import settings
from rest_framework.throttling import UserRateThrottle
from rest_framework.decorators import api_view, throttle_classes, permission_classes
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from django.utils.translation import gettext_lazy as _


class AnalyticsThrottle(UserRateThrottle):
scope = 'analytics'


@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([AnalyticsThrottle])
def capture_frontend_event(request: Request):
"""This function simply takes the log event,
checks it against acceptable values and if it's valid then we log it.

This is a fire and forget function,
it's not vital that they actually capture but this function should not block the frontend either."""

event = request.data.get('event')
event_properties = request.data.get('event_properties')
if not event or event not in settings.FRONTEND_EVENTS:
raise ValidationError(_('The event passed is not valid.'))

if request.user.is_authenticated:
capture(event, request.user.oidc_id, event_properties)
else:
capture(event, str(f'unauthenticated-{uuid.uuid4().hex}'), event_properties)

return Response(status=200)
3 changes: 2 additions & 1 deletion src/thunderbird_accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
from thunderbird_accounts.subscription import views as subscription_views

from thunderbird_accounts.authentication.api import get_user_profile, sign_up
from thunderbird_accounts.telemetry import views as telemetry_views
from thunderbird_accounts.legal import views as legal_views
from thunderbird_accounts.telemetry import views as telemetry_views, api as telemetry_api

# Error handler overrides
handler500 = 'thunderbird_accounts.core.views.handle_500'
Expand Down Expand Up @@ -82,6 +82,7 @@
),
# Stalwart telemetry webhook
path('api/v1/telemetry/stalwart/webhook/', telemetry_views.stalwart_webhook, name='stalwart_webhook'),
path('api/v1/telemetry/event', telemetry_api.capture_frontend_event, name='api_capture_frontend_event'),
# Health check
path('health', infra_views.health_check),
]
Expand Down
Loading