diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e06f69db1fade..1b93b9b65b2ac 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1718,7 +1718,12 @@ const CONST = { }, TELEMETRY: { CONTEXT_FULLSTORY: 'Fullstory', + CONTEXT_MEMORY: 'Memory', CONTEXT_POLICIES: 'Policies', + // Breadcrumb names + BREADCRUMB_CATEGORY_MEMORY: 'system.memory', + BREADCRUMB_MEMORY_PERIODIC: 'Periodic memory check', + BREADCRUMB_MEMORY_FOREGROUND: 'App foreground - memory check', TAG_ACTIVE_POLICY: 'active_policy_id', TAG_POLICIES_COUNT: 'policies_count', TAG_REPORTS_COUNT: 'reports_count', @@ -1777,6 +1782,19 @@ const CONST = { ATTRIBUTE_FINISHED_MANUALLY: 'finished_manually', CONFIG: { SKELETON_MIN_DURATION: 10_000, + MEMORY_TRACKING_INTERVAL: 2 * 60 * 1000, + + // Web Memory Thresholds (% of jsHeapSizeLimit) + MEMORY_THRESHOLD_WEB_CRITICAL: 85, + MEMORY_THRESHOLD_WEB_WARNING: 70, + + // Android Memory Thresholds (% of device RAM - temporary solution) + MEMORY_THRESHOLD_ANDROID_CRITICAL: 85, // > 85% of device RAM + MEMORY_THRESHOLD_ANDROID_WARNING: 70, // > 70% of device RAM + + // iOS Memory Thresholds (absolute MB - no heap limit API available) + MEMORY_THRESHOLD_IOS_CRITICAL_MB: 600, // > 600MB approaching jetsam on older devices + MEMORY_THRESHOLD_IOS_WARNING_MB: 300, // > 300MB monitor closely }, }, PRIORITY_MODE: { diff --git a/src/Expensify.tsx b/src/Expensify.tsx index dade3083efc37..6a0fb6e8a0e95 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -48,8 +48,7 @@ import './libs/registerPaginationConfig'; import setCrashlyticsUserId from './libs/setCrashlyticsUserId'; import StartupTimer from './libs/StartupTimer'; import {endSpan, getSpan, startSpan} from './libs/telemetry/activeSpans'; -// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection -import './libs/telemetry/TelemetrySynchronizer'; +import {cleanupMemoryTrackingTelemetry, initializeMemoryTrackingTelemetry} from './libs/telemetry/TelemetrySynchronizer'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import './libs/UnreadIndicatorUpdater'; import Visibility from './libs/Visibility'; @@ -130,6 +129,13 @@ function Expensify() { useDebugShortcut(); usePriorityMode(); + useEffect(() => { + initializeMemoryTrackingTelemetry(); + return () => { + cleanupMemoryTrackingTelemetry(); + }; + }, []); + const bootsplashSpan = useRef(null); const [initialUrl, setInitialUrl] = useState(null); diff --git a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx index ca4b46d94a9ac..7e8462ec9c56a 100644 --- a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx +++ b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx @@ -2,7 +2,6 @@ import type JSZip from 'jszip'; import type {RefObject} from 'react'; import React, {useEffect, useState} from 'react'; import {Alert} from 'react-native'; -import DeviceInfo from 'react-native-device-info'; import Button from '@components/Button'; import Switch from '@components/Switch'; import TestToolRow from '@components/TestToolRow'; @@ -14,6 +13,7 @@ import {cleanupAfterDisable, disableRecording, enableRecording, stopProfilingAnd import type {ProfilingData} from '@libs/actions/Troubleshoot'; import {parseStringifiedMessages} from '@libs/Console'; import getPlatform from '@libs/getPlatform'; +import getMemoryInfo from '@libs/telemetry/getMemoryInfo'; import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Log as OnyxLog} from '@src/types/onyx'; @@ -49,7 +49,7 @@ type BaseRecordTroubleshootDataToolMenuProps = { displayPath?: string; }; -function formatBytes(bytes: number, decimals = 2) { +function formatBytes(bytes: number, decimals = 2): string { if (!+bytes) { return '0 Bytes'; } @@ -59,8 +59,9 @@ function formatBytes(bytes: number, decimals = 2) { const sizes = ['Bytes', 'KiB', 'MiB', 'GiB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); + const sizeIndex = Math.min(i, sizes.length - 1); - return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes.at(i)}`; + return `${parseFloat((bytes / k ** sizeIndex).toFixed(dm))} ${sizes.at(sizeIndex)}`; } // WARNING: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working! @@ -86,13 +87,13 @@ function BaseRecordTroubleshootDataToolMenu({ const [profileTracePath, setProfileTracePath] = useState(); const getAppInfo = async (profilingData: ProfilingData) => { - const [totalMemory, usedMemory] = await Promise.all([DeviceInfo.getTotalMemory(), DeviceInfo.getUsedMemory()]); + const memoryInfo = await getMemoryInfo(); return JSON.stringify({ appVersion: pkg.version, environment: CONFIG.ENVIRONMENT, platform: getPlatform(), - totalMemory: formatBytes(totalMemory, 2), - usedMemory: formatBytes(usedMemory, 2), + totalMemory: memoryInfo.totalMemoryBytes !== null ? formatBytes(memoryInfo.totalMemoryBytes, 2) : null, + usedMemory: memoryInfo.usedMemoryBytes !== null ? formatBytes(memoryInfo.usedMemoryBytes, 2) : null, memoizeStats: profilingData.memoizeStats, performance: profilingData.performanceMeasures, }); diff --git a/src/libs/telemetry/TelemetrySynchronizer.ts b/src/libs/telemetry/TelemetrySynchronizer.ts index b876ec63e055a..4475bcdb4d970 100644 --- a/src/libs/telemetry/TelemetrySynchronizer.ts +++ b/src/libs/telemetry/TelemetrySynchronizer.ts @@ -10,6 +10,7 @@ import {getActivePolicies} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Session, TryNewDot} from '@src/types/onyx'; +import {cleanupMemoryTracking, initializeMemoryTracking} from './sendMemoryContext'; /** * Connect to Onyx to retrieve information about the user's active policies. @@ -148,3 +149,5 @@ function sendReportsCountTag(reportsCount: number) { const reportsCountBucket = bucketReportCount(reportsCount); Sentry.setTag(CONST.TELEMETRY.TAG_REPORTS_COUNT, reportsCountBucket); } + +export {initializeMemoryTracking as initializeMemoryTrackingTelemetry, cleanupMemoryTracking as cleanupMemoryTrackingTelemetry}; diff --git a/src/libs/telemetry/formatMemoryBreadcrumb/index.android.ts b/src/libs/telemetry/formatMemoryBreadcrumb/index.android.ts new file mode 100644 index 0000000000000..f6af8151fb319 --- /dev/null +++ b/src/libs/telemetry/formatMemoryBreadcrumb/index.android.ts @@ -0,0 +1,12 @@ +import type {MemoryInfo} from '../getMemoryInfo/types'; + +/** + * Format breadcrumb message for Android platform + * @param memoryInfo - Memory information object + * @param usedMemoryMB - Used memory in MB (from memoryInfo.usedMemoryMB) + * @returns Formatted breadcrumb message + */ +export default function formatMemoryBreadcrumb(memoryInfo: MemoryInfo, usedMemoryMB: number | null): string { + const usagePercent = memoryInfo.usagePercentage?.toFixed(0) ?? '?'; + return `RAM Check: ${usedMemoryMB ?? '?'}MB used (${usagePercent}% device RAM)`; +} diff --git a/src/libs/telemetry/formatMemoryBreadcrumb/index.ios.ts b/src/libs/telemetry/formatMemoryBreadcrumb/index.ios.ts new file mode 100644 index 0000000000000..e6a2f35cd70ad --- /dev/null +++ b/src/libs/telemetry/formatMemoryBreadcrumb/index.ios.ts @@ -0,0 +1,11 @@ +import type {MemoryInfo} from '../getMemoryInfo/types'; + +/** + * Format breadcrumb message for iOS platform + * @param memoryInfo - Memory information object + * @param usedMemoryMB - Used memory in MB (from memoryInfo.usedMemoryMB) + * @returns Formatted breadcrumb message + */ +export default function formatMemoryBreadcrumb(_memoryInfo: MemoryInfo, usedMemoryMB: number | null): string { + return `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)`; +} diff --git a/src/libs/telemetry/formatMemoryBreadcrumb/index.ts b/src/libs/telemetry/formatMemoryBreadcrumb/index.ts new file mode 100644 index 0000000000000..bab0d06e01e8e --- /dev/null +++ b/src/libs/telemetry/formatMemoryBreadcrumb/index.ts @@ -0,0 +1,12 @@ +import type {MemoryInfo} from '../getMemoryInfo/types'; + +/** + * Format breadcrumb message for web platform + * @param memoryInfo - Memory information object + * @param usedMemoryMB - Used memory in MB (from memoryInfo.usedMemoryMB) + * @returns Formatted breadcrumb message + */ +export default function formatMemoryBreadcrumb(memoryInfo: MemoryInfo, usedMemoryMB: number | null): string { + const maxMB = memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'; + return `RAM Check: ${usedMemoryMB ?? '?'}MB / ${maxMB}MB limit`; +} diff --git a/src/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts new file mode 100644 index 0000000000000..18d932e655ea5 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -0,0 +1,67 @@ +import {Platform} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import type {MemoryInfo} from './types'; + +const BYTES_PER_MB = 1024 * 1024; + +const normalizeMemoryValue = (value: number | null | undefined): number | null => { + if (value === null || value === undefined || value < 0) { + return null; + } + return value; +}; + +const getMemoryInfo = async (): Promise => { + try { + const totalMemoryBytesRaw = DeviceInfo.getTotalMemorySync?.() ?? null; + const totalMemoryBytes = normalizeMemoryValue(totalMemoryBytesRaw); + + const [usedMemory, maxMemory] = await Promise.allSettled([DeviceInfo.getUsedMemory(), Platform.OS === 'android' ? DeviceInfo.getMaxMemory() : Promise.resolve(null)]); + + const usedMemoryBytesRaw = usedMemory.status === 'fulfilled' ? usedMemory.value : null; + const usedMemoryBytes = normalizeMemoryValue(usedMemoryBytesRaw); + + const maxMemoryBytesRaw = maxMemory.status === 'fulfilled' ? maxMemory.value : null; + const maxMemoryBytes = normalizeMemoryValue(maxMemoryBytesRaw); + + // Calculate usage percentage based on appropriate metric per platform + // Android: Use % of device RAM (RSS / totalMemory) - temporary until native onTrimMemory module + // This is more adaptive than absolute MB and scales with device capabilities + // iOS: No calculation (use absolute MB thresholds) + let usagePercentage: number | null = null; + if (Platform.OS === 'android' && usedMemoryBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0) { + usagePercentage = parseFloat(((usedMemoryBytes / totalMemoryBytes) * 100).toFixed(2)); + } + + const freeMemoryBytes = totalMemoryBytes !== null && usedMemoryBytes !== null ? totalMemoryBytes - usedMemoryBytes : null; + const freeMemoryMB = freeMemoryBytes !== null ? Math.round(freeMemoryBytes / BYTES_PER_MB) : null; + const freeMemoryPercentage = + totalMemoryBytes !== null && usedMemoryBytes !== null && totalMemoryBytes > 0 ? parseFloat((((totalMemoryBytes - usedMemoryBytes) / totalMemoryBytes) * 100).toFixed(2)) : null; + + return { + usedMemoryBytes, + usedMemoryMB: usedMemoryBytes !== null ? Math.round(usedMemoryBytes / BYTES_PER_MB) : null, + totalMemoryBytes, + maxMemoryBytes, + usagePercentage, + freeMemoryBytes, + freeMemoryMB, + freeMemoryPercentage, + platform: Platform.OS, + }; + } catch (error) { + return { + usedMemoryBytes: null, + usedMemoryMB: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + freeMemoryBytes: null, + freeMemoryMB: null, + freeMemoryPercentage: null, + platform: Platform.OS, + }; + } +}; + +export default getMemoryInfo; diff --git a/src/libs/telemetry/getMemoryInfo/index.ts b/src/libs/telemetry/getMemoryInfo/index.ts new file mode 100644 index 0000000000000..248be80d4d4c7 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/index.ts @@ -0,0 +1,84 @@ +import {Platform} from 'react-native'; +import type {MemoryInfo} from './types'; + +const BYTES_PER_MB = 1024 * 1024; + +// Only works in Chrome/Edge (Chromium browsers) - navigator.deviceMemory and performance.memory are not available in Firefox/Safari +const getMemoryInfo = async (): Promise => { + try { + let totalMemoryBytes: number | null = null; + let usedMemoryBytes: number | null = null; + let maxMemoryBytes: number | null = null; + + if (typeof window === 'undefined') { + return { + usedMemoryBytes: null, + usedMemoryMB: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + freeMemoryBytes: null, + freeMemoryMB: null, + freeMemoryPercentage: null, + platform: Platform.OS, + }; + } + + // We prioritize this API as the source of truth for web memory measurements + if (window.performance) { + try { + const perfMemory = ( + window.performance as Performance & { + memory?: { + usedJSHeapSize?: number; + totalJSHeapSize?: number; + jsHeapSizeLimit?: number; + }; + } + ).memory; + + if (perfMemory) { + if (perfMemory.jsHeapSizeLimit && perfMemory.jsHeapSizeLimit > 0) { + maxMemoryBytes = perfMemory.jsHeapSizeLimit; + } + + if (perfMemory.totalJSHeapSize && perfMemory.totalJSHeapSize > 0) { + totalMemoryBytes = perfMemory.totalJSHeapSize; + } + + if (perfMemory.usedJSHeapSize && perfMemory.usedJSHeapSize > 0) { + usedMemoryBytes = perfMemory.usedJSHeapSize; + } + } + } catch (error) { + // Gracefully degrade - these APIs are not available in Firefox/Safari + } + } + + return { + usedMemoryBytes, + usedMemoryMB: usedMemoryBytes !== null ? Math.round(usedMemoryBytes / BYTES_PER_MB) : null, + totalMemoryBytes, + maxMemoryBytes, + usagePercentage: usedMemoryBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0 ? parseFloat(((usedMemoryBytes / totalMemoryBytes) * 100).toFixed(2)) : null, + freeMemoryBytes: totalMemoryBytes !== null && usedMemoryBytes !== null ? totalMemoryBytes - usedMemoryBytes : null, + freeMemoryMB: totalMemoryBytes !== null && usedMemoryBytes !== null ? Math.round((totalMemoryBytes - usedMemoryBytes) / BYTES_PER_MB) : null, + freeMemoryPercentage: totalMemoryBytes !== null && usedMemoryBytes !== null ? parseFloat((((totalMemoryBytes - usedMemoryBytes) / totalMemoryBytes) * 100).toFixed(2)) : null, + platform: Platform.OS, + }; + } catch (error) { + return { + usedMemoryBytes: null, + usedMemoryMB: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + freeMemoryBytes: null, + freeMemoryMB: null, + freeMemoryPercentage: null, + platform: Platform.OS, + }; + } +}; + +export default getMemoryInfo; diff --git a/src/libs/telemetry/getMemoryInfo/types.ts b/src/libs/telemetry/getMemoryInfo/types.ts new file mode 100644 index 0000000000000..96f439de7e9e2 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/types.ts @@ -0,0 +1,14 @@ +type MemoryInfo = { + usedMemoryBytes: number | null; + usedMemoryMB: number | null; + totalMemoryBytes: number | null; + maxMemoryBytes: number | null; + usagePercentage: number | null; + freeMemoryBytes: number | null; + freeMemoryMB: number | null; + freeMemoryPercentage: number | null; + platform: string; +}; + +// eslint-disable-next-line import/prefer-default-export -- Single type export is intentional; more types may be added in the future +export type {MemoryInfo}; diff --git a/src/libs/telemetry/getMemoryLogLevel/index.android.ts b/src/libs/telemetry/getMemoryLogLevel/index.android.ts new file mode 100644 index 0000000000000..c18d0af1b56aa --- /dev/null +++ b/src/libs/telemetry/getMemoryLogLevel/index.android.ts @@ -0,0 +1,20 @@ +import type * as Sentry from '@sentry/react-native'; +import CONST from '@src/CONST'; +import type {MemoryInfo} from '../getMemoryInfo/types'; + +/** + * Determine memory log level for Android platform based on device RAM percentage + * @param memoryInfo - Memory information object + * @returns Sentry severity level based on memory usage + */ +export default function getMemoryLogLevel(memoryInfo: MemoryInfo): Sentry.SeverityLevel { + if (memoryInfo.usagePercentage !== null) { + if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_CRITICAL) { + return 'error'; + } + if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_WARNING) { + return 'warning'; + } + } + return 'info'; +} diff --git a/src/libs/telemetry/getMemoryLogLevel/index.ios.ts b/src/libs/telemetry/getMemoryLogLevel/index.ios.ts new file mode 100644 index 0000000000000..88f1e5da65b66 --- /dev/null +++ b/src/libs/telemetry/getMemoryLogLevel/index.ios.ts @@ -0,0 +1,20 @@ +import type * as Sentry from '@sentry/react-native'; +import CONST from '@src/CONST'; +import type {MemoryInfo} from '../getMemoryInfo/types'; + +/** + * Determine memory log level for iOS platform based on absolute MB values + * @param memoryInfo - Memory information object + * @returns Sentry severity level based on memory usage + */ +export default function getMemoryLogLevel(memoryInfo: MemoryInfo): Sentry.SeverityLevel { + if (memoryInfo.usedMemoryMB !== null) { + if (memoryInfo.usedMemoryMB > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_IOS_CRITICAL_MB) { + return 'error'; + } + if (memoryInfo.usedMemoryMB > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_IOS_WARNING_MB) { + return 'warning'; + } + } + return 'info'; +} diff --git a/src/libs/telemetry/getMemoryLogLevel/index.ts b/src/libs/telemetry/getMemoryLogLevel/index.ts new file mode 100644 index 0000000000000..235839a1b04c9 --- /dev/null +++ b/src/libs/telemetry/getMemoryLogLevel/index.ts @@ -0,0 +1,22 @@ +import type * as Sentry from '@sentry/react-native'; +import CONST from '@src/CONST'; +import type {MemoryInfo} from '../getMemoryInfo/types'; + +/** + * Determine memory log level for web platform based on JS heap percentage + * @param memoryInfo - Memory information object + * @returns Sentry severity level based on memory usage + */ +export default function getMemoryLogLevel(memoryInfo: MemoryInfo): Sentry.SeverityLevel { + const usagePercent = memoryInfo.usedMemoryBytes !== null && memoryInfo.maxMemoryBytes !== null ? (memoryInfo.usedMemoryBytes / memoryInfo.maxMemoryBytes) * 100 : null; + + if (usagePercent !== null) { + if (usagePercent > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WEB_CRITICAL) { + return 'error'; + } + if (usagePercent > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WEB_WARNING) { + return 'warning'; + } + } + return 'info'; +} diff --git a/src/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts new file mode 100644 index 0000000000000..fe7211c7237ef --- /dev/null +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -0,0 +1,104 @@ +import * as Sentry from '@sentry/react-native'; +import AppStateMonitor from '@libs/AppStateMonitor'; +import CONST from '@src/CONST'; +import formatMemoryBreadcrumb from './formatMemoryBreadcrumb'; +import getMemoryInfo from './getMemoryInfo'; +import getMemoryLogLevel from './getMemoryLogLevel'; + +let memoryTrackingIntervalID: ReturnType | undefined; +let memoryTrackingListenerCleanup: (() => void) | undefined; + +/** + * Send memory usage context to Sentry + * Called on app start, when app comes to foreground, and periodically every 2 minutes + */ +function sendMemoryContext() { + getMemoryInfo() + .then((memoryInfo) => { + const freeMemoryMB = memoryInfo.freeMemoryMB; + const usedMemoryMB = memoryInfo.usedMemoryMB; + + /** + * Memory Threshold Strategy (based on platform capabilities): + * + * WEB: + * - Has jsHeapSizeLimit API ✅ + * - Use percentage: (usedMemory / jsHeapSizeLimit) * 100 + * - Thresholds: >85% error, >70% warning + * + * ANDROID: + * - getUsedMemory() returns RSS (process memory including native + heap + libs) + * - getMaxMemory() returns heap limit (Java heap only) - incompatible for comparison + * - Temporary: Use % of device RAM (RSS / totalMemory * 100) + * - Thresholds: >85% error, >70% warning + * - Future: Use native onTrimMemory() callbacks for system memory pressure + * + * iOS: + * - NO API for jetsam limit ❌ + * - Use absolute MB values (conservative approach) + * - Thresholds: >600MB error, >300MB warning (supports iPhone 8+) + * - Note: iPhone 8/X jetsam ~300-350MB, iPhone 11+ ~400-600MB + */ + + const logLevel = getMemoryLogLevel(memoryInfo); + + const timestamp = Date.now(); + const timestampISO = new Date(timestamp).toISOString(); + + const breadcrumbMessage = formatMemoryBreadcrumb(memoryInfo, usedMemoryMB); + + Sentry.addBreadcrumb({ + category: 'system.memory', + message: breadcrumbMessage, + level: logLevel, + timestamp: timestamp / 1000, + data: { + ...memoryInfo, + freeMemoryMB, + usedMemoryMB, + }, + }); + + Sentry.setContext(CONST.TELEMETRY.CONTEXT_MEMORY, { + ...memoryInfo, + freeMemoryMB, + lowMemoryThreat: logLevel !== 'info', + lastUpdated: timestampISO, + }); + }) + .catch(() => { + // Silently ignore errors to avoid impacting app performance + // Memory tracking is non-critical and should not cause issues + }); +} + +function initializeMemoryTracking() { + if (memoryTrackingIntervalID) { + clearInterval(memoryTrackingIntervalID); + memoryTrackingIntervalID = undefined; + } + + if (memoryTrackingListenerCleanup) { + memoryTrackingListenerCleanup(); + memoryTrackingListenerCleanup = undefined; + } + + sendMemoryContext(); + memoryTrackingListenerCleanup = AppStateMonitor.addBecameActiveListener(sendMemoryContext); + memoryTrackingIntervalID = setInterval(sendMemoryContext, CONST.TELEMETRY.CONFIG.MEMORY_TRACKING_INTERVAL); +} + +function cleanupMemoryTracking() { + if (memoryTrackingIntervalID) { + clearInterval(memoryTrackingIntervalID); + memoryTrackingIntervalID = undefined; + } + + if (memoryTrackingListenerCleanup) { + memoryTrackingListenerCleanup(); + memoryTrackingListenerCleanup = undefined; + } +} + +export {cleanupMemoryTracking, initializeMemoryTracking}; +export default sendMemoryContext; diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts new file mode 100644 index 0000000000000..4e8183ff1fdf2 --- /dev/null +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -0,0 +1,457 @@ +import * as Sentry from '@sentry/react-native'; +import Onyx from 'react-native-onyx'; +import {getActivePolicies} from '@libs/PolicyUtils'; +import '@libs/telemetry/TelemetrySynchronizer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Session, TryNewDot} from '@src/types/onyx'; +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@sentry/react-native', () => ({ + setTag: jest.fn(), + setContext: jest.fn(), +})); + +jest.mock('@libs/PolicyUtils', () => ({ + getActivePolicies: jest.fn(), +})); + +jest.mock('@libs/telemetry/sendMemoryContext', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: jest.fn(), + initializeMemoryTracking: jest.fn(), + cleanupMemoryTracking: jest.fn(), +})); + +Onyx.init({keys: ONYXKEYS}); + +describe('TelemetrySynchronizer', () => { + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + describe('sendPoliciesContext', () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + + const mockActivePolicyID = '123'; + + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + [`${ONYXKEYS.COLLECTION.POLICY}456`]: createRandomPolicy(456), + }; + + const mockActivePolicies = [mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`], mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}456`]]; + + beforeEach(() => { + jest.clearAllMocks(); + (getActivePolicies as jest.Mock).mockReturnValue(mockActivePolicies); + }); + + it('should call Sentry.setTag and Sentry.setContext when all required data is available', async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID, + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, mockActivePolicyID); + expect(Sentry.setContext).toHaveBeenCalledWith(CONST.TELEMETRY.CONTEXT_POLICIES, { + activePolicyID: mockActivePolicyID, + activePolicies: expect.arrayContaining(['123', '456']), + }); + expect(getActivePolicies).toHaveBeenCalled(); + }); + + it('should not call Sentry methods when policies are missing', async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID, + [ONYXKEYS.COLLECTION.POLICY]: null, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + expect(Sentry.setTag).toHaveBeenCalledTimes(0); + expect(Sentry.setContext).toHaveBeenCalledTimes(0); + }); + + it('should not call Sentry methods when session.email is missing', async () => { + const sessionWithoutEmail: Session = { + accountID: 1, + } as Session; + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: sessionWithoutEmail, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID, + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + expect(Sentry.setTag).toHaveBeenCalledTimes(0); + expect(Sentry.setContext).toHaveBeenCalledTimes(0); + }); + + it('should not call Sentry methods when activePolicyID is missing', async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: null, + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + expect(Sentry.setTag).toHaveBeenCalledTimes(0); + expect(Sentry.setContext).toHaveBeenCalledTimes(0); + }); + + it('should correctly map active policies using getActivePolicies', async () => { + const customActivePolicies = [createRandomPolicy(999)]; + (getActivePolicies as jest.Mock).mockReturnValue(customActivePolicies); + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: '999', + [ONYXKEYS.COLLECTION.POLICY]: { + [`${ONYXKEYS.COLLECTION.POLICY}999`]: createRandomPolicy(999), + }, + }); + + await waitForBatchedUpdatesWithAct(); + + expect(getActivePolicies).toHaveBeenCalled(); + expect(Sentry.setContext).toHaveBeenCalledWith( + CONST.TELEMETRY.CONTEXT_POLICIES, + expect.objectContaining({ + activePolicies: ['999'], + }), + ); + }); + + it('should include both activePolicyID and activePolicies array in context', async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID, + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setContext).toHaveBeenCalledWith(CONST.TELEMETRY.CONTEXT_POLICIES, { + activePolicyID: mockActivePolicyID, + activePolicies: expect.arrayContaining([expect.any(String)]), + }); + }); + }); + + describe('sendTryNewDotCohortTag', () => { + it('should call Sentry.setTag when cohort exists', async () => { + const mockTryNewDot: TryNewDot = { + nudgeMigration: { + timestamp: new Date(), + cohort: 'cohort_A', + }, + }; + + await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_NUDGE_MIGRATION_COHORT, 'cohort_A'); + }); + + it('should not call Sentry.setTag when cohort is missing', async () => { + const mockTryNewDot: TryNewDot = { + nudgeMigration: { + timestamp: new Date(), + }, + }; + + await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + }); + + it('should not call Sentry.setTag when tryNewDot is null', async () => { + await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, null); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + }); + + it('should not call Sentry.setTag when nudgeMigration is missing', async () => { + const mockTryNewDot: TryNewDot = {}; + + await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + }); + }); + + describe('Onyx callbacks', () => { + describe('NVP_ACTIVE_POLICY_ID callback', () => { + it('should call sendPoliciesContext when value is set', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]); + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'policy123'); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, 'policy123'); + expect(Sentry.setContext).toHaveBeenCalled(); + }); + + it('should not call sendPoliciesContext when value is null', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]); + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123', + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, null); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + expect(Sentry.setContext).not.toHaveBeenCalled(); + }); + }); + + describe('SESSION callback', () => { + it('should call sendPoliciesContext when session with email is set', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]); + + await Onyx.multiSet({ + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123', + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.SESSION, mockSession); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, 'policy123'); + expect(Sentry.setContext).toHaveBeenCalled(); + }); + + it('should not call sendPoliciesContext when session.email is missing', async () => { + const sessionWithoutEmail: Session = { + accountID: 1, + } as Session; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + + await Onyx.multiSet({ + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123', + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.SESSION, sessionWithoutEmail); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + expect(Sentry.setContext).not.toHaveBeenCalled(); + }); + + it('should not call sendPoliciesContext when session is null', async () => { + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + + await Onyx.multiSet({ + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123', + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.SESSION, null); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + expect(Sentry.setContext).not.toHaveBeenCalled(); + }); + }); + + describe('COLLECTION.POLICY callback', () => { + it('should call sendPoliciesContext when policies collection is set', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]); + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123', + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.COLLECTION.POLICY, mockPolicies); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, 'policy123'); + expect(Sentry.setContext).toHaveBeenCalled(); + }); + + it('should not call sendPoliciesContext when policies is null', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123', + }); + await waitForBatchedUpdatesWithAct(); + + jest.clearAllMocks(); + + await Onyx.set(ONYXKEYS.COLLECTION.POLICY, null); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).not.toHaveBeenCalled(); + expect(Sentry.setContext).not.toHaveBeenCalled(); + }); + }); + + describe('NVP_TRY_NEW_DOT callback', () => { + it('should call sendTryNewDotCohortTag when value is set', async () => { + const mockTryNewDot: TryNewDot = { + nudgeMigration: { + timestamp: new Date(), + cohort: 'cohort_B', + }, + }; + + await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_NUDGE_MIGRATION_COHORT, 'cohort_B'); + }); + }); + }); + + describe('Integration tests', () => { + it('should call sendPoliciesContext with correct data when all required Onyx keys are set', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + const mockActivePolicyID = '789'; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}789`]: createRandomPolicy(789), + [`${ONYXKEYS.COLLECTION.POLICY}101`]: createRandomPolicy(101), + }; + const mockActivePolicies = [mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}789`]]; + (getActivePolicies as jest.Mock).mockReturnValue(mockActivePolicies); + + await Onyx.set(ONYXKEYS.SESSION, mockSession); + await waitForBatchedUpdatesWithAct(); + + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, mockActivePolicyID); + await waitForBatchedUpdatesWithAct(); + + await Onyx.set(ONYXKEYS.COLLECTION.POLICY, mockPolicies); + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, mockActivePolicyID); + expect(Sentry.setContext).toHaveBeenCalledWith(CONST.TELEMETRY.CONTEXT_POLICIES, { + activePolicyID: mockActivePolicyID, + activePolicies: ['789'], + }); + expect(getActivePolicies).toHaveBeenCalled(); + }); + + it('should verify Sentry methods are called with correct CONST values', async () => { + const mockSession: Session = { + email: 'test@example.com', + accountID: 1, + }; + const mockPolicies: Record = { + [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123), + }; + (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]); + + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: mockSession, + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: '123', + [ONYXKEYS.COLLECTION.POLICY]: mockPolicies, + }); + + await waitForBatchedUpdatesWithAct(); + + expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, '123'); + expect(Sentry.setContext).toHaveBeenCalledWith( + CONST.TELEMETRY.CONTEXT_POLICIES, + expect.objectContaining({ + activePolicyID: '123', + activePolicies: expect.any(Array) as unknown as string[], + }), + ); + }); + }); +});