From bfb74b428811f1ab57df43d4a0d96e5950de5994 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Tue, 3 Feb 2026 20:16:41 +0100 Subject: [PATCH 1/8] Add memory usage breadcrumbs and context to Sentry spans --- src/CONST/index.ts | 10 + .../BaseRecordTroubleshootDataToolMenu.tsx | 12 +- src/libs/telemetry/TelemetrySynchronizer.ts | 1 + .../telemetry/getMemoryInfo/index.native.ts | 53 ++ src/libs/telemetry/getMemoryInfo/index.ts | 96 ++++ src/libs/telemetry/getMemoryInfo/types.ts | 14 + src/libs/telemetry/sendMemoryContext.ts | 95 ++++ tests/unit/TelemetrySynchronizerTest.ts | 455 ++++++++++++++++++ 8 files changed, 731 insertions(+), 5 deletions(-) create mode 100644 src/libs/telemetry/getMemoryInfo/index.native.ts create mode 100644 src/libs/telemetry/getMemoryInfo/index.ts create mode 100644 src/libs/telemetry/getMemoryInfo/types.ts create mode 100644 src/libs/telemetry/sendMemoryContext.ts create mode 100644 tests/unit/TelemetrySynchronizerTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 766a5451176da..9c5628f910362 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1715,7 +1715,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_NUDGE_MIGRATION_COHORT: 'nudge_migration_cohort', TAG_AUTHENTICATION_FUNCTION: 'authentication_function', @@ -1772,6 +1777,11 @@ const CONST = { ATTRIBUTE_FINISHED_MANUALLY: 'finished_manually', CONFIG: { SKELETON_MIN_DURATION: 10_000, + MEMORY_THRESHOLD_CRITICAL_PERCENTAGE: 90, + MEMORY_TRACKING_INTERVAL: 2 * 60 * 1000, + // Memory Thresholds (in MB) + MEMORY_THRESHOLD_WARNING: 120, + MEMORY_THRESHOLD_CRITICAL: 50, }, }, PRIORITY_MODE: { diff --git a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx index ca4b46d94a9ac..1c72c1939dd44 100644 --- a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx +++ b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx @@ -14,6 +14,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 +50,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 +60,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 +88,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 a8de074dd5d24..5780d41e4e2e2 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 './sendMemoryContext'; /** * Connect to Onyx to retrieve information about the user's active policies. diff --git a/src/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts new file mode 100644 index 0000000000000..0edb3aff17721 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -0,0 +1,53 @@ +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); + + 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; \ No newline at end of file diff --git a/src/libs/telemetry/getMemoryInfo/index.ts b/src/libs/telemetry/getMemoryInfo/index.ts new file mode 100644 index 0000000000000..5be7a6b865b87 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/index.ts @@ -0,0 +1,96 @@ +import {Platform} from 'react-native'; +import type {MemoryInfo} from './types'; + +const BYTES_PER_MB = 1024 * 1024; +const BYTES_PER_GB = BYTES_PER_MB * 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, + }; + } + + if (window.navigator) { + try { + const deviceMemoryGB = (window.navigator as Navigator & {deviceMemory?: number}).deviceMemory; + if (deviceMemoryGB && deviceMemoryGB > 0) { + totalMemoryBytes = deviceMemoryGB * BYTES_PER_GB; + } + } catch (error) { + // Gracefully degrade - deviceMemory requires HTTPS and is Chromium-only + } + } + + // performance.memory is deprecated but still works in Chromium, not enumerable so we use direct access + if (window.performance) { + try { + const perfMemory = ( + window.performance as Performance & { + memory?: { + usedJSHeapSize?: number; + totalJSHeapSize?: number; + jsHeapSizeLimit?: number; + }; + } + ).memory; + + if (perfMemory) { + if (perfMemory.usedJSHeapSize && perfMemory.usedJSHeapSize > 0) { + usedMemoryBytes = perfMemory.usedJSHeapSize; + } + + if (perfMemory.jsHeapSizeLimit && perfMemory.jsHeapSizeLimit > 0) { + maxMemoryBytes = perfMemory.jsHeapSizeLimit; + } + + if (!totalMemoryBytes && perfMemory.totalJSHeapSize && perfMemory.totalJSHeapSize > 0) { + totalMemoryBytes = perfMemory.totalJSHeapSize; + } + } + } 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; \ No newline at end of file diff --git a/src/libs/telemetry/getMemoryInfo/types.ts b/src/libs/telemetry/getMemoryInfo/types.ts new file mode 100644 index 0000000000000..9717200d697d6 --- /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 +export type {MemoryInfo}; \ No newline at end of file diff --git a/src/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts new file mode 100644 index 0000000000000..9884ce5ea3a48 --- /dev/null +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/react-native'; +import AppStateMonitor from '@libs/AppStateMonitor'; +import CONST from '@src/CONST'; +import getMemoryInfo from './getMemoryInfo'; + +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; + let logLevel: Sentry.SeverityLevel = 'info'; + /** + * Log Level Thresholds (Based on OS resource management): + * * 1. < 50MB (Error): Critical memory exhaustion. The OS's memory killer + * (Jetsam on iOS / Low Memory Killer on Android) is likely to terminate the process immediately. + * * 2. < 120MB (Warning): System starts sending 'didReceiveMemoryWarning' signals. + * The app is unstable and any sudden allocation spike will lead to a crash. + * * 3. > 120MB (Info): Safe operational zone for most modern mobile devices. + */ + if (freeMemoryMB !== null) { + if (freeMemoryMB < CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_CRITICAL) { + logLevel = 'error'; + } else if (freeMemoryMB < CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WARNING) { + logLevel = 'warning'; + } + } else if (memoryInfo.usagePercentage && memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_CRITICAL_PERCENTAGE) { + logLevel = 'error'; + } + + const timestamp = Date.now(); + const timestampISO = new Date(timestamp).toISOString(); + + Sentry.addBreadcrumb({ + category: 'system.memory', + message: `RAM Check: ${usedMemoryMB ?? '?'}MB used / ${freeMemoryMB ?? '?'}MB free`, + level: logLevel, + timestamp: timestamp / 1000, + data: { + ...memoryInfo, + freeMemoryMB, + usedMemoryMB, + }, + }); + + Sentry.setContext(CONST.TELEMETRY.CONTEXT_MEMORY, { + ...memoryInfo, + freeMemoryMB, + lowMemoryThreat: logLevel !== 'info', + lastUpdated: timestampISO, + }); + }) + .catch((error) => { + // Ignore error + }); +} + +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); +} + +initializeMemoryTracking(); + +function cleanupMemoryTracking() { + if (memoryTrackingIntervalID) { + clearInterval(memoryTrackingIntervalID); + memoryTrackingIntervalID = undefined; + } + + if (memoryTrackingListenerCleanup) { + memoryTrackingListenerCleanup(); + memoryTrackingListenerCleanup = undefined; + } +} + +export {cleanupMemoryTracking, initializeMemoryTracking}; +export default sendMemoryContext; \ No newline at end of file diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts new file mode 100644 index 0000000000000..f31cb55f35d25 --- /dev/null +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -0,0 +1,455 @@ +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(), +})); + +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[], + }), + ); + }); + }); +}); \ No newline at end of file From f52a56657f05afdb0ff0bb6fcd0f5e665dfde3b0 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Tue, 3 Feb 2026 20:27:05 +0100 Subject: [PATCH 2/8] Lint and prettier fixes --- .../BaseRecordTroubleshootDataToolMenu.tsx | 1 - src/libs/telemetry/getMemoryInfo/index.native.ts | 2 +- src/libs/telemetry/getMemoryInfo/index.ts | 2 +- src/libs/telemetry/getMemoryInfo/types.ts | 2 +- src/libs/telemetry/sendMemoryContext.ts | 5 +++-- tests/unit/TelemetrySynchronizerTest.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx index 1c72c1939dd44..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'; diff --git a/src/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts index 0edb3aff17721..cdaab382a7a07 100644 --- a/src/libs/telemetry/getMemoryInfo/index.native.ts +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -50,4 +50,4 @@ const getMemoryInfo = async (): Promise => { } }; -export default getMemoryInfo; \ No newline at end of file +export default getMemoryInfo; diff --git a/src/libs/telemetry/getMemoryInfo/index.ts b/src/libs/telemetry/getMemoryInfo/index.ts index 5be7a6b865b87..78dc68517ef90 100644 --- a/src/libs/telemetry/getMemoryInfo/index.ts +++ b/src/libs/telemetry/getMemoryInfo/index.ts @@ -93,4 +93,4 @@ const getMemoryInfo = async (): Promise => { } }; -export default getMemoryInfo; \ No newline at end of file +export default getMemoryInfo; diff --git a/src/libs/telemetry/getMemoryInfo/types.ts b/src/libs/telemetry/getMemoryInfo/types.ts index 9717200d697d6..0f6bfe49b357d 100644 --- a/src/libs/telemetry/getMemoryInfo/types.ts +++ b/src/libs/telemetry/getMemoryInfo/types.ts @@ -11,4 +11,4 @@ type MemoryInfo = { }; // eslint-disable-next-line import/prefer-default-export -export type {MemoryInfo}; \ No newline at end of file +export type {MemoryInfo}; diff --git a/src/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts index 9884ce5ea3a48..4aaae1d25b32e 100644 --- a/src/libs/telemetry/sendMemoryContext.ts +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -56,7 +56,8 @@ function sendMemoryContext() { lastUpdated: timestampISO, }); }) - .catch((error) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .catch((_error) => { // Ignore error }); } @@ -92,4 +93,4 @@ function cleanupMemoryTracking() { } export {cleanupMemoryTracking, initializeMemoryTracking}; -export default sendMemoryContext; \ No newline at end of file +export default sendMemoryContext; diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts index f31cb55f35d25..22e0744eb8ae2 100644 --- a/tests/unit/TelemetrySynchronizerTest.ts +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -452,4 +452,4 @@ describe('TelemetrySynchronizer', () => { ); }); }); -}); \ No newline at end of file +}); From 1063fe6f57bba0c89f6dc9419a798ad40822e284 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Wed, 4 Feb 2026 15:55:19 +0100 Subject: [PATCH 3/8] Fix for mobile memory usage --- src/CONST/index.ts | 18 +++-- src/Expensify.tsx | 8 +++ src/libs/telemetry/TelemetrySynchronizer.ts | 4 +- .../telemetry/getMemoryInfo/index.native.ts | 23 ++++-- src/libs/telemetry/getMemoryInfo/index.ts | 24 ++----- src/libs/telemetry/getMemoryInfo/types.ts | 2 +- src/libs/telemetry/sendMemoryContext.ts | 71 ++++++++++++++----- tests/unit/TelemetrySynchronizerTest.ts | 2 + 8 files changed, 104 insertions(+), 48 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9c5628f910362..58552098b3cd6 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1777,11 +1777,19 @@ const CONST = { ATTRIBUTE_FINISHED_MANUALLY: 'finished_manually', CONFIG: { SKELETON_MIN_DURATION: 10_000, - MEMORY_THRESHOLD_CRITICAL_PERCENTAGE: 90, MEMORY_TRACKING_INTERVAL: 2 * 60 * 1000, - // Memory Thresholds (in MB) - MEMORY_THRESHOLD_WARNING: 120, - MEMORY_THRESHOLD_CRITICAL: 50, + + // Web Memory Thresholds (% of jsHeapSizeLimit) + MEMORY_THRESHOLD_WEB_CRITICAL: 90, // > 90% heap usage + MEMORY_THRESHOLD_WEB_WARNING: 75, // > 75% heap usage + + // Android Memory Thresholds (% of VM heap limit from getMaxMemory) + MEMORY_THRESHOLD_ANDROID_CRITICAL: 85, // > 85% of heap limit + MEMORY_THRESHOLD_ANDROID_WARNING: 70, // > 70% of heap limit + + // iOS Memory Thresholds (absolute MB - no heap limit API available) + MEMORY_THRESHOLD_IOS_CRITICAL_MB: 800, // > 800MB used (approaching jetsam) + MEMORY_THRESHOLD_IOS_WARNING_MB: 500, // > 500MB used (monitor closely) }, }, PRIORITY_MODE: { @@ -3011,7 +3019,7 @@ const CONST = { APPROVE: 'approve', TRACK: 'track', }, - AMOUNT_MAX_LENGTH: 10, + AMOUNT_MAX_LENGTH: 8, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', diff --git a/src/Expensify.tsx b/src/Expensify.tsx index dade3083efc37..ccfa19bee1010 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -50,6 +50,7 @@ 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 +131,13 @@ function Expensify() { useDebugShortcut(); usePriorityMode(); + useEffect(() => { + initializeMemoryTrackingTelemetry(); + return () => { + cleanupMemoryTrackingTelemetry(); + }; + }, []); + const bootsplashSpan = useRef(null); const [initialUrl, setInitialUrl] = useState(null); diff --git a/src/libs/telemetry/TelemetrySynchronizer.ts b/src/libs/telemetry/TelemetrySynchronizer.ts index 5780d41e4e2e2..09a74c9f36af3 100644 --- a/src/libs/telemetry/TelemetrySynchronizer.ts +++ b/src/libs/telemetry/TelemetrySynchronizer.ts @@ -10,7 +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 './sendMemoryContext'; +import {cleanupMemoryTracking, initializeMemoryTracking} from './sendMemoryContext'; /** * Connect to Onyx to retrieve information about the user's active policies. @@ -78,3 +78,5 @@ function sendTryNewDotCohortTag() { } Sentry.setTag(CONST.TELEMETRY.TAG_NUDGE_MIGRATION_COHORT, cohort); } + +export {initializeMemoryTracking as initializeMemoryTrackingTelemetry, cleanupMemoryTracking as cleanupMemoryTrackingTelemetry}; diff --git a/src/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts index cdaab382a7a07..2d325f4cb689c 100644 --- a/src/libs/telemetry/getMemoryInfo/index.native.ts +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -24,15 +24,30 @@ const getMemoryInfo = async (): Promise => { const maxMemoryBytesRaw = maxMemory.status === 'fulfilled' ? maxMemory.value : null; const maxMemoryBytes = normalizeMemoryValue(maxMemoryBytesRaw); + // Calculate usage percentage based on the appropriate limit: + // - Android: Use maxMemoryBytes (VM heap limit from getMaxMemory) + // - iOS: null (no reliable API for jetsam limit - use absolute thresholds instead) + let usagePercentage: number | null = null; + if (Platform.OS === 'android' && usedMemoryBytes !== null && maxMemoryBytes !== null && maxMemoryBytes > 0) { + usagePercentage = parseFloat(((usedMemoryBytes / maxMemoryBytes) * 100).toFixed(2)); + } + + // Free memory calculations are based on device total RAM, not app limits + // These are informational only and should NOT be used for memory pressure detection + 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: 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, + usagePercentage, + freeMemoryBytes, + freeMemoryMB, + freeMemoryPercentage, platform: Platform.OS, }; } catch (error) { diff --git a/src/libs/telemetry/getMemoryInfo/index.ts b/src/libs/telemetry/getMemoryInfo/index.ts index 78dc68517ef90..248be80d4d4c7 100644 --- a/src/libs/telemetry/getMemoryInfo/index.ts +++ b/src/libs/telemetry/getMemoryInfo/index.ts @@ -2,7 +2,6 @@ import {Platform} from 'react-native'; import type {MemoryInfo} from './types'; const BYTES_PER_MB = 1024 * 1024; -const BYTES_PER_GB = BYTES_PER_MB * 1024; // Only works in Chrome/Edge (Chromium browsers) - navigator.deviceMemory and performance.memory are not available in Firefox/Safari const getMemoryInfo = async (): Promise => { @@ -25,18 +24,7 @@ const getMemoryInfo = async (): Promise => { }; } - if (window.navigator) { - try { - const deviceMemoryGB = (window.navigator as Navigator & {deviceMemory?: number}).deviceMemory; - if (deviceMemoryGB && deviceMemoryGB > 0) { - totalMemoryBytes = deviceMemoryGB * BYTES_PER_GB; - } - } catch (error) { - // Gracefully degrade - deviceMemory requires HTTPS and is Chromium-only - } - } - - // performance.memory is deprecated but still works in Chromium, not enumerable so we use direct access + // We prioritize this API as the source of truth for web memory measurements if (window.performance) { try { const perfMemory = ( @@ -50,17 +38,17 @@ const getMemoryInfo = async (): Promise => { ).memory; if (perfMemory) { - if (perfMemory.usedJSHeapSize && perfMemory.usedJSHeapSize > 0) { - usedMemoryBytes = perfMemory.usedJSHeapSize; - } - if (perfMemory.jsHeapSizeLimit && perfMemory.jsHeapSizeLimit > 0) { maxMemoryBytes = perfMemory.jsHeapSizeLimit; } - if (!totalMemoryBytes && perfMemory.totalJSHeapSize && perfMemory.totalJSHeapSize > 0) { + 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 diff --git a/src/libs/telemetry/getMemoryInfo/types.ts b/src/libs/telemetry/getMemoryInfo/types.ts index 0f6bfe49b357d..96f439de7e9e2 100644 --- a/src/libs/telemetry/getMemoryInfo/types.ts +++ b/src/libs/telemetry/getMemoryInfo/types.ts @@ -10,5 +10,5 @@ type MemoryInfo = { platform: string; }; -// eslint-disable-next-line import/prefer-default-export +// 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/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts index 4aaae1d25b32e..5e84e20943483 100644 --- a/src/libs/telemetry/sendMemoryContext.ts +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -16,30 +16,65 @@ function sendMemoryContext() { const freeMemoryMB = memoryInfo.freeMemoryMB; const usedMemoryMB = memoryInfo.usedMemoryMB; let logLevel: Sentry.SeverityLevel = 'info'; + /** - * Log Level Thresholds (Based on OS resource management): - * * 1. < 50MB (Error): Critical memory exhaustion. The OS's memory killer - * (Jetsam on iOS / Low Memory Killer on Android) is likely to terminate the process immediately. - * * 2. < 120MB (Warning): System starts sending 'didReceiveMemoryWarning' signals. - * The app is unstable and any sudden allocation spike will lead to a crash. - * * 3. > 120MB (Info): Safe operational zone for most modern mobile devices. + * Memory Threshold Strategy (based on platform capabilities): + * + * WEB: + * - Has jsHeapSizeLimit API ✅ + * - Use percentage: (usedMemory / jsHeapSizeLimit) * 100 + * - Thresholds: >90% error, >75% warning + * + * ANDROID: + * - Has getMaxMemory() for VM heap limit ✅ + * - Use percentage: (usedMemory / maxMemory) * 100 + * - Thresholds: >85% error, >70% warning + * + * iOS: + * - NO API for jetsam limit ❌ + * - Use absolute MB values (conservative approach) + * - Thresholds: >800MB error, >500MB warning + * - Note: Actual jetsam limit varies by device (typically 20-30% of device RAM) */ - if (freeMemoryMB !== null) { - if (freeMemoryMB < CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_CRITICAL) { - logLevel = 'error'; - } else if (freeMemoryMB < CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WARNING) { - logLevel = 'warning'; + if (memoryInfo.platform === 'web') { + // Web: Use percentage of JS heap limit + 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) { + logLevel = 'error'; + } else if (usagePercent > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WEB_WARNING) { + logLevel = 'warning'; + } + } + } else if (memoryInfo.platform === 'android') { + // Android: Use percentage of VM heap limit (from getMaxMemory) + if (memoryInfo.usagePercentage !== null) { + if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_CRITICAL) { + logLevel = 'error'; + } else if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_WARNING) { + logLevel = 'warning'; + } + } + } else if (memoryInfo.platform === 'ios') { + // iOS: Use absolute MB values (no reliable heap limit API) + if (usedMemoryMB !== null) { + if (usedMemoryMB > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_IOS_CRITICAL_MB) { + logLevel = 'error'; + } else if (usedMemoryMB > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_IOS_WARNING_MB) { + logLevel = 'warning'; + } } - } else if (memoryInfo.usagePercentage && memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_CRITICAL_PERCENTAGE) { - logLevel = 'error'; } const timestamp = Date.now(); const timestampISO = new Date(timestamp).toISOString(); + const maxMB = memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : null; + Sentry.addBreadcrumb({ category: 'system.memory', - message: `RAM Check: ${usedMemoryMB ?? '?'}MB used / ${freeMemoryMB ?? '?'}MB free`, + message: `RAM Check: ${usedMemoryMB ?? '?'}MB / ${maxMB ?? '?'}MB limit`, level: logLevel, timestamp: timestamp / 1000, data: { @@ -56,9 +91,9 @@ function sendMemoryContext() { lastUpdated: timestampISO, }); }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .catch((_error) => { - // Ignore error + .catch(() => { + // Silently ignore errors to avoid impacting app performance + // Memory tracking is non-critical and should not cause issues }); } @@ -78,8 +113,6 @@ function initializeMemoryTracking() { memoryTrackingIntervalID = setInterval(sendMemoryContext, CONST.TELEMETRY.CONFIG.MEMORY_TRACKING_INTERVAL); } -initializeMemoryTracking(); - function cleanupMemoryTracking() { if (memoryTrackingIntervalID) { clearInterval(memoryTrackingIntervalID); diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts index 22e0744eb8ae2..4e8183ff1fdf2 100644 --- a/tests/unit/TelemetrySynchronizerTest.ts +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -21,6 +21,8 @@ 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}); From d85297fdc3d7bd591295b20ef70ced3082ade331 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Wed, 4 Feb 2026 20:00:17 +0100 Subject: [PATCH 4/8] Fix for iOS --- src/CONST/index.ts | 18 +++++++++--------- src/Expensify.tsx | 2 -- src/libs/telemetry/sendMemoryContext.ts | 13 +++++++------ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1556cea7df367..7a67028be15c2 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1783,18 +1783,18 @@ const CONST = { CONFIG: { SKELETON_MIN_DURATION: 10_000, MEMORY_TRACKING_INTERVAL: 2 * 60 * 1000, - + // Web Memory Thresholds (% of jsHeapSizeLimit) - MEMORY_THRESHOLD_WEB_CRITICAL: 90, // > 90% heap usage - MEMORY_THRESHOLD_WEB_WARNING: 75, // > 75% heap usage - + MEMORY_THRESHOLD_WEB_CRITICAL: 90, + MEMORY_THRESHOLD_WEB_WARNING: 75, + // Android Memory Thresholds (% of VM heap limit from getMaxMemory) - MEMORY_THRESHOLD_ANDROID_CRITICAL: 85, // > 85% of heap limit - MEMORY_THRESHOLD_ANDROID_WARNING: 70, // > 70% of heap limit - + MEMORY_THRESHOLD_ANDROID_CRITICAL: 85, + MEMORY_THRESHOLD_ANDROID_WARNING: 70, + // iOS Memory Thresholds (absolute MB - no heap limit API available) - MEMORY_THRESHOLD_IOS_CRITICAL_MB: 800, // > 800MB used (approaching jetsam) - MEMORY_THRESHOLD_IOS_WARNING_MB: 500, // > 500MB used (monitor closely) + MEMORY_THRESHOLD_IOS_CRITICAL_MB: 800, + MEMORY_THRESHOLD_IOS_WARNING_MB: 500, }, }, PRIORITY_MODE: { diff --git a/src/Expensify.tsx b/src/Expensify.tsx index ccfa19bee1010..6a0fb6e8a0e95 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -48,8 +48,6 @@ 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'; diff --git a/src/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts index 5e84e20943483..9d4c993583b7a 100644 --- a/src/libs/telemetry/sendMemoryContext.ts +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -19,17 +19,17 @@ function sendMemoryContext() { /** * Memory Threshold Strategy (based on platform capabilities): - * + * * WEB: * - Has jsHeapSizeLimit API ✅ * - Use percentage: (usedMemory / jsHeapSizeLimit) * 100 * - Thresholds: >90% error, >75% warning - * + * * ANDROID: * - Has getMaxMemory() for VM heap limit ✅ * - Use percentage: (usedMemory / maxMemory) * 100 * - Thresholds: >85% error, >70% warning - * + * * iOS: * - NO API for jetsam limit ❌ * - Use absolute MB values (conservative approach) @@ -70,11 +70,12 @@ function sendMemoryContext() { const timestamp = Date.now(); const timestampISO = new Date(timestamp).toISOString(); - const maxMB = memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : null; - Sentry.addBreadcrumb({ category: 'system.memory', - message: `RAM Check: ${usedMemoryMB ?? '?'}MB / ${maxMB ?? '?'}MB limit`, + message: + memoryInfo.platform === 'ios' + ? `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)` + : `RAM Check: ${usedMemoryMB ?? '?'}MB / ${memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'}MB limit`, level: logLevel, timestamp: timestamp / 1000, data: { From 5d10e37d7d067d6abfd4c567a700079b2f8da52d Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Wed, 4 Feb 2026 20:02:11 +0100 Subject: [PATCH 5/8] PR fixes --- src/CONST/index.ts | 2 +- src/libs/telemetry/getMemoryInfo/index.native.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7a67028be15c2..4e13edad26dfa 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3025,7 +3025,7 @@ const CONST = { APPROVE: 'approve', TRACK: 'track', }, - AMOUNT_MAX_LENGTH: 8, + AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', diff --git a/src/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts index 2d325f4cb689c..751e53da8f578 100644 --- a/src/libs/telemetry/getMemoryInfo/index.native.ts +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -24,16 +24,11 @@ const getMemoryInfo = async (): Promise => { const maxMemoryBytesRaw = maxMemory.status === 'fulfilled' ? maxMemory.value : null; const maxMemoryBytes = normalizeMemoryValue(maxMemoryBytesRaw); - // Calculate usage percentage based on the appropriate limit: - // - Android: Use maxMemoryBytes (VM heap limit from getMaxMemory) - // - iOS: null (no reliable API for jetsam limit - use absolute thresholds instead) let usagePercentage: number | null = null; if (Platform.OS === 'android' && usedMemoryBytes !== null && maxMemoryBytes !== null && maxMemoryBytes > 0) { usagePercentage = parseFloat(((usedMemoryBytes / maxMemoryBytes) * 100).toFixed(2)); } - // Free memory calculations are based on device total RAM, not app limits - // These are informational only and should NOT be used for memory pressure detection const freeMemoryBytes = totalMemoryBytes !== null && usedMemoryBytes !== null ? totalMemoryBytes - usedMemoryBytes : null; const freeMemoryMB = freeMemoryBytes !== null ? Math.round(freeMemoryBytes / BYTES_PER_MB) : null; const freeMemoryPercentage = From 975f8e64d332e605bf1c4a13a6b3430eadec1d45 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Thu, 5 Feb 2026 17:29:04 +0100 Subject: [PATCH 6/8] Android usage data fixes --- src/CONST/index.ts | 14 ++++++------ .../telemetry/getMemoryInfo/index.native.ts | 8 +++++-- src/libs/telemetry/sendMemoryContext.ts | 22 +++++++++++-------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4e13edad26dfa..f46317ce8abac 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1785,16 +1785,16 @@ const CONST = { MEMORY_TRACKING_INTERVAL: 2 * 60 * 1000, // Web Memory Thresholds (% of jsHeapSizeLimit) - MEMORY_THRESHOLD_WEB_CRITICAL: 90, - MEMORY_THRESHOLD_WEB_WARNING: 75, + MEMORY_THRESHOLD_WEB_CRITICAL: 85, + MEMORY_THRESHOLD_WEB_WARNING: 70, - // Android Memory Thresholds (% of VM heap limit from getMaxMemory) - MEMORY_THRESHOLD_ANDROID_CRITICAL: 85, - MEMORY_THRESHOLD_ANDROID_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: 800, - MEMORY_THRESHOLD_IOS_WARNING_MB: 500, + 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/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts index 751e53da8f578..18d932e655ea5 100644 --- a/src/libs/telemetry/getMemoryInfo/index.native.ts +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -24,9 +24,13 @@ const getMemoryInfo = async (): Promise => { 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 && maxMemoryBytes !== null && maxMemoryBytes > 0) { - usagePercentage = parseFloat(((usedMemoryBytes / maxMemoryBytes) * 100).toFixed(2)); + 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; diff --git a/src/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts index 9d4c993583b7a..ffea4b0eb8161 100644 --- a/src/libs/telemetry/sendMemoryContext.ts +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -23,18 +23,20 @@ function sendMemoryContext() { * WEB: * - Has jsHeapSizeLimit API ✅ * - Use percentage: (usedMemory / jsHeapSizeLimit) * 100 - * - Thresholds: >90% error, >75% warning + * - Thresholds: >85% error, >70% warning * * ANDROID: - * - Has getMaxMemory() for VM heap limit ✅ - * - Use percentage: (usedMemory / maxMemory) * 100 + * - 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: >800MB error, >500MB warning - * - Note: Actual jetsam limit varies by device (typically 20-30% of device RAM) + * - Thresholds: >600MB error, >300MB warning (supports iPhone 8+) + * - Note: iPhone 8/X jetsam ~300-350MB, iPhone 11+ ~400-600MB */ if (memoryInfo.platform === 'web') { // Web: Use percentage of JS heap limit @@ -48,7 +50,7 @@ function sendMemoryContext() { } } } else if (memoryInfo.platform === 'android') { - // Android: Use percentage of VM heap limit (from getMaxMemory) + // Android: Use percentage of device RAM (RSS / totalMemory) if (memoryInfo.usagePercentage !== null) { if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_CRITICAL) { logLevel = 'error'; @@ -73,9 +75,11 @@ function sendMemoryContext() { Sentry.addBreadcrumb({ category: 'system.memory', message: - memoryInfo.platform === 'ios' - ? `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)` - : `RAM Check: ${usedMemoryMB ?? '?'}MB / ${memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'}MB limit`, + memoryInfo.platform === 'web' + ? `RAM Check: ${usedMemoryMB ?? '?'}MB / ${memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'}MB limit` + : memoryInfo.platform === 'android' + ? `RAM Check: ${usedMemoryMB ?? '?'}MB used (${memoryInfo.usagePercentage?.toFixed(0) ?? '?'}% device RAM)` + : `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)`, level: logLevel, timestamp: timestamp / 1000, data: { From d3c8819ce1e7146a5a1b733e3a2d934ea55a1531 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Thu, 5 Feb 2026 17:36:12 +0100 Subject: [PATCH 7/8] Prettier fix --- src/CONST/index.ts | 8 ++++---- src/libs/telemetry/sendMemoryContext.ts | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f46317ce8abac..1b93b9b65b2ac 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1789,12 +1789,12 @@ const CONST = { 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 + 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 + 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/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts index ffea4b0eb8161..8bd22c325f682 100644 --- a/src/libs/telemetry/sendMemoryContext.ts +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -72,14 +72,21 @@ function sendMemoryContext() { const timestamp = Date.now(); const timestampISO = new Date(timestamp).toISOString(); + // Build breadcrumb message based on platform + let breadcrumbMessage: string; + if (memoryInfo.platform === 'web') { + const maxMB = memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'; + breadcrumbMessage = `RAM Check: ${usedMemoryMB ?? '?'}MB / ${maxMB}MB limit`; + } else if (memoryInfo.platform === 'android') { + const usagePercent = memoryInfo.usagePercentage?.toFixed(0) ?? '?'; + breadcrumbMessage = `RAM Check: ${usedMemoryMB ?? '?'}MB used (${usagePercent}% device RAM)`; + } else { + breadcrumbMessage = `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)`; + } + Sentry.addBreadcrumb({ category: 'system.memory', - message: - memoryInfo.platform === 'web' - ? `RAM Check: ${usedMemoryMB ?? '?'}MB / ${memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'}MB limit` - : memoryInfo.platform === 'android' - ? `RAM Check: ${usedMemoryMB ?? '?'}MB used (${memoryInfo.usagePercentage?.toFixed(0) ?? '?'}% device RAM)` - : `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)`, + message: breadcrumbMessage, level: logLevel, timestamp: timestamp / 1000, data: { From 8db254a347691d6e960d886ee049d919df0ea628 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 6 Feb 2026 18:25:02 +0100 Subject: [PATCH 8/8] Separate getMemoryLogLevel and formatMemoryBreadcrumb methods --- .../formatMemoryBreadcrumb/index.android.ts | 12 +++++ .../formatMemoryBreadcrumb/index.ios.ts | 11 +++++ .../telemetry/formatMemoryBreadcrumb/index.ts | 12 +++++ .../getMemoryLogLevel/index.android.ts | 20 +++++++++ .../telemetry/getMemoryLogLevel/index.ios.ts | 20 +++++++++ src/libs/telemetry/getMemoryLogLevel/index.ts | 22 +++++++++ src/libs/telemetry/sendMemoryContext.ts | 45 ++----------------- 7 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 src/libs/telemetry/formatMemoryBreadcrumb/index.android.ts create mode 100644 src/libs/telemetry/formatMemoryBreadcrumb/index.ios.ts create mode 100644 src/libs/telemetry/formatMemoryBreadcrumb/index.ts create mode 100644 src/libs/telemetry/getMemoryLogLevel/index.android.ts create mode 100644 src/libs/telemetry/getMemoryLogLevel/index.ios.ts create mode 100644 src/libs/telemetry/getMemoryLogLevel/index.ts 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/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 index 8bd22c325f682..fe7211c7237ef 100644 --- a/src/libs/telemetry/sendMemoryContext.ts +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -1,7 +1,9 @@ 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; @@ -15,7 +17,6 @@ function sendMemoryContext() { .then((memoryInfo) => { const freeMemoryMB = memoryInfo.freeMemoryMB; const usedMemoryMB = memoryInfo.usedMemoryMB; - let logLevel: Sentry.SeverityLevel = 'info'; /** * Memory Threshold Strategy (based on platform capabilities): @@ -38,51 +39,13 @@ function sendMemoryContext() { * - Thresholds: >600MB error, >300MB warning (supports iPhone 8+) * - Note: iPhone 8/X jetsam ~300-350MB, iPhone 11+ ~400-600MB */ - if (memoryInfo.platform === 'web') { - // Web: Use percentage of JS heap limit - 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) { - logLevel = 'error'; - } else if (usagePercent > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WEB_WARNING) { - logLevel = 'warning'; - } - } - } else if (memoryInfo.platform === 'android') { - // Android: Use percentage of device RAM (RSS / totalMemory) - if (memoryInfo.usagePercentage !== null) { - if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_CRITICAL) { - logLevel = 'error'; - } else if (memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_ANDROID_WARNING) { - logLevel = 'warning'; - } - } - } else if (memoryInfo.platform === 'ios') { - // iOS: Use absolute MB values (no reliable heap limit API) - if (usedMemoryMB !== null) { - if (usedMemoryMB > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_IOS_CRITICAL_MB) { - logLevel = 'error'; - } else if (usedMemoryMB > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_IOS_WARNING_MB) { - logLevel = 'warning'; - } - } - } + const logLevel = getMemoryLogLevel(memoryInfo); const timestamp = Date.now(); const timestampISO = new Date(timestamp).toISOString(); - // Build breadcrumb message based on platform - let breadcrumbMessage: string; - if (memoryInfo.platform === 'web') { - const maxMB = memoryInfo.maxMemoryBytes ? Math.round(memoryInfo.maxMemoryBytes / (1024 * 1024)) : '?'; - breadcrumbMessage = `RAM Check: ${usedMemoryMB ?? '?'}MB / ${maxMB}MB limit`; - } else if (memoryInfo.platform === 'android') { - const usagePercent = memoryInfo.usagePercentage?.toFixed(0) ?? '?'; - breadcrumbMessage = `RAM Check: ${usedMemoryMB ?? '?'}MB used (${usagePercent}% device RAM)`; - } else { - breadcrumbMessage = `RAM Check: ${usedMemoryMB ?? '?'}MB used (iOS - no limit API)`; - } + const breadcrumbMessage = formatMemoryBreadcrumb(memoryInfo, usedMemoryMB); Sentry.addBreadcrumb({ category: 'system.memory',