From 326f7923045607fd0454a0b9755da1e6c7f40cdf Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 4 Nov 2026 11:21:35 +0100 Subject: [PATCH 1/5] feat(core): Add longTaskIntegration to report JS long tasks as metrics Stall tracking only records JS thread blocking inside an active transaction, so blocking outside of traced work stays invisible. The new opt-in longTaskIntegration listens to the React Native PerformanceObserver for 'longtask' entries and sends each task of at least minDurationMs (default 200 ms) as the react_native.long_task.duration distribution metric. It does not depend on tracing. Scope attributes and the user come from the metrics pipeline in @sentry/core, so apps can filter by their own attributes. sampleRate (default 1) is checked once per launch, so a sampled launch reports all its long tasks. The observer uses buffered: true to also report tasks from before Sentry.init, and disconnects when the client closes. React Native installs PerformanceObserver with 'longtask' support only from 0.82 on the New Architecture, and fixes the native threshold at 50 ms. In other environments the integration logs a debug message and does nothing. --- CHANGELOG.md | 2 + packages/core/etc/sentry-react-native.api.md | 9 ++ packages/core/src/js/integrations/exports.ts | 2 + packages/core/src/js/integrations/longTask.ts | 69 ++++++++ packages/core/src/js/utils/worldwide.ts | 9 ++ .../core/test/integrations/longTask.test.ts | 150 ++++++++++++++++++ 6 files changed, 241 insertions(+) create mode 100644 packages/core/src/js/integrations/longTask.ts create mode 100644 packages/core/test/integrations/longTask.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3412112336..5843a2864f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ enableJsHangTracking: true, }); ``` +- Add `longTaskIntegration` to report JS thread long tasks as the `react_native.long_task.duration` metric, also outside of transactions ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) + - It requires React Native 0.82 or newer on the New Architecture. Set `minDurationMs` (default `200`) and `sampleRate` (default `1`) to control what it reports. ### Fixes diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index da47ee6c8b..e095ba09c7 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -727,6 +727,15 @@ export const logEnricherIntegration: () => Integration; export { logger } +// @public +export const longTaskIntegration: (options?: LongTaskOptions) => Integration; + +// @public +export interface LongTaskOptions { + minDurationMs?: number; + sampleRate?: number; +} + // @public (undocumented) export const Mask: HostComponent | React_2.ComponentType; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 9be254f8e0..1c44dc0c28 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -35,6 +35,7 @@ export { logEnricherIntegration } from './logEnricherIntegration'; export { graphqlIntegration } from './graphql'; export { supabaseIntegration } from './supabase'; export { deeplinkIntegration } from './deeplink'; +export { longTaskIntegration } from './longTask'; // User-facing integration option types. Re-exported from the entry point so their // full shape lands in the API report and field-level changes are surfaced in its diff. @@ -47,6 +48,7 @@ export type { SupabaseReactNativeIntegrationOptions } from './supabase'; export type { HermesProfilingOptions } from '../profiling/integration'; export type { MobileReplayOptions, ScreenshotStrategy } from '../replay/mobilereplay'; export type { ReplayConfiguration } from '../replay/browserReplay'; +export type { LongTaskOptions } from './longTask'; export { browserApiErrorsIntegration, diff --git a/packages/core/src/js/integrations/longTask.ts b/packages/core/src/js/integrations/longTask.ts new file mode 100644 index 0000000000..63614a49ca --- /dev/null +++ b/packages/core/src/js/integrations/longTask.ts @@ -0,0 +1,69 @@ +import type { Client, Integration } from '@sentry/core'; + +import { debug, metrics } from '@sentry/core'; + +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; + +const INTEGRATION_NAME = 'LongTask'; + +const METRIC_NAME = 'react_native.long_task.duration'; +const METRIC_OPTIONS = { unit: 'millisecond', attributes: { 'sentry.origin': 'auto.react_native.long_task' } }; + +/** Options for {@link longTaskIntegration}. */ +export interface LongTaskOptions { + /** + * The minimum duration in milliseconds of a JS task to report it. + * React Native reports only tasks of 50 ms or longer, so a lower value has no effect. + * + * @default 200 + */ + minDurationMs?: number; + /** + * The probability (0 to 1) that an app launch reports its long tasks. + * The SDK decides once per launch. A launch reports all of its long tasks or none of them. + * Sentry does not scale the metric counts by this rate. + * + * @default 1 + */ + sampleRate?: number; +} + +/** + * Reports JS thread long tasks as the `react_native.long_task.duration` distribution metric, + * independently of tracing. + * + * Requires React Native 0.82 or newer on the New Architecture. + * In other environments, the integration does nothing. + */ +export const longTaskIntegration = (options: LongTaskOptions = {}): Integration => { + const { minDurationMs = 200, sampleRate = 1 } = options; + + return { + name: INTEGRATION_NAME, + setup(client: Client): void { + const PerformanceObserver = RN_GLOBAL_OBJ.PerformanceObserver; + if (!PerformanceObserver?.supportedEntryTypes?.includes('longtask')) { + debug.log(`[${INTEGRATION_NAME}] Long tasks are not supported in this environment.`); + return; + } + + if (Math.random() >= sampleRate) { + return; + } + + try { + const observer = new PerformanceObserver(list => { + for (const { duration } of list.getEntries()) { + if (duration >= minDurationMs) { + metrics.distribution(METRIC_NAME, duration, METRIC_OPTIONS); + } + } + }); + observer.observe({ type: 'longtask', buffered: true }); + client.on('close', () => observer.disconnect()); + } catch (error) { + debug.warn(`[${INTEGRATION_NAME}] Failed to observe long tasks.`, error); + } + }, + }; +}; diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index 8f4af44100..8d98c354e3 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -38,6 +38,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { __BUNDLE_START_TIME__?: number; nativePerformanceNow?: () => number; TextEncoder?: TextEncoder; + PerformanceObserver?: PerformanceObserver; alert?: (message: string) => void; __SENTRY_OPTIONS__?: ReactNativeOptions; SENTRY_RELEASE?: { @@ -54,5 +55,13 @@ type TextEncoder = { }; }; +type PerformanceObserver = { + new (callback: (list: { getEntries(): ReadonlyArray<{ duration: number }> }) => void): { + observe(options: { type: string; buffered?: boolean }): void; + disconnect(): void; + }; + supportedEntryTypes?: ReadonlyArray; +}; + /** Get's the global object for the current JavaScript runtime */ export const RN_GLOBAL_OBJ = GLOBAL_OBJ as ReactNativeInternalGlobal; diff --git a/packages/core/test/integrations/longTask.test.ts b/packages/core/test/integrations/longTask.test.ts new file mode 100644 index 0000000000..02f9bc5b44 --- /dev/null +++ b/packages/core/test/integrations/longTask.test.ts @@ -0,0 +1,150 @@ +import type { Metric } from '@sentry/core'; + +import { getClient } from '@sentry/core'; + +import type { LongTaskOptions } from '../../src/js/integrations/longTask'; + +import { longTaskIntegration } from '../../src/js/integrations/longTask'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; +import { setupTestClient } from '../mocks/client'; + +type EntriesCallback = (list: { getEntries: () => Array<{ duration: number }> }) => void; + +class FakePerformanceObserver { + public static supportedEntryTypes: ReadonlyArray = ['longtask']; + public static instances: FakePerformanceObserver[] = []; + + public observe = jest.fn(); + public disconnect = jest.fn(); + + public constructor(public callback: EntriesCallback) { + FakePerformanceObserver.instances.push(this); + } + + public emit(...durations: number[]): void { + this.callback({ getEntries: () => durations.map(duration => ({ duration })) }); + } +} + +describe('longTaskIntegration', () => { + let beforeSendMetric: jest.Mock; + + const setup = (options?: LongTaskOptions): void => { + setupTestClient({ beforeSendMetric, integrations: [longTaskIntegration(options)] }); + }; + + beforeEach(() => { + beforeSendMetric = jest.fn(metric => metric); + FakePerformanceObserver.supportedEntryTypes = ['longtask']; + FakePerformanceObserver.instances = []; + RN_GLOBAL_OBJ.PerformanceObserver = FakePerformanceObserver; + }); + + afterEach(() => { + getClient()?.close(); + delete RN_GLOBAL_OBJ.PerformanceObserver; + jest.restoreAllMocks(); + }); + + it('observes buffered long tasks', () => { + setup(); + + expect(FakePerformanceObserver.instances).toHaveLength(1); + expect(FakePerformanceObserver.instances[0]?.observe).toHaveBeenCalledWith({ type: 'longtask', buffered: true }); + }); + + it('emits a duration metric for each long task at or above the default threshold of 200 ms', () => { + setup(); + + FakePerformanceObserver.instances[0]?.emit(199, 200, 350); + + expect(beforeSendMetric).toHaveBeenCalledTimes(2); + expect(beforeSendMetric.mock.calls.map(([metric]) => metric)).toEqual([ + expect.objectContaining({ + name: 'react_native.long_task.duration', + type: 'distribution', + value: 200, + unit: 'millisecond', + attributes: expect.objectContaining({ 'sentry.origin': 'auto.react_native.long_task' }), + }), + expect.objectContaining({ name: 'react_native.long_task.duration', value: 350 }), + ]); + }); + + it('uses a custom minDurationMs', () => { + setup({ minDurationMs: 100 }); + + FakePerformanceObserver.instances[0]?.emit(99, 100); + + expect(beforeSendMetric).toHaveBeenCalledTimes(1); + expect(beforeSendMetric).toHaveBeenCalledWith(expect.objectContaining({ value: 100 })); + }); + + describe('sampleRate', () => { + it('does not observe when the launch is not sampled', () => { + jest.spyOn(Math, 'random').mockReturnValue(0.5); + + setup({ sampleRate: 0.5 }); + + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + + it('observes when the launch is sampled', () => { + jest.spyOn(Math, 'random').mockReturnValue(0.49); + + setup({ sampleRate: 0.5 }); + + expect(FakePerformanceObserver.instances).toHaveLength(1); + }); + + it('does not observe when sampleRate is 0', () => { + setup({ sampleRate: 0 }); + + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + }); + + describe('when long tasks are not supported', () => { + it('does nothing when PerformanceObserver is not defined', () => { + delete RN_GLOBAL_OBJ.PerformanceObserver; + + expect(() => setup()).not.toThrow(); + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + + it('does nothing when longtask is not a supported entry type', () => { + FakePerformanceObserver.supportedEntryTypes = ['mark', 'measure']; + + setup(); + + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + + it('does nothing when supportedEntryTypes is missing', () => { + // @ts-expect-error Simulates a runtime without the static property + FakePerformanceObserver.supportedEntryTypes = undefined; + + setup(); + + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + }); + + it('does not throw when observe throws', () => { + RN_GLOBAL_OBJ.PerformanceObserver = class extends FakePerformanceObserver { + public observe = jest.fn(() => { + throw new Error('observe failed'); + }); + }; + + expect(() => setup()).not.toThrow(); + }); + + it('disconnects the observer when the client closes', async () => { + setup(); + + await getClient()?.close(); + + expect(FakePerformanceObserver.instances[0]?.disconnect).toHaveBeenCalledTimes(1); + }); +}); From 8d289e3d435a647f8a076285ae4999e8d66842d5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 4 Nov 2026 11:24:17 +0100 Subject: [PATCH 2/5] docs(core): Link longTaskIntegration changelog entry to its PR Replace the #XXXX placeholder in the CHANGELOG entry with the number of the pull request, #6986, now that the PR exists. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5843a2864f..2b0aa28a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ enableJsHangTracking: true, }); ``` -- Add `longTaskIntegration` to report JS thread long tasks as the `react_native.long_task.duration` metric, also outside of transactions ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) +- Add `longTaskIntegration` to report JS thread long tasks as the `react_native.long_task.duration` metric, also outside of transactions ([#6986](https://github.com/getsentry/sentry-react-native/pull/6986)) - It requires React Native 0.82 or newer on the New Architecture. Set `minDurationMs` (default `200`) and `sampleRate` (default `1`) to control what it reports. ### Fixes From d30d70ab579cdfaeebe6f478e2a72554dbcdd246 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 5 Nov 2026 09:39:36 +0100 Subject: [PATCH 3/5] fix(core): Skip long task observer when metrics are off and guard detection longTaskIntegration now returns early when enableMetrics is false. @sentry/core already drops the metrics in that case, but the observer still ran on the device for nothing. The support check now runs inside the try/catch. Reading the lazy PerformanceObserver global loads the React Native module, and supportedEntryTypes calls into native code, so both can throw. An error there now logs a debug warning instead of breaking client setup. --- packages/core/src/js/integrations/longTask.ts | 20 +++++++++++------ .../core/test/integrations/longTask.test.ts | 22 +++++++++++++++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/core/src/js/integrations/longTask.ts b/packages/core/src/js/integrations/longTask.ts index 63614a49ca..e5d9c8e02b 100644 --- a/packages/core/src/js/integrations/longTask.ts +++ b/packages/core/src/js/integrations/longTask.ts @@ -41,17 +41,23 @@ export const longTaskIntegration = (options: LongTaskOptions = {}): Integration return { name: INTEGRATION_NAME, setup(client: Client): void { - const PerformanceObserver = RN_GLOBAL_OBJ.PerformanceObserver; - if (!PerformanceObserver?.supportedEntryTypes?.includes('longtask')) { - debug.log(`[${INTEGRATION_NAME}] Long tasks are not supported in this environment.`); - return; - } - - if (Math.random() >= sampleRate) { + if (client.getOptions().enableMetrics === false) { + debug.log(`[${INTEGRATION_NAME}] Metrics are disabled, long tasks will not be reported.`); return; } try { + // Reading the lazy global loads the RN module, and `supportedEntryTypes` calls into native code. + const PerformanceObserver = RN_GLOBAL_OBJ.PerformanceObserver; + if (!PerformanceObserver?.supportedEntryTypes?.includes('longtask')) { + debug.log(`[${INTEGRATION_NAME}] Long tasks are not supported in this environment.`); + return; + } + + if (Math.random() >= sampleRate) { + return; + } + const observer = new PerformanceObserver(list => { for (const { duration } of list.getEntries()) { if (duration >= minDurationMs) { diff --git a/packages/core/test/integrations/longTask.test.ts b/packages/core/test/integrations/longTask.test.ts index 02f9bc5b44..22766ced09 100644 --- a/packages/core/test/integrations/longTask.test.ts +++ b/packages/core/test/integrations/longTask.test.ts @@ -3,6 +3,7 @@ import type { Metric } from '@sentry/core'; import { getClient } from '@sentry/core'; import type { LongTaskOptions } from '../../src/js/integrations/longTask'; +import type { TestClientOptions } from '../mocks/client'; import { longTaskIntegration } from '../../src/js/integrations/longTask'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; @@ -29,8 +30,8 @@ class FakePerformanceObserver { describe('longTaskIntegration', () => { let beforeSendMetric: jest.Mock; - const setup = (options?: LongTaskOptions): void => { - setupTestClient({ beforeSendMetric, integrations: [longTaskIntegration(options)] }); + const setup = (options?: LongTaskOptions, clientOptions?: Partial): void => { + setupTestClient({ beforeSendMetric, integrations: [longTaskIntegration(options)], ...clientOptions }); }; beforeEach(() => { @@ -130,6 +131,23 @@ describe('longTaskIntegration', () => { }); }); + it('does not observe when metrics are disabled', () => { + setup(undefined, { enableMetrics: false }); + + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + + it('does not throw when reading supportedEntryTypes throws', () => { + RN_GLOBAL_OBJ.PerformanceObserver = class extends FakePerformanceObserver { + public static get supportedEntryTypes(): ReadonlyArray { + throw new Error('native module missing'); + } + }; + + expect(() => setup()).not.toThrow(); + expect(FakePerformanceObserver.instances).toHaveLength(0); + }); + it('does not throw when observe throws', () => { RN_GLOBAL_OBJ.PerformanceObserver = class extends FakePerformanceObserver { public observe = jest.fn(() => { From 531ac868dfa83b661b3f77b4e92e5d6dc8b31c15 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 5 Nov 2026 13:04:40 +0100 Subject: [PATCH 4/5] Update packages/core/src/js/integrations/longTask.ts Co-authored-by: Markus Hintersteiner --- packages/core/src/js/integrations/longTask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/integrations/longTask.ts b/packages/core/src/js/integrations/longTask.ts index e5d9c8e02b..92ca557cb6 100644 --- a/packages/core/src/js/integrations/longTask.ts +++ b/packages/core/src/js/integrations/longTask.ts @@ -19,7 +19,7 @@ export interface LongTaskOptions { */ minDurationMs?: number; /** - * The probability (0 to 1) that an app launch reports its long tasks. + * The probability (0.0 to 1.0) that an app launch reports its long tasks. * The SDK decides once per launch. A launch reports all of its long tasks or none of them. * Sentry does not scale the metric counts by this rate. * From d0b151ec08dee1e10b745875b83472cad3c21b60 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 5 Nov 2026 13:04:55 +0100 Subject: [PATCH 5/5] Update packages/core/src/js/integrations/longTask.ts Co-authored-by: Markus Hintersteiner --- packages/core/src/js/integrations/longTask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/integrations/longTask.ts b/packages/core/src/js/integrations/longTask.ts index 92ca557cb6..df26461742 100644 --- a/packages/core/src/js/integrations/longTask.ts +++ b/packages/core/src/js/integrations/longTask.ts @@ -20,7 +20,7 @@ export interface LongTaskOptions { minDurationMs?: number; /** * The probability (0.0 to 1.0) that an app launch reports its long tasks. - * The SDK decides once per launch. A launch reports all of its long tasks or none of them. + * The SDK decides once per SDK init. A launch reports all of its long tasks or none of them. * Sentry does not scale the metric counts by this rate. * * @default 1