diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b71240024..5b688ba123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) + ## 8.12.0 ### Features diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index f2f492bd8d..7fb53b8ef5 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -279,6 +279,14 @@ public void disableShakeDetection() { stopShakeDetection(); } + public void pauseAppHangTracking() { + // No-op: App hang tracking is iOS-only + } + + public void resumeAppHangTracking() { + // No-op: App hang tracking is iOS-only + } + public void fetchModules(Promise promise) { final AssetManager assets = this.getReactApplicationContext().getResources().getAssets(); try (InputStream stream = new BufferedInputStream(assets.open(modulesPath))) { diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 82d2622996..973480ef64 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -234,6 +234,16 @@ public void disableShakeDetection() { this.impl.disableShakeDetection(); } + @Override + public void pauseAppHangTracking() { + this.impl.pauseAppHangTracking(); + } + + @Override + public void resumeAppHangTracking() { + this.impl.resumeAppHangTracking(); + } + @Override public void invalidate() { this.impl.invalidate(); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 099623f056..9d1bac1028 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -234,6 +234,16 @@ public void disableShakeDetection() { this.impl.disableShakeDetection(); } + @ReactMethod + public void pauseAppHangTracking() { + this.impl.pauseAppHangTracking(); + } + + @ReactMethod + public void resumeAppHangTracking() { + this.impl.resumeAppHangTracking(); + } + @Override public void invalidate() { this.impl.invalidate(); diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 3957c06956..806e67c2d7 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -495,6 +495,9 @@ export { OpenAiClient } export { OpenAiOptions } +// @public +export function pauseAppHangTracking(): void; + // @public export const primitiveTagIntegration: () => Integration; @@ -559,6 +562,9 @@ export const reactNavigationIntegration: (input?: Partial; enableShakeDetection(): void; disableShakeDetection(): void; + pauseAppHangTracking(): void; + resumeAppHangTracking(): void; } export type NativeStackFrame = { diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 81734f21c5..46d0d6a6f6 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -98,7 +98,18 @@ export { SDK_NAME, SDK_VERSION } from './version'; export type { ReactNativeOptions, NativeLogEntry } from './options'; export { ReactNativeClient } from './client'; -export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun, appLoaded } from './sdk'; +export { + init, + wrap, + nativeCrash, + flush, + close, + withScope, + crashedLastRun, + appLoaded, + pauseAppHangTracking, + resumeAppHangTracking, +} from './sdk'; export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { GlobalErrorBoundary, withGlobalErrorBoundary } from './GlobalErrorBoundary'; export type { GlobalErrorBoundaryProps } from './GlobalErrorBoundary'; diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 6fffe6d66f..84cee9a088 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -312,3 +312,31 @@ export function withScope(callback: (scope: Scope) => T): T | undefined { export async function crashedLastRun(): Promise { return NATIVE.crashedLastRun(); } + +/** + * Pauses app hang tracking on iOS. + * + * App hang detection will ignore detected app hangs until + * `resumeAppHangTracking` is called. + * + * Use this when showing system dialogs (e.g., permission prompts) + * that block the main thread but are not real hangs. + * + * No-op on Android and when native is not available. + * + * @platform ios + */ +export function pauseAppHangTracking(): void { + NATIVE.pauseAppHangTracking(); +} + +/** + * Resumes app hang tracking on iOS after it was paused. + * + * No-op on Android and when native is not available. + * + * @platform ios + */ +export function resumeAppHangTracking(): void { + NATIVE.resumeAppHangTracking(); +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 5117996a8f..d4225740d6 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -100,6 +100,8 @@ interface SentryNativeWrapper { disableNativeFramesTracking(): void; enableNativeFramesTracking(): void; + pauseAppHangTracking(): void; + resumeAppHangTracking(): void; addBreadcrumb(breadcrumb: Breadcrumb): void; // oxlint-disable-next-line typescript-eslint(no-explicit-any) @@ -673,6 +675,28 @@ export const NATIVE: SentryNativeWrapper = { RNSentry.enableNativeFramesTracking(); }, + pauseAppHangTracking(): void { + if (!this.enableNative) { + return; + } + if (!this._isModuleLoaded(RNSentry)) { + return; + } + + RNSentry.pauseAppHangTracking(); + }, + + resumeAppHangTracking(): void { + if (!this.enableNative) { + return; + } + if (!this._isModuleLoaded(RNSentry)) { + return; + } + + RNSentry.resumeAppHangTracking(); + }, + isNativeAvailable(): boolean { if (!RNSentry) { RNSentry = getRNSentryModule(); diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 86612bafa8..d1814169e5 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -36,6 +36,8 @@ const NATIVE: MockInterface = { disableNativeFramesTracking: jest.fn(), enableNativeFramesTracking: jest.fn(), + pauseAppHangTracking: jest.fn(), + resumeAppHangTracking: jest.fn(), addBreadcrumb: jest.fn(), setContext: jest.fn(), diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index a5d3b327fb..838bf0fbfb 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -59,6 +59,8 @@ jest.mock('react-native', () => { _getLastPayload: () => ({ initPayload }), startProfiling: jest.fn(), stopProfiling: jest.fn(), + pauseAppHangTracking: jest.fn(), + resumeAppHangTracking: jest.fn(), }; return { @@ -1208,6 +1210,58 @@ describe('Tests Native Wrapper', () => { }); }); + describe('pauseAppHangTracking', () => { + test('calls native pauseAppHangTracking', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + NATIVE.pauseAppHangTracking(); + expect(RNSentry.pauseAppHangTracking).toHaveBeenCalled(); + }); + + test('does not call native when enableNative is false', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: false, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + NATIVE.pauseAppHangTracking(); + expect(RNSentry.pauseAppHangTracking).not.toHaveBeenCalled(); + }); + }); + + describe('resumeAppHangTracking', () => { + test('calls native resumeAppHangTracking', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + NATIVE.resumeAppHangTracking(); + expect(RNSentry.resumeAppHangTracking).toHaveBeenCalled(); + }); + + test('does not call native when enableNative is false', async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: false, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + NATIVE.resumeAppHangTracking(); + expect(RNSentry.resumeAppHangTracking).not.toHaveBeenCalled(); + }); + }); + describe('primitiveProcessor and _setPrimitiveProcessor', () => { describe('primitiveProcessor', () => { it('default primitiveProcessor returns value as string', () => {