From 9c09228a974f0661a52868c63bee074ddb185de6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 31 Jul 2026 12:04:05 +0200 Subject: [PATCH] test(e2e): Add TurboModule sample coverage and e2e validation Extends the sample TurboModules with a synchronous `NativeSampleModule.add` and an asynchronous `NativePlatformSampleModule.getPlatform` so the whole instrumentation stack can be exercised end to end. - New "TurboModule Playground" screen on the Errors tab runs both calls inside a manual root span, renders the resulting `turbo_module.*` span attributes on device and offers throw-from-native buttons for the crash-context path. - The sample now configures `turboModuleContextIntegration` with its own modules instead of relying on the default (which only tracks `RNSentry`). - Maestro flow plus a Jest e2e test asserting the aggregated and per-method `turbo_module.*` attributes on the transaction envelope. - Micro-benchmark printing the per-call latency of the TurboModule wrapper with and without tracking into the CI output. --- .../turbomodule/turboModuleLatency.test.ts | 140 ++++++++++++ .../sample/NativePlatformSampleModule.kt | 6 + .../turboModuleSpanAttributes.test.ts | 82 +++++++ .../turboModuleSpanAttributes.test.yml | 42 ++++ .../NativePlatformSampleModule.mm | 7 + samples/react-native/src/App.tsx | 25 +- .../react-native/src/Screens/ErrorsScreen.tsx | 6 + .../src/Screens/TurboModuleScreen.tsx | 213 ++++++++++++++++++ samples/react-native/src/tabs/ErrorsTab.tsx | 6 + .../tm/NativePlatformSampleModule.ts | 2 + .../react-native/tm/NativeSampleModule.cpp | 6 + samples/react-native/tm/NativeSampleModule.h | 2 + samples/react-native/tm/NativeSampleModule.ts | 2 + 13 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/turbomodule/turboModuleLatency.test.ts create mode 100644 samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.ts create mode 100644 samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.yml create mode 100644 samples/react-native/src/Screens/TurboModuleScreen.tsx diff --git a/packages/core/test/turbomodule/turboModuleLatency.test.ts b/packages/core/test/turbomodule/turboModuleLatency.test.ts new file mode 100644 index 0000000000..618029b018 --- /dev/null +++ b/packages/core/test/turbomodule/turboModuleLatency.test.ts @@ -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; +} + +const createSyncModule = (): SyncModule => ({ + add: (a: number, b: number): number => a + b, +}); + +const createAsyncModule = (): AsyncModule => ({ + getPlatform: (): Promise => 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 => { + 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); +}); diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/NativePlatformSampleModule.kt b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/NativePlatformSampleModule.kt index b2b0d4a25c..8766c45ef7 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/NativePlatformSampleModule.kt +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/NativePlatformSampleModule.kt @@ -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( @@ -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" } diff --git a/samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.ts b/samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.ts new file mode 100644 index 0000000000..bdb04a0cc4 --- /dev/null +++ b/samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.ts @@ -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(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(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), + }), + }), + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.yml b/samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.yml new file mode 100644 index 0000000000..e8bd2c0131 --- /dev/null +++ b/samples/react-native/e2e/tests/turboModuleSpanAttributes/turboModuleSpanAttributes.test.yml @@ -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 diff --git a/samples/react-native/ios/sentryreactnativesample/NativePlatformSampleModule.mm b/samples/react-native/ios/sentryreactnativesample/NativePlatformSampleModule.mm index 80c31d1b3a..335113870a 100644 --- a/samples/react-native/ios/sentryreactnativesample/NativePlatformSampleModule.mm +++ b/samples/react-native/ios/sentryreactnativesample/NativePlatformSampleModule.mm @@ -2,6 +2,8 @@ #ifdef RCT_NEW_ARCH_ENABLED +# import + @implementation NativePlatformSampleModule RCT_EXPORT_MODULE(); @@ -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 diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 82be49ff0c..85aae107d2 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -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'; @@ -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 = isMobileOs ? createNativeStackNavigator() : createStackNavigator(); @@ -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. diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index 9cc221ac46..c4098191d0 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -173,6 +173,12 @@ const ErrorsScreen = (_props: Props) => { } }} /> +