[SELF-723] feat: add structured NFC and Proof logging (#1048)

* feat: add structured NFC logging

* fix ci

* Fix: add deps

* logging fixes. use breadcrumbs

* fix android build

* update SeverityLevel

* [SELF-705] feat: add proof event logging (#1057)

* feat: add proof event logging

* refactor: unify sentry event logging

* fix types

* fix mock

* simplify

* code rabbit feedback

* fix tests

---------

Co-authored-by: seshanthS <seshanth@protonmail.com>
This commit is contained in:
Justin Hernandez
2025-09-12 17:12:44 -07:00
committed by GitHub
parent 94d8fcada5
commit 99165c95dc
19 changed files with 1702 additions and 251 deletions

View File

@@ -4,15 +4,100 @@
import { SENTRY_DSN } from '@env';
import {
addBreadcrumb,
captureException as sentryCaptureException,
captureFeedback as sentryCaptureFeedback,
captureMessage as sentryCaptureMessage,
consoleLoggingIntegration,
feedbackIntegration,
init as sentryInit,
withScope,
wrap,
} from '@sentry/react-native';
interface BaseContext {
sessionId: string;
userId?: string;
platform: 'ios' | 'android';
stage: string;
}
// Security: Whitelist of allowed tag keys to prevent XSS
const ALLOWED_TAG_KEYS = new Set([
'session_id',
'platform',
'stage',
'circuitType',
'currentState',
'scanType',
'error_code',
'proof_step',
'scan_result',
'verification_status',
'document_type',
]);
// Security: Sanitize tag values to prevent XSS
const sanitizeTagValue = (value: unknown): string => {
if (value == null) return '';
const stringValue = String(value);
// Truncate to safe length
const MAX_TAG_LENGTH = 200;
const truncated =
stringValue.length > MAX_TAG_LENGTH
? stringValue.substring(0, MAX_TAG_LENGTH) + '...'
: stringValue;
// Escape HTML characters and remove potentially dangerous characters
return (
truncated
.replace(/[<>&"']/g, char => {
switch (char) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case '"':
return '&quot;';
case "'":
return '&#x27;';
default:
return char;
}
})
// Remove control characters and non-printable characters
.replace(/[^\x20-\x7E]/g, '')
);
};
// Security: Sanitize tag key to prevent XSS
const sanitizeTagKey = (key: string): string | null => {
// Only allow whitelisted keys
if (!ALLOWED_TAG_KEYS.has(key)) {
return null;
}
// Additional validation: alphanumeric and underscores only
if (!/^[a-zA-Z0-9_]+$/.test(key)) {
return null;
}
return key;
};
export interface NFCScanContext extends BaseContext, Record<string, unknown> {
scanType: 'mrz' | 'can';
}
export interface ProofContext extends BaseContext, Record<string, unknown> {
circuitType: 'register' | 'dsc' | 'disclose' | null;
currentState: string;
}
export const captureException = (
error: Error,
context?: Record<string, unknown>,
@@ -118,6 +203,78 @@ export const initSentry = () => {
export const isSentryDisabled = !SENTRY_DSN;
type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'proof' | 'nfc';
export const logEvent = (
level: LogLevel,
category: LogCategory,
message: string,
context: BaseContext & Record<string, unknown>,
extra?: Record<string, unknown>,
) => {
if (isSentryDisabled) {
return;
}
const { sessionId, userId, platform, stage, ...rest } = context;
const data = {
session_id: sessionId,
user_id: userId,
platform,
stage,
...rest,
...extra,
};
if (level === 'error') {
withScope(scope => {
scope.setLevel('error');
scope.setTag('session_id', sessionId);
scope.setTag('platform', platform);
scope.setTag('stage', stage);
Object.entries(rest).forEach(([key, value]) => {
const sanitizedKey = sanitizeTagKey(key);
if (sanitizedKey) {
const sanitizedValue = sanitizeTagValue(value);
scope.setTag(sanitizedKey, sanitizedValue);
}
});
if (userId) {
scope.setUser({ id: userId });
}
if (extra) {
Object.entries(extra).forEach(([key, value]) => {
scope.setExtra(key, value);
});
}
sentryCaptureMessage(message);
});
} else {
addBreadcrumb({
message,
level: level === 'warn' ? 'warning' : 'info',
category,
data,
timestamp: Date.now() / 1000,
});
}
};
export const logNFCEvent = (
level: LogLevel,
message: string,
context: NFCScanContext,
extra?: Record<string, unknown>,
) => logEvent(level, 'nfc', message, context, extra);
export const logProofEvent = (
level: LogLevel,
message: string,
context: ProofContext,
extra?: Record<string, unknown>,
) => logEvent(level, 'proof', message, context, extra);
export const wrapWithSentry = (App: React.ComponentType) => {
return isSentryDisabled ? App : wrap(App);
};

View File

@@ -4,14 +4,99 @@
import { SENTRY_DSN } from '@env';
import {
addBreadcrumb,
captureException as sentryCaptureException,
captureFeedback as sentryCaptureFeedback,
captureMessage as sentryCaptureMessage,
feedbackIntegration,
init as sentryInit,
withProfiler,
withScope,
} from '@sentry/react';
interface BaseContext {
sessionId: string;
userId?: string;
platform: 'ios' | 'android';
stage: string;
}
// Security: Whitelist of allowed tag keys to prevent XSS
const ALLOWED_TAG_KEYS = new Set([
'session_id',
'platform',
'stage',
'circuitType',
'currentState',
'scanType',
'error_code',
'proof_step',
'scan_result',
'verification_status',
'document_type',
]);
// Security: Sanitize tag values to prevent XSS
const sanitizeTagValue = (value: unknown): string => {
if (value == null) return '';
const stringValue = String(value);
// Truncate to safe length
const MAX_TAG_LENGTH = 200;
const truncated =
stringValue.length > MAX_TAG_LENGTH
? stringValue.substring(0, MAX_TAG_LENGTH) + '...'
: stringValue;
// Escape HTML characters and remove potentially dangerous characters
return (
truncated
.replace(/[<>&"']/g, char => {
switch (char) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case '"':
return '&quot;';
case "'":
return '&#x27;';
default:
return char;
}
})
// Remove control characters and non-printable characters
.replace(/[^\x20-\x7E]/g, '')
);
};
// Security: Sanitize tag key to prevent XSS
const sanitizeTagKey = (key: string): string | null => {
// Only allow whitelisted keys
if (!ALLOWED_TAG_KEYS.has(key)) {
return null;
}
// Additional validation: alphanumeric and underscores only
if (!/^[a-zA-Z0-9_]+$/.test(key)) {
return null;
}
return key;
};
export interface NFCScanContext extends BaseContext, Record<string, unknown> {
scanType: 'mrz' | 'can';
}
export interface ProofContext extends BaseContext, Record<string, unknown> {
circuitType: 'register' | 'dsc' | 'disclose' | null;
currentState: string;
}
export const captureException = (
error: Error,
context?: Record<string, unknown>,
@@ -110,6 +195,78 @@ export const initSentry = () => {
export const isSentryDisabled = !SENTRY_DSN;
type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'proof' | 'nfc';
export const logEvent = (
level: LogLevel,
category: LogCategory,
message: string,
context: BaseContext & Record<string, unknown>,
extra?: Record<string, unknown>,
) => {
if (isSentryDisabled) {
return;
}
const { sessionId, userId, platform, stage, ...rest } = context;
const data = {
session_id: sessionId,
user_id: userId,
platform,
stage,
...rest,
...extra,
};
if (level === 'error') {
withScope(scope => {
scope.setLevel('error');
scope.setTag('session_id', sessionId);
scope.setTag('platform', platform);
scope.setTag('stage', stage);
Object.entries(rest).forEach(([key, value]) => {
const sanitizedKey = sanitizeTagKey(key);
if (sanitizedKey) {
const sanitizedValue = sanitizeTagValue(value);
scope.setTag(sanitizedKey, sanitizedValue);
}
});
if (userId) {
scope.setUser({ id: userId });
}
if (extra) {
Object.entries(extra).forEach(([key, value]) => {
scope.setExtra(key, value);
});
}
sentryCaptureMessage(message);
});
} else {
addBreadcrumb({
message,
level: level === 'warn' ? 'warning' : 'info',
category,
data,
timestamp: Date.now() / 1000,
});
}
};
export const logNFCEvent = (
level: LogLevel,
message: string,
context: NFCScanContext,
extra?: Record<string, unknown>,
) => logEvent(level, 'nfc', message, context, extra);
export const logProofEvent = (
level: LogLevel,
message: string,
context: ProofContext,
extra?: Record<string, unknown>,
) => logEvent(level, 'proof', message, context, extra);
export const wrapWithSentry = (App: React.ComponentType) => {
return isSentryDisabled ? App : withProfiler(App);
};

View File

@@ -14,6 +14,7 @@ import {
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import NfcManager from 'react-native-nfc-manager';
import { Button, Image, XStack } from 'tamagui';
import { v4 as uuidv4 } from 'uuid';
import type { RouteProp } from '@react-navigation/native';
import {
useFocusEffect,
@@ -44,6 +45,7 @@ import NFC_IMAGE from '@/images/nfc.png';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { useFeedback } from '@/providers/feedbackProvider';
import { storePassportData } from '@/providers/passportDataProvider';
import { logNFCEvent } from '@/Sentry';
import useUserStore from '@/stores/userStore';
import {
flushAllAnalytics,
@@ -104,6 +106,13 @@ const DocumentNFCScanScreen: React.FC = () => {
const [nfcMessage, setNfcMessage] = useState<string | null>(null);
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scanCancelledRef = useRef(false);
const sessionIdRef = useRef(uuidv4());
const baseContext = {
sessionId: sessionIdRef.current,
platform: Platform.OS as 'ios' | 'android',
scanType: route.params?.useCan ? 'can' : 'mrz',
} as const;
const animationRef = useRef<LottieView>(null);
@@ -111,6 +120,16 @@ const DocumentNFCScanScreen: React.FC = () => {
animationRef.current?.play();
}, []);
useEffect(() => {
logNFCEvent('info', 'screen_mount', { ...baseContext, stage: 'mount' });
return () => {
logNFCEvent('info', 'screen_unmount', {
...baseContext,
stage: 'unmount',
});
};
}, []);
// Cleanup timeout on component unmount
useEffect(() => {
return () => {
@@ -144,6 +163,15 @@ const DocumentNFCScanScreen: React.FC = () => {
const openErrorModal = useCallback(
(message: string) => {
flushAllAnalytics();
logNFCEvent(
'error',
'nfc_error_modal',
{
...baseContext,
stage: 'error',
},
{ message: sanitizeErrorMessage(message) },
);
showModal({
titleText: 'NFC Scan Error',
bodyText: message,
@@ -171,6 +199,18 @@ const DocumentNFCScanScreen: React.FC = () => {
setDialogMessage('NFC is not enabled. Please enable it in settings.');
}
setIsNfcSupported(true);
logNFCEvent(
'info',
'nfc_capability',
{
...baseContext,
stage: 'check',
},
{
supported: true,
enabled: isEnabled,
},
);
} else {
setDialogMessage(
"Sorry, your device doesn't seem to have an NFC reader.",
@@ -179,6 +219,18 @@ const DocumentNFCScanScreen: React.FC = () => {
// near the disabled button when NFC isn't supported
setIsNfcEnabled(false);
setIsNfcSupported(false);
logNFCEvent(
'warn',
'nfc_capability',
{
...baseContext,
stage: 'check',
},
{
supported: false,
enabled: false,
},
);
}
}, []);
@@ -200,7 +252,12 @@ const DocumentNFCScanScreen: React.FC = () => {
const onVerifyPress = useCallback(async () => {
buttonTap();
if (isNfcEnabled) {
logNFCEvent('info', 'verify_pressed', {
...baseContext,
stage: 'ui',
});
setIsNfcSheetOpen(true);
logNFCEvent('info', 'sheet_open', { ...baseContext, stage: 'ui' });
// Add timestamp when scan starts
scanCancelledRef.current = false;
const scanStartTime = Date.now();
@@ -213,8 +270,16 @@ const DocumentNFCScanScreen: React.FC = () => {
trackEvent(PassportEvents.NFC_SCAN_FAILED, {
error: 'timeout',
});
logNFCEvent('warn', 'scan_timeout', {
...baseContext,
stage: 'timeout',
});
openErrorModal('Scan timed out. Please try again.');
setIsNfcSheetOpen(false);
logNFCEvent('info', 'sheet_close', {
...baseContext,
stage: 'ui',
});
}, 30000);
// Mark NFC scanning as active to prevent analytics flush interference
@@ -233,8 +298,16 @@ const DocumentNFCScanScreen: React.FC = () => {
trackNfcEvent(PassportEvents.NFC_SCAN_FAILED, {
error: 'timeout',
});
logNFCEvent('warn', 'scan_timeout', {
...baseContext,
stage: 'timeout',
});
openErrorModal('Scan timed out. Please try again.');
setIsNfcSheetOpen(false);
logNFCEvent('info', 'sheet_close', {
...baseContext,
stage: 'ui',
});
}, 30000);
try {
@@ -251,6 +324,7 @@ const DocumentNFCScanScreen: React.FC = () => {
skipCA,
extendedMode,
usePacePolling: isPacePolling,
sessionId: sessionIdRef.current,
});
// Check if scan was cancelled by timeout
@@ -270,6 +344,15 @@ const DocumentNFCScanScreen: React.FC = () => {
trackEvent(PassportEvents.NFC_SCAN_SUCCESS, {
duration_seconds: parseFloat(scanDurationSeconds),
});
logNFCEvent(
'info',
'scan_success',
{
...baseContext,
stage: 'complete',
},
{ duration_seconds: parseFloat(scanDurationSeconds) },
);
let passportData: PassportData | null = null;
let parsedPassportData: PassportData | null = null;
try {
@@ -388,6 +471,7 @@ const DocumentNFCScanScreen: React.FC = () => {
scanTimeoutRef.current = null;
}
setIsNfcSheetOpen(false);
logNFCEvent('info', 'sheet_close', { ...baseContext, stage: 'ui' });
setNfcScanningActive(false);
}
} else if (isNfcSupported) {
@@ -419,6 +503,7 @@ const DocumentNFCScanScreen: React.FC = () => {
const onCancelPress = async () => {
flushAllAnalytics();
logNFCEvent('info', 'scan_cancelled', { ...baseContext, stage: 'cancel' });
const hasValidDocument = await hasAnyValidRegisteredDocument(selfClient);
if (hasValidDocument) {
navigateToHome();
@@ -435,6 +520,7 @@ const DocumentNFCScanScreen: React.FC = () => {
useFocusEffect(
useCallback(() => {
logNFCEvent('info', 'screen_focus', { ...baseContext, stage: 'focus' });
checkNfcSupport();
if (Platform.OS === 'android' && emitter) {
@@ -469,6 +555,7 @@ const DocumentNFCScanScreen: React.FC = () => {
);
return () => {
logNFCEvent('info', 'screen_blur', { ...baseContext, stage: 'blur' });
subscription.remove();
// Clear scan timeout when component loses focus
scanCancelledRef.current = true;
@@ -481,6 +568,7 @@ const DocumentNFCScanScreen: React.FC = () => {
// For iOS or when no emitter, still handle timeout cleanup on blur
return () => {
logNFCEvent('info', 'screen_blur', { ...baseContext, stage: 'blur' });
scanCancelledRef.current = true;
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);

View File

@@ -10,6 +10,7 @@ declare module 'react-native-passport-reader' {
canNumber: string;
useCan: boolean;
quality?: number;
sessionId?: string;
}
interface PassportReader {
@@ -27,6 +28,7 @@ declare module 'react-native-passport-reader' {
skipCA: boolean,
extendedMode: boolean,
usePacePolling: boolean,
sessionId: string,
): Promise<{
mrz: string;
eContent: string;

View File

@@ -7,6 +7,7 @@ import { Platform } from 'react-native';
import type { PassportData } from '@selfxyz/common/types';
import { logNFCEvent, type NFCScanContext } from '@/Sentry';
import { configureNfcAnalytics } from '@/utils/analytics';
import {
PassportReader,
@@ -39,6 +40,8 @@ interface Inputs {
skipCA?: boolean;
extendedMode?: boolean;
usePacePolling?: boolean;
sessionId: string;
userId?: string;
}
export const parseScanResponse = (response: unknown) => {
@@ -50,18 +53,46 @@ export const parseScanResponse = (response: unknown) => {
export const scan = async (inputs: Inputs) => {
await configureNfcAnalytics();
return Platform.OS === 'android'
? await scanAndroid(inputs)
: await scanIOS(inputs);
const baseContext = {
sessionId: inputs.sessionId,
userId: inputs.userId,
platform: Platform.OS as 'ios' | 'android',
scanType: inputs.useCan ? 'can' : 'mrz',
} as const;
logNFCEvent('info', 'scan_start', { ...baseContext, stage: 'start' });
try {
return Platform.OS === 'android'
? await scanAndroid(inputs, baseContext)
: await scanIOS(inputs, baseContext);
} catch (error) {
logNFCEvent(
'error',
'scan_failed',
{ ...baseContext, stage: 'scan' },
{
error: error instanceof Error ? error.message : String(error),
},
);
throw error;
}
};
const scanAndroid = async (inputs: Inputs) => {
const scanAndroid = async (
inputs: Inputs,
context: Omit<NFCScanContext, 'stage'>,
) => {
reset();
if (!scanDocument) {
console.warn(
'Android passport scanner is not available - native module failed to load',
);
logNFCEvent('error', 'module_unavailable', {
...context,
stage: 'init',
} as NFCScanContext);
return Promise.reject(new Error('NFC scanning is currently unavailable.'));
}
@@ -71,14 +102,22 @@ const scanAndroid = async (inputs: Inputs) => {
dateOfExpiry: inputs.dateOfExpiry,
canNumber: inputs.canNumber ?? '',
useCan: inputs.useCan ?? false,
sessionId: inputs.sessionId,
});
};
const scanIOS = async (inputs: Inputs) => {
const scanIOS = async (
inputs: Inputs,
context: Omit<NFCScanContext, 'stage'>,
) => {
if (!PassportReader?.scanPassport) {
console.warn(
'iOS passport scanner is not available - native module failed to load',
);
logNFCEvent('error', 'module_unavailable', {
...context,
stage: 'init',
} as NFCScanContext);
return Promise.reject(
new Error(
'NFC scanning is currently unavailable. Please ensure the app is properly installed.',
@@ -97,6 +136,7 @@ const scanIOS = async (inputs: Inputs) => {
inputs.skipCA ?? false,
inputs.extendedMode ?? false,
inputs.usePacePolling ?? false,
inputs.sessionId,
),
);
};

View File

@@ -14,6 +14,7 @@ type ScanOptions = {
skipCA?: boolean;
extendedMode?: boolean;
usePacePolling?: boolean;
sessionId?: string;
};
// Platform-specific PassportReader implementation
@@ -55,6 +56,7 @@ if (Platform.OS === 'android') {
skipCA = false,
extendedMode = false,
usePacePolling = true,
sessionId = '',
} = options;
const result = await PassportReader.scanPassport(
@@ -67,6 +69,7 @@ if (Platform.OS === 'android') {
skipCA,
extendedMode,
usePacePolling,
sessionId,
);
// iOS native returns a JSON string; normalize to object.
try {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
// 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.
export interface StatusHandlerResult {
shouldDisconnect: boolean;
stateUpdate?: {
error_code?: string;
reason?: string;
socketConnection?: null;
};
actorEvent?: {
type: 'PROVE_FAILURE' | 'PROVE_SUCCESS';
};
analytics?: Array<{
event: string;
data?: Record<string, unknown>;
}>;
}
/**
* Pure functions for handling Socket.IO status messages
* These can be tested independently without mocking complex dependencies
*/
export interface StatusMessage {
status: number;
error_code?: string;
reason?: string;
}
/**
* Determine actions to take based on status code
*/
export function handleStatusCode(
data: StatusMessage,
circuitType: string,
): StatusHandlerResult {
const result: StatusHandlerResult = {
shouldDisconnect: false,
analytics: [],
};
// Failure statuses (3 or 5)
if (data.status === 3 || data.status === 5) {
result.shouldDisconnect = true;
result.stateUpdate = {
error_code: data.error_code,
reason: data.reason,
socketConnection: null,
};
result.actorEvent = { type: 'PROVE_FAILURE' };
result.analytics = [
{
event: 'SOCKETIO_PROOF_FAILURE',
data: {
error_code: data.error_code,
reason: data.reason,
},
},
];
return result;
}
// Success status (4)
if (data.status === 4) {
result.shouldDisconnect = true;
result.stateUpdate = {
socketConnection: null,
};
result.actorEvent = { type: 'PROVE_SUCCESS' };
result.analytics = [
{
event: 'SOCKETIO_PROOF_SUCCESS',
},
];
// Additional tracking for register circuit
if (circuitType === 'register') {
result.analytics.push({
event: 'REGISTER_COMPLETED',
});
}
return result;
}
// Other statuses - no action needed
return result;
}
/**
* Parse incoming socket message into structured data
*/
export function parseStatusMessage(message: unknown): StatusMessage {
if (typeof message === 'string') {
try {
return JSON.parse(message) as StatusMessage;
} catch {
throw new Error('Invalid JSON message received');
}
}
if (typeof message === 'object' && message !== null) {
return message as StatusMessage;
}
throw new Error('Invalid message format');
}