diff --git a/src/common/Analytics/AnalyticsEvents.ts b/src/common/Analytics/AnalyticsEvents.ts index ff5673e5..954770cc 100644 --- a/src/common/Analytics/AnalyticsEvents.ts +++ b/src/common/Analytics/AnalyticsEvents.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import { ConsentAndForm } from '../../hooks/useConsent'; export type AnalyticsEventTypeHandlers = { track: (eventName: string, event: unknown) => void; @@ -47,9 +48,33 @@ export const createAnalyticsEmitter = < }; export type SDKTrackEvents = { + ConsentResponseSubmitted: { + consentName?: string; + consentId?: string; + userAcceptedConsent: boolean; + }; + ConsentScreenShown: { consentName?: string; consentId?: string }; + ConsentSubmitFailure: { + reason: string; + consent?: ConsentAndForm; + userAcceptedConsent: boolean; + }; + CustomConsentScreenShown: { consentName?: string; consentId?: string }; + InviteAcceptFailure: { reason: string }; + InviteAcceptSuccess: { accountName: string; accountId: string }; + InviteDetected: {}; + InviteRequiredScreenPresented: {}; Login: { usedInvite: boolean }; + LoginButtonPresented: { buttonText: string }; + LoginFailure: { error: string; usedInvite: boolean }; + Navigate: { + fromScreen: string; + toScreen?: string; + method: 'pop' | 'replace'; + }; PostCreated: { messageLength: number }; PostEdited: { messageLength: number }; + TokenRefreshFailure: { accessTokenExpirationDate: string }; }; /** diff --git a/src/components/Invitations/InviteProvider.tsx b/src/components/Invitations/InviteProvider.tsx index f993ba4d..33d42a2c 100644 --- a/src/components/Invitations/InviteProvider.tsx +++ b/src/components/Invitations/InviteProvider.tsx @@ -7,6 +7,7 @@ import { useAuth } from '../../hooks/useAuth'; import { useRestCache, useRestMutation } from '../../hooks/rest-api'; import { ActivityIndicatorView } from '../ActivityIndicatorView'; import { useQueryClient } from '@tanstack/react-query'; +import { _sdkAnalyticsEvent } from '../../common/Analytics'; export type PendingInvite = { inviteId: string; @@ -25,6 +26,7 @@ const usePendingInviteStateStore = create<{ }>(() => ({})); inviteNotifier.addListener('inviteDetected', (invite) => { + _sdkAnalyticsEvent.track('InviteDetected', {}); // inviteId/evc exposure should be limited usePendingInviteStore.setState(invite); usePendingInviteStateStore.setState({ loading: false, @@ -32,9 +34,9 @@ inviteNotifier.addListener('inviteDetected', (invite) => { failureMessage: undefined, }); }); -inviteNotifier.addListener('inviteLoadingStateChanged', (state) => - usePendingInviteStateStore.setState(state, true), -); +inviteNotifier.addListener('inviteLoadingStateChanged', (state) => { + usePendingInviteStateStore.setState(state, true); +}); export const usePendingInvite = () => { return usePendingInviteStore(); @@ -90,14 +92,25 @@ export const InviteProvider: React.FC = ({ children }) => { onError: (error: any) => { if (isInviteAlreadyAcceptedErrorResponse(error.response?.data)) { console.warn('Ignoring already accepted invite'); + _sdkAnalyticsEvent.track('InviteAcceptFailure', { + reason: 'Invite Already Accepted', + }); } else { console.warn('Error accepting invitation', error); Alert.alert(t('Error accepting invitation. Please try again.')); + _sdkAnalyticsEvent.track('InviteAcceptFailure', { + reason: JSON.stringify(error), + }); } clearPendingInvite(); mutation.reset(); }, onSuccess: async (acceptedInvite) => { + _sdkAnalyticsEvent.track('InviteAcceptSuccess', { + accountName: acceptedInvite.accountName, + accountId: acceptedInvite.account, + }); + // Add the new account to the account list. cache.updateCache( 'GET /v1/accounts', diff --git a/src/hooks/useOAuthFlow.tsx b/src/hooks/useOAuthFlow.tsx index b5c22d4d..e29c2bde 100644 --- a/src/hooks/useOAuthFlow.tsx +++ b/src/hooks/useOAuthFlow.tsx @@ -162,6 +162,10 @@ export const OAuthContextProvider = ({ _sdkAnalyticsEvent.track('Login', { usedInvite: !!pendingInvite?.evc }); onSuccess?.(result); } catch (error) { + _sdkAnalyticsEvent.track('LoginFailure', { + error: JSON.stringify(error), + usedInvite: !!pendingInvite?.evc, + }); await clearAuthResult(); onFail?.(error); } @@ -172,6 +176,9 @@ export const OAuthContextProvider = ({ const refreshHandler = useCallback( async function (storedResult: AuthResult) { if (!storedResult?.refreshToken) { + _sdkAnalyticsEvent.track('TokenRefreshFailure', { + accessTokenExpirationDate: storedResult.accessTokenExpirationDate, + }); throw new Error( 'No refreshToken! The app can NOT function properly without a refreshToken. Expect to be logged out immediately.', ); diff --git a/src/screens/ConsentScreen.test.tsx b/src/screens/ConsentScreen.test.tsx index 06a09c1a..8cea0387 100644 --- a/src/screens/ConsentScreen.test.tsx +++ b/src/screens/ConsentScreen.test.tsx @@ -141,7 +141,7 @@ test('renders custom consent screen if present in developer config', () => { }); expect(navigateMock.replace).toHaveBeenCalledWith('app'); expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledTimes(1); - expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledWith( + expect(updateConsentDirectiveMutationMock.mutate.mock.calls[0][0]).toEqual( createConsentPatch(defaultConsentDirective.id, true), ); @@ -156,7 +156,7 @@ test('renders custom consent screen if present in developer config', () => { }); () => expect(logoutMock).toHaveBeenCalledTimes(1); expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledTimes(1); - expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledWith( + expect(updateConsentDirectiveMutationMock.mutate.mock.calls[0][0]).toEqual( createConsentPatch(defaultConsentDirective.id, false), ); @@ -191,7 +191,7 @@ test('renders the consent body and acceptance verbiage', () => { test('should accept the consent and navigate to the home screen', () => { const { getByText } = render(consentScreen); fireEvent.press(getByText('Agree')); - expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledWith( + expect(updateConsentDirectiveMutationMock.mutate.mock.calls[0][0]).toEqual( createConsentPatch(defaultConsentDirective.id, true), ); useUpdateProjectConsentDirectiveMock.mock.calls[0][0].onSuccess(undefined, { @@ -206,7 +206,7 @@ test('should accept the consent and navigate to the onboarding course screen', ( }); const { getByText } = render(consentScreen); fireEvent.press(getByText('Agree')); - expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledWith( + expect(updateConsentDirectiveMutationMock.mutate.mock.calls[0][0]).toEqual( createConsentPatch(defaultConsentDirective.id, true), ); useUpdateProjectConsentDirectiveMock.mock.calls[0][0].onSuccess(undefined, { @@ -227,7 +227,7 @@ test('Pressing logout declines the consent and logs the the user out', async () const { getByText } = render(consentScreen); fireEvent.press(getByText('Decline')); alertSpy.mock.calls[0]?.[2]?.[1].onPress!(); - expect(updateConsentDirectiveMutationMock.mutate).toHaveBeenCalledWith( + expect(updateConsentDirectiveMutationMock.mutate.mock.calls[0][0]).toEqual( createConsentPatch(defaultConsentDirective.id, false), ); useUpdateProjectConsentDirectiveMock.mock.calls[0][0].onSuccess(undefined, { diff --git a/src/screens/ConsentScreen.tsx b/src/screens/ConsentScreen.tsx index bf0b750f..aaa38ad0 100644 --- a/src/screens/ConsentScreen.tsx +++ b/src/screens/ConsentScreen.tsx @@ -14,6 +14,7 @@ import { import type { ConsentAndForm } from '../hooks/useConsent'; import { useDeveloperConfig } from '../hooks/useDeveloperConfig'; import { LoggedInRootScreenProps } from '../navigators/types'; +import { _sdkAnalyticsEvent } from '../common/Analytics'; export const ConsentScreen = ({ navigation, @@ -44,12 +45,21 @@ export const ConsentScreen = ({ useEffect(() => { if (!shouldRenderConsentScreen) { if (route.params?.noNavOnAccept) { + _sdkAnalyticsEvent.track('Navigate', { + fromScreen: 'consentScreen', + method: 'pop', + }); navigation.pop(); return; } const nextRoute = shouldLaunchOnboardingCourse ? 'screens/OnboardingCourseScreen' : 'app'; + _sdkAnalyticsEvent.track('Navigate', { + fromScreen: 'consentScreen', + toScreen: nextRoute, + method: 'replace', + }); navigation.replace(nextRoute); } }, [ @@ -67,10 +77,31 @@ export const ConsentScreen = ({ const updateConsentDirective = useCallback( (accept: boolean) => { if (!consentToPresent?.id) { + _sdkAnalyticsEvent.track('ConsentSubmitFailure', { + reason: 'Consent ID was missing; mutation not executed', + consent: consentToPresent, + userAcceptedConsent: accept, + }); return; } updateConsentDirectiveMutation.mutate( createConsentPatch(consentToPresent.id, accept), + { + onSuccess: () => { + _sdkAnalyticsEvent.track('ConsentResponseSubmitted', { + userAcceptedConsent: accept, + consentName: consentToPresent.form.name, + consentId: consentToPresent.form.id, + }); + }, + onError: (err) => { + _sdkAnalyticsEvent.track('ConsentSubmitFailure', { + reason: `Error thrown during mutation: ${JSON.stringify(err)}`, + consent: consentToPresent, + userAcceptedConsent: accept, + }); + }, + }, ); }, [updateConsentDirectiveMutation, consentToPresent], @@ -126,6 +157,10 @@ export const ConsentScreen = ({ } if (CustomConsentScreen) { + _sdkAnalyticsEvent.track('CustomConsentScreenShown', { + consentName: consentToPresent.form.name, + consentId: consentToPresent.form.id, + }); return ( diff --git a/src/screens/InviteRequiredScreen.tsx b/src/screens/InviteRequiredScreen.tsx index 35a7d5ee..511358f5 100644 --- a/src/screens/InviteRequiredScreen.tsx +++ b/src/screens/InviteRequiredScreen.tsx @@ -8,9 +8,11 @@ import { import { createStyles } from '../components/BrandConfigProvider'; import { tID } from '../common/testID'; import { useStyles } from '../hooks/useStyles'; +import { _sdkAnalyticsEvent } from '../common/Analytics'; export const InviteRequiredScreen = () => { const { styles } = useStyles(defaultStyles); + _sdkAnalyticsEvent.track('InviteRequiredScreenPresented', {}); return ( diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 2eb1adfa..4657d4d5 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -11,6 +11,7 @@ import { createStyles, useIcons } from '../components/BrandConfigProvider'; import LaunchScreen from '../components/LaunchScreen'; import { Dialog, Portal, Text } from 'react-native-paper'; import compact from 'lodash/compact'; +import { _sdkAnalyticsEvent } from '../common/Analytics'; export const LoginScreen: FC = () => { const { renderCustomLoginScreen, componentProps = {} } = useDeveloperConfig(); @@ -32,11 +33,18 @@ export const LoginScreen: FC = () => { return t('login-button-loading-invite', 'Loading'); } if (pendingInvite) { + _sdkAnalyticsEvent.track('LoginButtonPresented', { + buttonText: 'Accept Invite', + }); return ( loginScreenProps.acceptInviteText ?? t('login-button-title-invite-found', 'Accept Invite') ); } + + _sdkAnalyticsEvent.track('LoginButtonPresented', { + buttonText: 'Login', + }); return loginScreenProps.loginText ?? t('login-button-title', 'Login'); };