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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewProps> | React_2.ComponentType<ViewProps>;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/js/integrations/longTask.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
antonis marked this conversation as resolved.
Resolved
}
}
});
observer.observe({ type: 'longtask', buffered: true });
client.on('close', () => observer.disconnect());
} catch (error) {
debug.warn(`[${INTEGRATION_NAME}] Failed to observe long tasks.`, error);
}
},
};
};
9 changes: 9 additions & 0 deletions packages/core/src/js/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand All @@ -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<string>;
};

/** Get's the global object for the current JavaScript runtime */
export const RN_GLOBAL_OBJ = GLOBAL_OBJ as ReactNativeInternalGlobal;
168 changes: 168 additions & 0 deletions packages/core/test/integrations/longTask.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = ['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<Metric, [Metric]>;

const setup = (options?: LongTaskOptions, clientOptions?: Partial<TestClientOptions>): 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<string> {
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);
});
});
Loading