From 5cdf345372bf0240c2f636cafeec2af3c087efab Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 30 Dec 2025 14:04:28 -0800 Subject: [PATCH] SELF-773: standardize analytics imports (#1538) * standardize analytics * format --- app/src/components/navbar/Points.tsx | 3 +- app/src/hooks/useConnectionModal.ts | 4 +- app/src/navigation/index.tsx | 3 +- app/src/providers/authProvider.tsx | 4 +- app/src/providers/authProvider.web.tsx | 4 +- .../notificationTrackingProvider.tsx | 4 +- app/src/providers/selfClientProvider.tsx | 10 +- app/src/proving/validateDocument.ts | 4 +- app/src/services/analytics.ts | 210 ++++++++++-------- app/tests/src/navigation.test.tsx | 3 + .../src/proving/validateDocument.test.ts | 26 ++- app/tests/src/services/analytics.test.ts | 4 +- 12 files changed, 147 insertions(+), 132 deletions(-) diff --git a/app/src/components/navbar/Points.tsx b/app/src/components/navbar/Points.tsx index c4f78348a..5b71bbc2f 100644 --- a/app/src/components/navbar/Points.tsx +++ b/app/src/components/navbar/Points.tsx @@ -33,7 +33,7 @@ import { appsUrl } from '@/consts/links'; import { useIncomingPoints, usePoints } from '@/hooks/usePoints'; import { usePointsGuardrail } from '@/hooks/usePointsGuardrail'; import type { RootStackParamList } from '@/navigation'; -import analytics from '@/services/analytics'; +import { trackScreenView } from '@/services/analytics'; import { isTopicSubscribed, requestNotificationPermission, @@ -70,7 +70,6 @@ const Points: React.FC = () => { // Track NavBar view analytics useFocusEffect( React.useCallback(() => { - const { trackScreenView } = analytics(); trackScreenView('Points NavBar', { screenName: 'Points NavBar', }); diff --git a/app/src/hooks/useConnectionModal.ts b/app/src/hooks/useConnectionModal.ts index 31189c547..75c808a8f 100644 --- a/app/src/hooks/useConnectionModal.ts +++ b/app/src/hooks/useConnectionModal.ts @@ -11,11 +11,9 @@ import { apiPingUrl } from '@/consts/links'; import { useModal } from '@/hooks/useModal'; import { useNetInfo } from '@/hooks/useNetInfo'; import { navigationRef } from '@/navigation'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; -const { trackEvent } = analytics(); - const connectionModalParams = { titleText: 'Internet connection error', bodyText: 'In order to use SELF, you must have access to the internet.', diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 452917e4f..b481036cb 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -30,7 +30,7 @@ import sharedScreens from '@/navigation/shared'; import verificationScreens from '@/navigation/verification'; import type { ModalNavigationParams } from '@/screens/app/ModalScreen'; import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen'; -import analytics from '@/services/analytics'; +import { trackScreenView } from '@/services/analytics'; import type { ProofHistory } from '@/stores/proofTypes'; export const navigationScreens = { @@ -195,7 +195,6 @@ declare global { } } -const { trackScreenView } = analytics(); const Navigation = createStaticNavigation(AppNavigation); const NavigationWithTracking = () => { diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 72a5db305..40be9fb47 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -22,12 +22,10 @@ import { createKeychainOptions, detectSecurityCapabilities, } from '@/integrations/keychain'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; import type { Mnemonic } from '@/types/mnemonic'; -const { trackEvent } = analytics(); - const SERVICE_NAME = 'secret'; type SignedPayload = { signature: string; data: T }; diff --git a/app/src/providers/authProvider.web.tsx b/app/src/providers/authProvider.web.tsx index 6c19f8650..34004a220 100644 --- a/app/src/providers/authProvider.web.tsx +++ b/app/src/providers/authProvider.web.tsx @@ -18,11 +18,9 @@ import React, { import { AuthEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import type { Mnemonic } from '@/types/mnemonic'; -const { trackEvent } = analytics(); - type SignedPayload = { signature: string; data: T }; // Check if Android bridge is available diff --git a/app/src/providers/notificationTrackingProvider.tsx b/app/src/providers/notificationTrackingProvider.tsx index 8746fb55f..a4346c8e1 100644 --- a/app/src/providers/notificationTrackingProvider.tsx +++ b/app/src/providers/notificationTrackingProvider.tsx @@ -8,9 +8,7 @@ import messaging from '@react-native-firebase/messaging'; import { NotificationEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import analytics from '@/services/analytics'; - -const { trackEvent } = analytics(); +import { trackEvent } from '@/services/analytics'; export const NotificationTrackingProvider: React.FC = ({ children, diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 34cf31c67..40a3f3dcd 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -25,7 +25,7 @@ import type { RootStackParamList } from '@/navigation'; import { navigationRef } from '@/navigation'; import { unsafe_getPrivateKey } from '@/providers/authProvider'; import { selfClientDocumentsAdapter } from '@/providers/passportDataProvider'; -import analytics, { trackNfcEvent } from '@/services/analytics'; +import { trackEvent, trackNfcEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; type GlobalCrypto = { crypto?: { subtle?: Crypto['subtle'] } }; @@ -130,7 +130,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { }, analytics: { trackEvent: (event: string, data?: TrackEventParams) => { - analytics().trackEvent(event, data); + trackEvent(event, data); }, trackNfcEvent: (name: string, data?: Record) => { trackNfcEvent(name, data); @@ -211,21 +211,21 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { if (fcmToken) { try { - analytics().trackEvent('DEVICE_TOKEN_REG_STARTED'); + trackEvent('DEVICE_TOKEN_REG_STARTED'); logProofEvent('info', 'Device token registration started', context); const { registerDeviceToken: registerFirebaseDeviceToken } = await import('@/services/notifications/notificationService'); await registerFirebaseDeviceToken(uuid, fcmToken, isMock); - analytics().trackEvent('DEVICE_TOKEN_REG_SUCCESS'); + trackEvent('DEVICE_TOKEN_REG_SUCCESS'); logProofEvent('info', 'Device token registration success', context); } catch (error) { logProofEvent('warn', 'Device token registration failed', context, { error: error instanceof Error ? error.message : String(error), }); console.error('Error registering device token:', error); - analytics().trackEvent('DEVICE_TOKEN_REG_FAILED', { + trackEvent('DEVICE_TOKEN_REG_FAILED', { message: error instanceof Error ? error.message : String(error), }); } diff --git a/app/src/proving/validateDocument.ts b/app/src/proving/validateDocument.ts index 180c73d08..8549f8d97 100644 --- a/app/src/proving/validateDocument.ts +++ b/app/src/proving/validateDocument.ts @@ -27,9 +27,7 @@ import { storePassportData, updateDocumentRegistrationState, } from '@/providers/passportDataProvider'; -import analytics from '@/services/analytics'; - -const { trackEvent } = analytics(); +import { trackEvent } from '@/services/analytics'; /** * This function checks and updates registration states for all documents and updates the `isRegistered`. diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 3ad9c7415..9bb65e99c 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -93,64 +93,41 @@ function validateParams( return cleanParams(validatedProps); } -/* - Records analytics events and screen views - In development mode, events are logged to console instead of being sent to Segment +/** + * Internal tracking function used by trackEvent and trackScreenView + * Records analytics events and screen views + * In development mode, events are logged to console instead of being sent to Segment */ -const analytics = () => { - function _track( - type: 'event' | 'screen', - eventName: string, - properties?: Record, - ) { - // Validate and clean properties - const validatedProps = validateParams(properties); +function _track( + type: 'event' | 'screen', + eventName: string, + properties?: Record, +) { + // Validate and clean properties + const validatedProps = validateParams(properties); - if (__DEV__) { - console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { - name: eventName, - properties: validatedProps, - }); - return; - } - - if (!segmentClient) { - return; - } - const trackMethod = (e: string, p?: JsonMap) => - type === 'screen' - ? segmentClient.screen(e, p) - : segmentClient.track(e, p); - - if (!validatedProps) { - // you may need to remove the catch when debugging - return trackMethod(eventName).catch(console.info); - } - - // you may need to remove the catch when debugging - trackMethod(eventName, validatedProps).catch(console.info); + if (__DEV__) { + console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { + name: eventName, + properties: validatedProps, + }); + return; } - return { - // Using LiteralCheck will allow constants but not plain string literals - trackEvent: (eventName: string, properties?: TrackEventParams) => { - _track('event', eventName, properties); - }, - trackScreenView: ( - screenName: string, - properties?: Record, - ) => { - _track('screen', screenName, properties); - }, - flush: () => { - if (!__DEV__ && segmentClient) { - segmentClient.flush(); - } - }, - }; -}; + if (!segmentClient) { + return; + } + const trackMethod = (e: string, p?: JsonMap) => + type === 'screen' ? segmentClient.screen(e, p) : segmentClient.track(e, p); -export default analytics; + if (!validatedProps) { + // you may need to remove the catch when debugging + return trackMethod(eventName).catch(console.info); + } + + // you may need to remove the catch when debugging + trackMethod(eventName, validatedProps).catch(console.info); +} /** * Cleanup function to clear event queues @@ -160,6 +137,69 @@ export const cleanupAnalytics = () => { eventCount = 0; }; +// --- Mixpanel NFC Analytics --- +export const configureNfcAnalytics = async () => { + if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; + const enableDebugLogs = + String(ENABLE_DEBUG_LOGS ?? '') + .trim() + .toLowerCase() === 'true'; + + // Check if PassportReader and configure method exist (Android doesn't have configure) + if (PassportReader && typeof PassportReader.configure === 'function') { + try { + // iOS configure method only accepts token and enableDebugLogs + // Android doesn't have this method at all + await Promise.resolve( + PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), + ); + } catch (error) { + console.warn('Failed to configure NFC analytics:', error); + } + } + + setupFlushPolicies(); + mixpanelConfigured = true; +}; + +/** + * Flush any pending analytics events immediately + */ +export const flush = () => { + if (!__DEV__ && segmentClient) { + segmentClient.flush(); + } +}; + +/** + * @deprecated Use named exports (trackEvent, trackScreenView, flush) instead + * Factory function that returns analytics methods + * Kept for backward compatibility + */ +const analytics = () => { + return { + trackEvent, + trackScreenView, + flush, + }; +}; + +export default analytics; + +/** + * Consolidated analytics flush function that flushes both Segment and Mixpanel events + * This should be called when you want to ensure all analytics events are sent immediately + */ +export const flushAllAnalytics = () => { + // Flush Segment analytics + flush(); + + // Never flush Mixpanel during active NFC scanning to prevent interference + if (!isNfcScanningActive) { + flushMixpanelEvents().catch(console.warn); + } +}; + const setupFlushPolicies = () => { AppState.addEventListener('change', (state: AppStateStatus) => { // Never flush during active NFC scanning to prevent interference @@ -221,46 +261,6 @@ const flushMixpanelEvents = async () => { } }; -// --- Mixpanel NFC Analytics --- -export const configureNfcAnalytics = async () => { - if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; - const enableDebugLogs = - String(ENABLE_DEBUG_LOGS ?? '') - .trim() - .toLowerCase() === 'true'; - - // Check if PassportReader and configure method exist (Android doesn't have configure) - if (PassportReader && typeof PassportReader.configure === 'function') { - try { - // iOS configure method only accepts token and enableDebugLogs - // Android doesn't have this method at all - await Promise.resolve( - PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), - ); - } catch (error) { - console.warn('Failed to configure NFC analytics:', error); - } - } - - setupFlushPolicies(); - mixpanelConfigured = true; -}; - -/** - * Consolidated analytics flush function that flushes both Segment and Mixpanel events - * This should be called when you want to ensure all analytics events are sent immediately - */ -export const flushAllAnalytics = () => { - // Flush Segment analytics - const { flush: flushAnalytics } = analytics(); - flushAnalytics(); - - // Never flush Mixpanel during active NFC scanning to prevent interference - if (!isNfcScanningActive) { - flushMixpanelEvents().catch(console.warn); - } -}; - /** * Set NFC scanning state to prevent analytics flush interference */ @@ -277,6 +277,18 @@ export const setNfcScanningActive = (active: boolean) => { } }; +/** + * Track an analytics event + * @param eventName - Name of the event to track + * @param properties - Optional properties to attach to the event + */ +export const trackEvent = ( + eventName: string, + properties?: TrackEventParams, +) => { + _track('event', eventName, properties); +}; + export const trackNfcEvent = async ( name: string, properties?: Record, @@ -302,3 +314,15 @@ export const trackNfcEvent = async ( eventQueue.push({ name, properties }); } }; + +/** + * Track a screen view + * @param screenName - Name of the screen to track + * @param properties - Optional properties to attach to the screen view + */ +export const trackScreenView = ( + screenName: string, + properties?: Record, +) => { + _track('screen', screenName, properties); +}; diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 3ed68c962..fbb8e3514 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -19,6 +19,9 @@ jest.mock('@/services/analytics', () => ({ trackScreenView: jest.fn(), flush: jest.fn(), })), + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), })); describe('navigation', () => { diff --git a/app/tests/src/proving/validateDocument.test.ts b/app/tests/src/proving/validateDocument.test.ts index c24c25516..f3fca732d 100644 --- a/app/tests/src/proving/validateDocument.test.ts +++ b/app/tests/src/proving/validateDocument.test.ts @@ -11,16 +11,20 @@ import { checkAndUpdateRegistrationStates, getAlternativeCSCA, } from '@/proving/validateDocument'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; // Mock the analytics module to avoid side effects in tests -jest.mock('@/services/analytics', () => { - // Create mock inside factory to avoid temporal dead zone - const mockTrackEvent = jest.fn(); - return jest.fn(() => ({ - trackEvent: mockTrackEvent, - })); -}); +jest.mock('@/services/analytics', () => ({ + __esModule: true, + default: jest.fn(() => ({ + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), + })), + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), +})); // Mock the passport data provider to avoid database operations const mockGetAllDocumentsDirectlyFromKeychain = jest.fn(); @@ -153,8 +157,7 @@ describe('getAlternativeCSCA', () => { beforeEach(() => { jest.clearAllMocks(); // Get the mocked trackEvent from the analytics module - const mockAnalytics = jest.mocked(analytics); - mockTrackEvent = mockAnalytics().trackEvent as jest.Mock; + mockTrackEvent = jest.mocked(trackEvent) as jest.Mock; }); it('should return public keys in Record format for Aadhaar with valid public keys', () => { @@ -245,8 +248,7 @@ describe('checkAndUpdateRegistrationStates', () => { beforeEach(() => { jest.clearAllMocks(); // Get the mocked trackEvent from the analytics module - const mockAnalytics = jest.mocked(analytics); - mockTrackEvent = mockAnalytics().trackEvent as jest.Mock; + mockTrackEvent = jest.mocked(trackEvent) as jest.Mock; mockGetState.mockReturnValue( buildState({ diff --git a/app/tests/src/services/analytics.test.ts b/app/tests/src/services/analytics.test.ts index 18e13f580..a588b9f58 100644 --- a/app/tests/src/services/analytics.test.ts +++ b/app/tests/src/services/analytics.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import analytics from '@/services/analytics'; +import { trackEvent, trackScreenView } from '@/services/analytics'; // Mock the Segment client jest.mock('@/config/segment', () => ({ @@ -13,8 +13,6 @@ jest.mock('@/config/segment', () => ({ })); describe('analytics', () => { - const { trackEvent, trackScreenView } = analytics(); - beforeEach(() => { jest.clearAllMocks(); });