Skip to content
Draft
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
140 changes: 140 additions & 0 deletions packages/core/test/turbomodule/turboModuleLatency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Micro-benchmark for the JS-side TurboModule instrumentation.
*
* `enableTurboModuleTracking` installs the native perf logger; the user-visible
* per-call cost in JS comes from the wrapper `wrapTurboModule` installs on each
* TurboModule method (scope push/pop + aggregate counters). This measures that
* wrapper against the exact same module without it, and prints the numbers so
* they show up in CI output.
*
* It deliberately makes no assertion on absolute timings — shared CI runners are
* far too noisy for that. The only guard is a very generous per-call ceiling that
* catches pathological regressions (e.g. an accidental O(n) scan per call).
*
* See https://github.com/getsentry/sentry-react-native/issues/6167.
*/
import * as SentryCore from '@sentry/core';
import { Scope } from '@sentry/core';

import {
_resetTurboModuleAggregator,
setAggregateRecordingEnabled,
} from '../../src/js/turbomodule/turboModuleAggregator';
import { _resetTurboModuleTracker } from '../../src/js/turbomodule/turboModuleTracker';
import { _resetWrappedModules, wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule';

/** Iterations per measured run. Kept small enough to stay well under the Jest timeout. */
const ITERATIONS = 20_000;
/** Discarded iterations, so JIT warm-up doesn't land in the measured window. */
const WARMUP_ITERATIONS = 2_000;

/**
* Generous ceiling on the added per-call cost, in microseconds. The wrapper does
* a handful of map writes per call; anything above this means something is
* structurally wrong rather than just a slow runner.
*/
const MAX_OVERHEAD_US_PER_CALL = 100;

interface SyncModule {
add: (a: number, b: number) => number;
}

interface AsyncModule {
getPlatform: () => Promise<string>;
}

const createSyncModule = (): SyncModule => ({
add: (a: number, b: number): number => a + b,
});

const createAsyncModule = (): AsyncModule => ({
getPlatform: (): Promise<string> => Promise.resolve('benchmark'),
});

const nowUs = (): number => Number(process.hrtime.bigint()) / 1_000;

const measureSync = (module: SyncModule, iterations: number): number => {
let sum = 0;
const start = nowUs();
for (let i = 0; i < iterations; i++) {
sum = module.add(sum, 1);
}
const elapsed = nowUs() - start;
// Consume `sum` so the loop can't be optimised away.
expect(sum).toBe(iterations);
return elapsed;
};

const measureAsync = async (module: AsyncModule, iterations: number): Promise<number> => {
const start = nowUs();
for (let i = 0; i < iterations; i++) {
await module.getPlatform();
}
return nowUs() - start;
};

interface Row {
label: string;
baselineUsPerCall: number;
trackedUsPerCall: number;
}

const report = (rows: Row[]): void => {
const lines = rows.map(row => {
const overhead = row.trackedUsPerCall - row.baselineUsPerCall;
const factor = row.baselineUsPerCall > 0 ? row.trackedUsPerCall / row.baselineUsPerCall : NaN;
return (
` ${row.label.padEnd(8)} ` +
`off=${row.baselineUsPerCall.toFixed(3)}us/call ` +
`on=${row.trackedUsPerCall.toFixed(3)}us/call ` +
`overhead=${overhead.toFixed(3)}us/call (${factor.toFixed(2)}x)`
);
});
// `test/mockConsole.ts` replaces `console.log`, so write straight to stdout to
// make sure the numbers reach the CI log.
process.stdout.write(`\n[benchmark] TurboModule call latency (${ITERATIONS} iterations)\n${lines.join('\n')}\n\n`);
};

describe('TurboModule call latency', () => {
beforeEach(() => {
_resetTurboModuleTracker();
_resetTurboModuleAggregator();
_resetWrappedModules();
setAggregateRecordingEnabled(true);
const scope = new Scope();
jest.spyOn(SentryCore, 'getIsolationScope').mockReturnValue(scope);
jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('adds a bounded per-call overhead for sync and async calls', async () => {
// Arrange
const baselineSync = createSyncModule();
const trackedSync = wrapTurboModule('BenchmarkModule', createSyncModule()) as SyncModule;
const baselineAsync = createAsyncModule();
const trackedAsync = wrapTurboModule('BenchmarkAsyncModule', createAsyncModule()) as AsyncModule;

measureSync(baselineSync, WARMUP_ITERATIONS);
measureSync(trackedSync, WARMUP_ITERATIONS);
await measureAsync(baselineAsync, WARMUP_ITERATIONS);
await measureAsync(trackedAsync, WARMUP_ITERATIONS);

// Act
const syncBaselineUs = measureSync(baselineSync, ITERATIONS) / ITERATIONS;
const syncTrackedUs = measureSync(trackedSync, ITERATIONS) / ITERATIONS;
const asyncBaselineUs = (await measureAsync(baselineAsync, ITERATIONS)) / ITERATIONS;
const asyncTrackedUs = (await measureAsync(trackedAsync, ITERATIONS)) / ITERATIONS;

report([
{ label: 'sync', baselineUsPerCall: syncBaselineUs, trackedUsPerCall: syncTrackedUs },
{ label: 'async', baselineUsPerCall: asyncBaselineUs, trackedUsPerCall: asyncTrackedUs },
]);

// Assert
expect(syncTrackedUs - syncBaselineUs).toBeLessThan(MAX_OVERHEAD_US_PER_CALL);
expect(asyncTrackedUs - asyncBaselineUs).toBeLessThan(MAX_OVERHEAD_US_PER_CALL);
}, 120_000);
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.sentry.reactnative.sample

import android.os.Build
import com.facebook.fbreact.specs.NativePlatformSampleModuleSpec
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext

class NativePlatformSampleModule(
Expand All @@ -10,6 +12,10 @@ class NativePlatformSampleModule(

override fun crashOrString(): String = throw RuntimeException("JVM Crash in NativePlatformSampleModule.crashOrString()")

override fun getPlatform(promise: Promise) {
promise.resolve("android ${Build.VERSION.RELEASE}")
}

companion object {
const val NAME = "NativePlatformSampleModule"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, beforeAll, expect, afterAll } from '@jest/globals';
import { Envelope, EventItem } from '@sentry/core';

import { getItemOfTypeFrom } from '../../utils/event';
import { maestro } from '../../utils/maestro';
import {
createSentryServer,
containingTransactionWithName,
} from '../../utils/mockedSentryServer';

const TURBO_MODULE_SPAN_NAME = 'turbo_module.sample';

/** 5 synchronous `NativeSampleModule.add` calls plus one async `getPlatform`. */
const EXPECTED_CALL_COUNT = 6;

describe('TurboModule span attributes', () => {
let sentryServer = createSentryServer();

let envelope: Envelope;

beforeAll(async () => {
await sentryServer.start();

const envelopePromise = sentryServer.waitForEnvelope(
containingTransactionWithName(TURBO_MODULE_SPAN_NAME),
);

await maestro(
'tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.yml',
);

envelope = await envelopePromise;
}, 240000); // 240 seconds timeout for iOS event delivery

afterAll(async () => {
await sentryServer.close();
});

it('attaches aggregated turbo_module attributes to the root span', async () => {
const item = getItemOfTypeFrom<EventItem>(envelope, 'transaction');

expect(item?.[1]).toEqual(
expect.objectContaining({
transaction: TURBO_MODULE_SPAN_NAME,
contexts: expect.objectContaining({
trace: expect.objectContaining({
data: expect.objectContaining({
'turbo_module.arch': 'new',
'turbo_module.total_call_count': EXPECTED_CALL_COUNT,
'turbo_module.total_error_count': 0,
'turbo_module.total_duration_ms': expect.any(Number),
'turbo_module.unique_methods': 2,
}),
}),
}),
}),
);
});

it('attaches a per-module and per-method breakdown', async () => {
const item = getItemOfTypeFrom<EventItem>(envelope, 'transaction');

expect(item?.[1]).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
data: expect.objectContaining({
'turbo_module.NativeSampleModule.add.call_count': 5,
'turbo_module.NativeSampleModule.add.error_count': 0,
'turbo_module.NativeSampleModule.add.duration_ms':
expect.any(Number),
'turbo_module.NativePlatformSampleModule.getPlatform.call_count': 1,
'turbo_module.NativePlatformSampleModule.getPlatform.error_count': 0,
'turbo_module.NativePlatformSampleModule.getPlatform.duration_ms':
expect.any(Number),
}),
}),
}),
}),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
appId: io.sentry.reactnative.sample
---
- launchApp:
clearState: true
stopApp: true
arguments:
isE2ETest: true

# App launches on ErrorsTab (first tab)
- waitForAnimationToEnd:
timeout: 2000

# The entry button is below the fold on smaller devices
- scrollUntilVisible:
element: 'TurboModule Playground'
timeout: 10000
direction: DOWN
- tapOn: 'TurboModule Playground'

- assertVisible:
id: 'turbo-module-status'

- tapOn: 'Run TurboModule calls'

# Sync C++ calls + one async platform call, wrapped in a manual root span
- extendedWaitUntil:
visible: 'Status: Done'
timeout: 30000

- assertVisible: 'Sync: add x5 = 15'
- assertVisible: 'Async: getPlatform = .*'

# The `turbo_module.*` attributes the TurboModuleContext integration attached
# to the root span are rendered on screen so QA can eyeball them on device.
- assertVisible: 'turbo_module.total_call_count: 6'
- assertVisible: 'turbo_module.total_error_count: 0'

# A native throw surfaced to JS must still be capturable (crash-context path)
- tapOn: 'Throw from native platform'
- extendedWaitUntil:
visible: 'Throw: captured platform throw as .*'
timeout: 30000
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#ifdef RCT_NEW_ARCH_ENABLED

# import <UIKit/UIKit.h>

@implementation NativePlatformSampleModule

RCT_EXPORT_MODULE();
Expand All @@ -20,6 +22,11 @@ - (NSString *)crashOrString
return @"NEVER RETURNED";
}

- (void)getPlatform:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
{
resolve([NSString stringWithFormat:@"ios %@", [[UIDevice currentDevice] systemVersion]]);
}

@end

#endif
25 changes: 24 additions & 1 deletion samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import getErrorsTab from './tabs/ErrorsTab';
import getPerformanceTab from './tabs/PerformanceTab';
import getPlaygroundTab from './tabs/PlaygroundTab';
import { logWithoutTracing, shouldUseAutoStart } from './utils';
import NativePlatformSampleModule from '../tm/NativePlatformSampleModule';
import NativeSampleModule from '../tm/NativeSampleModule';

LogBox.ignoreAllLogs();
const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios';
Expand All @@ -36,6 +38,20 @@ const reactNavigationIntegration = Sentry.reactNavigationIntegration({
const sampleFeatureFlagsIntegration = Sentry.featureFlagsIntegration();
sampleFeatureFlagsIntegration.addFeatureFlag('sample-test-flag', true);

// Replaces the default `TurboModuleContext` integration so the sample's own
// TurboModules are instrumented too, not just `RNSentry`. Exercised by the
// "TurboModule Playground" screen on the Errors tab.
const sampleTurboModuleContextIntegration =
Sentry.turboModuleContextIntegration({
modules: [
{ name: 'NativeSampleModule', module: NativeSampleModule },
{
name: 'NativePlatformSampleModule',
module: NativePlatformSampleModule,
},
],
});

const StackNavigator: TypedNavigator<any, any> = isMobileOs
? createNativeStackNavigator()
: createStackNavigator();
Expand Down Expand Up @@ -141,8 +157,15 @@ Sentry.init({
}),
Sentry.extraErrorDataIntegration(),
sampleFeatureFlagsIntegration,
sampleTurboModuleContextIntegration,
);
return integrations.filter(
i =>
i.name !== 'Dedupe' &&
// Drop the default TurboModuleContext in favour of the configured one.
(i.name !== 'TurboModuleContext' ||
i === sampleTurboModuleContextIntegration),
);
return integrations.filter(i => i.name !== 'Dedupe');
},
enableAutoSessionTracking: true,
// For testing, session close when 5 seconds (instead of the default 30) in the background.
Expand Down
6 changes: 6 additions & 0 deletions samples/react-native/src/Screens/ErrorsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ const ErrorsScreen = (_props: Props) => {
}
}}
/>
<Button
title="TurboModule Playground"
onPress={() => {
_props.navigation.navigate('TurboModule');
}}
/>
<Button
title="Log console"
onPress={() => {
Expand Down
Loading
Loading