Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down
10 changes: 8 additions & 2 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -130,6 +129,13 @@ function Expensify() {
useDebugShortcut();
usePriorityMode();

useEffect(() => {
initializeMemoryTrackingTelemetry();
return () => {
cleanupMemoryTrackingTelemetry();
};
}, []);

const bootsplashSpan = useRef<Sentry.Span>(null);

const [initialUrl, setInitialUrl] = useState<Route | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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';
}
Expand All @@ -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!
Expand All @@ -86,13 +87,13 @@ function BaseRecordTroubleshootDataToolMenu({
const [profileTracePath, setProfileTracePath] = useState<string>();

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,
});
Expand Down
3 changes: 3 additions & 0 deletions src/libs/telemetry/TelemetrySynchronizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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};
12 changes: 12 additions & 0 deletions src/libs/telemetry/formatMemoryBreadcrumb/index.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {MemoryInfo} from '../getMemoryInfo/types';

Check warning on line 1 in src/libs/telemetry/formatMemoryBreadcrumb/index.android.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../getMemoryInfo/types'. Use '@libs/telemetry/getMemoryInfo/types' instead

/**
* 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)`;
}
11 changes: 11 additions & 0 deletions src/libs/telemetry/formatMemoryBreadcrumb/index.ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type {MemoryInfo} from '../getMemoryInfo/types';

Check warning on line 1 in src/libs/telemetry/formatMemoryBreadcrumb/index.ios.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../getMemoryInfo/types'. Use '@libs/telemetry/getMemoryInfo/types' instead

/**
* 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)`;
}
12 changes: 12 additions & 0 deletions src/libs/telemetry/formatMemoryBreadcrumb/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {MemoryInfo} from '../getMemoryInfo/types';

Check warning on line 1 in src/libs/telemetry/formatMemoryBreadcrumb/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../getMemoryInfo/types'. Use '@libs/telemetry/getMemoryInfo/types' instead

/**
* 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`;
}
67 changes: 67 additions & 0 deletions src/libs/telemetry/getMemoryInfo/index.native.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryInfo> => {
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;
84 changes: 84 additions & 0 deletions src/libs/telemetry/getMemoryInfo/index.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryInfo> => {
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;
14 changes: 14 additions & 0 deletions src/libs/telemetry/getMemoryInfo/types.ts
Original file line number Diff line number Diff line change
@@ -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};
20 changes: 20 additions & 0 deletions src/libs/telemetry/getMemoryLogLevel/index.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type * as Sentry from '@sentry/react-native';
import CONST from '@src/CONST';
import type {MemoryInfo} from '../getMemoryInfo/types';

Check warning on line 3 in src/libs/telemetry/getMemoryLogLevel/index.android.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../getMemoryInfo/types'. Use '@libs/telemetry/getMemoryInfo/types' instead

/**
* 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';
}
20 changes: 20 additions & 0 deletions src/libs/telemetry/getMemoryLogLevel/index.ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type * as Sentry from '@sentry/react-native';
import CONST from '@src/CONST';
import type {MemoryInfo} from '../getMemoryInfo/types';

Check warning on line 3 in src/libs/telemetry/getMemoryLogLevel/index.ios.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../getMemoryInfo/types'. Use '@libs/telemetry/getMemoryInfo/types' instead

/**
* 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';
}
22 changes: 22 additions & 0 deletions src/libs/telemetry/getMemoryLogLevel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type * as Sentry from '@sentry/react-native';
import CONST from '@src/CONST';
import type {MemoryInfo} from '../getMemoryInfo/types';

Check warning on line 3 in src/libs/telemetry/getMemoryLogLevel/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../getMemoryInfo/types'. Use '@libs/telemetry/getMemoryInfo/types' instead

/**
* 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';
}
Loading
Loading