feat: improve mixpanel flush strategy (#960)

* feat: improve mixpanel flush strategy

* fixes

* fix build

* update lock

* refactor methods

* conslidate calls

* update package and lock
This commit is contained in:
Justin Hernandez
2025-08-27 20:09:22 -07:00
committed by GitHub
parent ebf5d5105a
commit dba8ee1951
18 changed files with 275 additions and 69 deletions

View File

@@ -25,8 +25,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1151.0)
aws-sdk-core (3.230.0)
aws-partitions (1.1152.0)
aws-sdk-core (3.231.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -34,11 +34,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.110.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sdk-kms (1.112.0)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.197.0)
aws-sdk-core (~> 3, >= 3.228.0)
aws-sdk-s3 (1.198.0)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)

View File

@@ -7,7 +7,8 @@ const DATE_REGEX = /^\d{6}$/
module.exports = {
...RNPassportReader,
scan
scan,
reset: RNPassportReader.reset
}
function scan({ documentNumber, dateOfBirth, dateOfExpiry, canNumber, useCan, quality=1 }) {

View File

@@ -2089,7 +2089,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- segment-analytics-react-native (2.21.1):
- segment-analytics-react-native (2.21.2):
- React-Core
- sovran-react-native
- Sentry/HybridSDK (8.52.1)
@@ -2531,7 +2531,7 @@ SPEC CHECKSUMS:
RNScreens: 224dba0e9e7674d911ebf3931eddca686f133e8a
RNSentry: d240d406990e08d9b1fa967aaac67b7cb61b32e2
RNSVG: e1a716d635c65297c86e874eeb6adf3704a2e50a
segment-analytics-react-native: 5c3e8a4ee6d7532a011ed862d7c7d4fb5e5303e2
segment-analytics-react-native: bad4c2c7b63818bd493caa2b5759fca59e4ae9a7
Sentry: 2cbbe3592f30050c60e916c63c7f5a2fa584005e
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594

View File

@@ -13,6 +13,11 @@ jest.mock(
{ virtual: true },
);
jest.mock('@env', () => ({
ENABLE_DEBUG_LOGS: 'false',
MIXPANEL_NFC_PROJECT_TOKEN: 'test-token',
}));
global.FileReader = class {
constructor() {
this.onload = null;
@@ -202,13 +207,30 @@ jest.mock('react-native-nfc-manager', () => ({
// Mock react-native-passport-reader
jest.mock('react-native-passport-reader', () => ({
default: {
initialize: jest.fn(),
configure: jest.fn(),
scanPassport: jest.fn(),
readPassport: jest.fn(),
cancelPassportRead: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
}));
const { NativeModules } = require('react-native');
NativeModules.PassportReader = {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
};
jest.mock('@react-native-community/netinfo', () => ({
addEventListener: jest.fn(() => jest.fn()),
fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
}));
// Mock @stablelib packages
jest.mock('@stablelib/cbor', () => ({
encode: jest.fn(),

View File

@@ -42,7 +42,7 @@
"mobile-local-deploy": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs both",
"mobile-local-deploy:android": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs android",
"mobile-local-deploy:ios": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs ios",
"nice": "yarn imports:fix && yarn lint:fix && yarn fmt:fix",
"nice": "yarn build:deps && yarn imports:fix && yarn lint:fix && yarn fmt:fix",
"reinstall": "yarn clean && yarn install && yarn install-app",
"release": "./scripts/release.sh",
"release:major": "./scripts/release.sh major",
@@ -82,7 +82,7 @@
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.3",
"@segment/analytics-react-native": "^2.21.0",
"@segment/analytics-react-native": "^2.21.2",
"@segment/sovran-react-native": "^1.1.3",
"@selfxyz/common": "workspace:^",
"@selfxyz/mobile-sdk-alpha": "workspace:^",

View File

@@ -9,6 +9,7 @@ import {
createClient,
EventPlugin,
PluginType,
StartupFlushPolicy,
} from '@segment/analytics-react-native';
import '@ethersproject/shims';
@@ -48,7 +49,7 @@ export const createSegmentClient = () => {
return segmentClient;
}
const flushPolicies = [new BackgroundFlushPolicy()];
const flushPolicies = [new BackgroundFlushPolicy(), new StartupFlushPolicy()];
const client = createClient({
writeKey: SEGMENT_KEY,
@@ -56,6 +57,8 @@ export const createSegmentClient = () => {
trackDeepLinks: true,
debug: __DEV__,
collectDeviceId: false,
flushAt: 20, // Flush every 20 events
flushInterval: 20000, // Flush every 20 seconds
defaultSettings: {
integrations: {
'Segment.io': {

View File

@@ -7,9 +7,7 @@ import React, { Component } from 'react';
import { Text, View } from 'react-native';
import { captureException } from '@/Sentry';
import analytics from '@/utils/analytics';
const { flush: flushAnalytics } = analytics();
import { flushAllAnalytics, trackNfcEvent } from '@/utils/analytics';
interface Props {
children: React.ReactNode;
@@ -30,8 +28,12 @@ class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Flush analytics before the app crashes
flushAnalytics();
trackNfcEvent('error_boundary', {
message: error.message,
stack: info.componentStack,
});
// Flush all analytics before the app crashes
flushAllAnalytics();
captureException(error, {
componentStack: info.componentStack,
errorBoundary: true,

View File

@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// Web mock for react-native-passport-reader
export const reset = async () => {
// No-op for web
return Promise.resolve();
};
export const scan = async () => {
throw new Error('NFC scanning is not supported on web');
};

View File

@@ -45,6 +45,7 @@ import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { useFeedback } from '@/providers/feedbackProvider';
import { storePassportData } from '@/providers/passportDataProvider';
import useUserStore from '@/stores/userStore';
import { flushAllAnalytics, trackNfcEvent } from '@/utils/analytics';
import { black, slate100, slate400, slate500, white } from '@/utils/colors';
import { sendFeedbackEmail } from '@/utils/email';
import { dinot } from '@/utils/fonts';
@@ -79,6 +80,7 @@ type PassportNFCScanRoute = RouteProp<
const PassportNFCScanScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
const navigation = useNavigation();
const route = useRoute<PassportNFCScanRoute>();
const { showModal } = useFeedback();
@@ -137,6 +139,7 @@ const PassportNFCScanScreen: React.FC = () => {
const openErrorModal = useCallback(
(message: string) => {
flushAllAnalytics();
showModal({
titleText: 'NFC Scan Error',
bodyText: message,
@@ -206,6 +209,9 @@ const PassportNFCScanScreen: React.FC = () => {
trackEvent(PassportEvents.NFC_SCAN_FAILED, {
error: 'timeout',
});
trackNfcEvent(PassportEvents.NFC_SCAN_FAILED, {
error: 'timeout',
});
openErrorModal('Scan timed out. Please try again.');
setIsNfcSheetOpen(false);
}, 30000);
@@ -249,10 +255,14 @@ const PassportNFCScanScreen: React.FC = () => {
passportData = parseScanResponse(scanResponse);
} catch (e: unknown) {
console.error('Parsing NFC Response Unsuccessful');
const errMsg = sanitizeErrorMessage(
e instanceof Error ? e.message : String(e),
);
trackEvent(PassportEvents.NFC_RESPONSE_PARSE_FAILED, {
error: sanitizeErrorMessage(
e instanceof Error ? e.message : String(e),
),
error: errMsg,
});
trackNfcEvent(PassportEvents.NFC_RESPONSE_PARSE_FAILED, {
error: errMsg,
});
return;
}
@@ -317,10 +327,14 @@ const PassportNFCScanScreen: React.FC = () => {
return;
}
console.error('Passport Parsed Failed:', e);
const errMsg = sanitizeErrorMessage(
e instanceof Error ? e.message : String(e),
);
trackEvent(PassportEvents.PASSPORT_PARSE_FAILED, {
error: sanitizeErrorMessage(
e instanceof Error ? e.message : String(e),
),
error: errMsg,
});
trackNfcEvent(PassportEvents.PASSPORT_PARSE_FAILED, {
error: errMsg,
});
return;
}
@@ -335,8 +349,13 @@ const PassportNFCScanScreen: React.FC = () => {
).toFixed(2);
console.error('NFC Scan Unsuccessful:', e);
const message = e instanceof Error ? e.message : String(e);
const sanitized = sanitizeErrorMessage(message);
trackEvent(PassportEvents.NFC_SCAN_FAILED, {
error: sanitizeErrorMessage(message),
error: sanitized,
duration_seconds: parseFloat(scanDurationSeconds),
});
trackNfcEvent(PassportEvents.NFC_SCAN_FAILED, {
error: sanitized,
duration_seconds: parseFloat(scanDurationSeconds),
});
openErrorModal(message);
@@ -350,6 +369,7 @@ const PassportNFCScanScreen: React.FC = () => {
setIsNfcSheetOpen(false);
}
} else if (isNfcSupported) {
flushAllAnalytics();
if (Platform.OS === 'ios') {
Linking.openURL('App-Prefs:root=General&path=About');
} else {
@@ -376,6 +396,7 @@ const PassportNFCScanScreen: React.FC = () => {
});
const onCancelPress = async () => {
flushAllAnalytics();
const hasValidDocument = await hasAnyValidRegisteredDocument(selfClient);
if (hasValidDocument) {
navigateToHome();

View File

@@ -13,12 +13,10 @@ import { Caption } from '@/components/typography/Caption';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import analytics from '@/utils/analytics';
import analytics, { flushAllAnalytics } from '@/utils/analytics';
import { slate500 } from '@/utils/colors';
import { sendFeedbackEmail } from '@/utils/email';
const { flush: flushAnalytics } = analytics();
const tips: TipProps[] = [
{
title: 'Know Your Chip Location',
@@ -55,7 +53,7 @@ const PassportNFCTrouble: React.FC = () => {
// error screen, flush analytics
useEffect(() => {
flushAnalytics();
flushAllAnalytics();
}, []);
// 5-taps with a single finger

View File

@@ -21,6 +21,7 @@ import { Title } from '@/components/typography/Title';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { styles } from '@/screens/prove/ProofRequestStatusScreen';
import analytics, { flushAllAnalytics, trackNfcEvent } from '@/utils/analytics';
import { black, white } from '@/utils/colors';
import { notificationSuccess } from '@/utils/haptic';
import {
@@ -52,6 +53,7 @@ const ConfirmBelongingScreen: React.FC<ConfirmBelongingScreenProps> = () => {
try {
setRequestingPermission(true);
trackEvent(ProofEvents.NOTIFICATION_PERMISSION_REQUESTED);
trackNfcEvent(ProofEvents.NOTIFICATION_PERMISSION_REQUESTED);
// Request notification permission
const permissionGranted = await requestNotificationPermission();
@@ -74,6 +76,11 @@ const ConfirmBelongingScreen: React.FC<ConfirmBelongingScreenProps> = () => {
trackEvent(ProofEvents.PROVING_PROCESS_ERROR, {
error: message,
});
trackNfcEvent(ProofEvents.PROVING_PROCESS_ERROR, {
error: message,
});
flushAllAnalytics();
} finally {
setRequestingPermission(false);
}

View File

@@ -9,11 +9,9 @@ import Tips from '@/components/Tips';
import { Caption } from '@/components/typography/Caption';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import analytics from '@/utils/analytics';
import analytics, { flushAllAnalytics } from '@/utils/analytics';
import { slate500 } from '@/utils/colors';
const { flush: flushAnalytics } = analytics();
const tips: TipProps[] = [
{
title: 'Ensure Valid QR Code',
@@ -49,7 +47,7 @@ const QRCodeTrouble: React.FC = () => {
// error screen, flush analytics
useEffect(() => {
flushAnalytics();
flushAllAnalytics();
}, []);
return (

View File

@@ -13,7 +13,19 @@ declare module 'react-native-passport-reader' {
}
interface PassportReader {
configure(token: string): void;
configure(
token: string,
enableDebug?: boolean,
flushPolicies?: {
flushInterval?: number;
flushCount?: number;
flushOnBackground?: boolean;
flushOnForeground?: boolean;
flushOnNetworkChange?: boolean;
},
): void;
trackEvent?(name: string, properties?: Record<string, unknown>): void;
flush?(): void;
reset(): void;
scan(options: ScanOptions): Promise<{
mrz: string;
@@ -33,6 +45,23 @@ declare module 'react-native-passport-reader' {
}>;
}
const PassportReader: PassportReader;
export default PassportReader;
export const PassportReader: PassportReader;
export function configure(token: string): void;
export function reset(): void;
export function scan(options: ScanOptions): Promise<{
mrz: string;
eContent: string;
encryptedDigest: string;
photo: {
base64: string;
};
digestAlgorithm: string;
signerInfoDigestAlgorithm: string;
digestEncryptionAlgorithm: string;
LDSVersion: string;
unicodeVersion: string;
encapContent: string;
documentSigningCertificate: string;
dataGroupHashes: string;
}>;
}

View File

@@ -2,6 +2,10 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { AppState, type AppStateStatus } from 'react-native';
import { NativeModules } from 'react-native';
import { ENABLE_DEBUG_LOGS, MIXPANEL_NFC_PROJECT_TOKEN } from '@env';
import NetInfo from '@react-native-community/netinfo';
import type { JsonMap, JsonValue } from '@segment/analytics-react-native';
import { TrackEventParams } from '@selfxyz/mobile-sdk-alpha';
@@ -10,6 +14,15 @@ import { createSegmentClient } from '@/Segment';
const segmentClient = createSegmentClient();
// --- Analytics flush strategy ---
let mixpanelConfigured = false;
let eventCount = 0;
let isConnected = true;
const eventQueue: Array<{
name: string;
properties?: Record<string, unknown>;
}> = [];
function coerceToJsonValue(
value: unknown,
seen = new WeakSet(),
@@ -136,3 +149,101 @@ const analytics = () => {
};
export default analytics;
/**
* Cleanup function to clear event queues
*/
export const cleanupAnalytics = () => {
eventQueue.length = 0;
eventCount = 0;
};
const setupFlushPolicies = () => {
AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'background' || state === 'active') {
flushMixpanelEvents();
}
});
NetInfo.addEventListener(state => {
isConnected = state.isConnected ?? true;
if (isConnected) {
flushMixpanelEvents();
}
});
};
const flushMixpanelEvents = () => {
if (!MIXPANEL_NFC_PROJECT_TOKEN) return;
try {
if (__DEV__) console.log('[Mixpanel] flush');
// Send any queued events before flushing
while (eventQueue.length > 0) {
const evt = eventQueue.shift()!;
NativeModules.PassportReader?.trackEvent?.(evt.name, evt.properties);
}
NativeModules.PassportReader?.flush?.();
eventCount = 0;
} catch (err) {
if (__DEV__) console.warn('Mixpanel flush failed', err);
// re-queue on failure
if (typeof err !== 'undefined') {
// no-op, events are already queued if failure happened before flush
}
}
};
// --- Mixpanel NFC Analytics ---
export const configureNfcAnalytics = () => {
if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return;
const enableDebugLogs = JSON.parse(String(ENABLE_DEBUG_LOGS));
NativeModules.PassportReader.configure(
MIXPANEL_NFC_PROJECT_TOKEN,
enableDebugLogs,
{
flushInterval: 20,
flushCount: 5,
flushOnBackground: true,
flushOnForeground: true,
flushOnNetworkChange: true,
},
);
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();
// Flush Mixpanel events
flushMixpanelEvents();
};
export const trackNfcEvent = (
name: string,
properties?: Record<string, unknown>,
) => {
if (!MIXPANEL_NFC_PROJECT_TOKEN) return;
if (!mixpanelConfigured) configureNfcAnalytics();
if (!isConnected) {
eventQueue.push({ name, properties });
return;
}
try {
NativeModules.PassportReader?.trackEvent?.(name, properties);
eventCount++;
if (eventCount >= 5) {
flushMixpanelEvents();
}
} catch (err) {
eventQueue.push({ name, properties });
}
};

View File

@@ -2,14 +2,14 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Buffer } from 'buffer';
import { NativeModules, Platform } from 'react-native';
import PassportReader from 'react-native-passport-reader';
import { ENABLE_DEBUG_LOGS, MIXPANEL_NFC_PROJECT_TOKEN } from '@env';
import { reset, scan as scanDocument } from 'react-native-passport-reader';
import type { PassportData } from '@selfxyz/common/types';
import { configureNfcAnalytics } from '@/utils/analytics';
interface AndroidScanResponse {
mrz: string;
eContent: string;
@@ -43,9 +43,17 @@ export const parseScanResponse = (response: unknown) => {
: handleResponseIOS(response);
};
export const scan = async (inputs: Inputs) => {
configureNfcAnalytics();
return Platform.OS === 'android'
? await scanAndroid(inputs)
: await scanIOS(inputs);
};
const scanAndroid = async (inputs: Inputs) => {
PassportReader.reset();
return await PassportReader.scan({
reset();
return await scanDocument({
documentNumber: inputs.passportNumber,
dateOfBirth: inputs.dateOfBirth,
dateOfExpiry: inputs.dateOfExpiry,
@@ -55,7 +63,7 @@ const scanAndroid = async (inputs: Inputs) => {
};
const scanIOS = async (inputs: Inputs) => {
return await NativeModules.PassportReader.scanPassport(
return await NativeModules.PassportReader.scanDocument(
inputs.passportNumber,
inputs.dateOfBirth,
inputs.dateOfExpiry,
@@ -68,23 +76,6 @@ const scanIOS = async (inputs: Inputs) => {
);
};
export const scan = async (inputs: Inputs) => {
if (MIXPANEL_NFC_PROJECT_TOKEN) {
if (Platform.OS === 'ios') {
const enableDebugLogs = JSON.parse(String(ENABLE_DEBUG_LOGS));
NativeModules.PassportReader.configure(
MIXPANEL_NFC_PROJECT_TOKEN,
enableDebugLogs,
);
} else {
}
}
return Platform.OS === 'android'
? await scanAndroid(inputs)
: await scanIOS(inputs);
};
const handleResponseIOS = (response: unknown) => {
const parsed = JSON.parse(String(response));
const dgHashesObj = JSON.parse(parsed?.dataGroupHashes);

View File

@@ -7,11 +7,13 @@ import { Text } from 'react-native';
import { render } from '@testing-library/react-native';
const mockFlush = jest.fn();
const mockAnalytics = jest.fn(() => ({
flush: mockFlush,
}));
const mockTrackNfcEvent = jest.fn();
const mockFlushAllAnalytics = jest.fn();
jest.doMock('@/utils/analytics', () => mockAnalytics);
jest.doMock('@/utils/analytics', () => ({
trackNfcEvent: mockTrackNfcEvent,
flushAllAnalytics: mockFlushAllAnalytics,
}));
jest.mock('@/Sentry', () => ({
captureException: jest.fn(),
}));
@@ -81,7 +83,11 @@ describe('ErrorBoundary', () => {
);
consoleError.mockRestore();
expect(mockFlush).toHaveBeenCalled();
expect(mockTrackNfcEvent).toHaveBeenCalledWith('error_boundary', {
message: 'boom',
stack: expect.any(String),
});
expect(mockFlushAllAnalytics).toHaveBeenCalled();
});
it('renders children normally when no error occurs', () => {

View File

@@ -42,6 +42,10 @@ export default defineConfig({
__dirname,
'src/mocks/react-native-gesture-handler.ts',
),
'react-native-passport-reader': path.resolve(
__dirname,
'src/mocks/react-native-passport-reader.ts',
),
},
},
plugins: [

View File

@@ -4833,9 +4833,9 @@ __metadata:
languageName: node
linkType: hard
"@segment/analytics-react-native@npm:^2.21.0":
version: 2.21.1
resolution: "@segment/analytics-react-native@npm:2.21.1"
"@segment/analytics-react-native@npm:^2.21.2":
version: 2.21.2
resolution: "@segment/analytics-react-native@npm:2.21.2"
dependencies:
"@segment/tsub": "npm:2.0.0"
"@stdlib/number-float64-base-normalize": "npm:0.0.8"
@@ -4851,7 +4851,7 @@ __metadata:
peerDependenciesMeta:
"@react-native-async-storage/async-storage":
optional: true
checksum: 10c0/5d7696f0b295d13bbb9d786db7ecf9c1c679a51525bf3051aef1907708b2808e4627da378998900c870358d54cd7680ad03cbaff2786dcb5faef2c2dfdddd03d
checksum: 10c0/bc87ab176fa4280e200f396bc5ee70e8cb0c0fd484f293e13bd8c5f59d1d295e270c839f1a965da740af75a686bd7294392b57edd21502121816c74fa5905845
languageName: node
linkType: hard
@@ -5103,7 +5103,7 @@ __metadata:
"@react-navigation/native": "npm:^7.0.14"
"@react-navigation/native-stack": "npm:^7.2.0"
"@robinbobin/react-native-google-drive-api-wrapper": "npm:^2.2.3"
"@segment/analytics-react-native": "npm:^2.21.0"
"@segment/analytics-react-native": "npm:^2.21.2"
"@segment/sovran-react-native": "npm:^1.1.3"
"@selfxyz/common": "workspace:^"
"@selfxyz/mobile-sdk-alpha": "workspace:^"