diff --git a/CHANGELOG.md b/CHANGELOG.md index 3412112336..2b0aa28a8d 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 ([#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 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..df26461742 --- /dev/null +++ b/packages/core/src/js/integrations/longTask.ts @@ -0,0 +1,75 @@ +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.0 to 1.0) that an app launch reports its long tasks. + * 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 + */ + 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 { + 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) { + 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..22766ced09 --- /dev/null +++ b/packages/core/test/integrations/longTask.test.ts @@ -0,0 +1,168 @@ +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'; +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, clientOptions?: Partial): void => { + setupTestClient({ beforeSendMetric, integrations: [longTaskIntegration(options)], ...clientOptions }); + }; + + 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 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(() => { + 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); + }); +});