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
115 changes: 115 additions & 0 deletions packages/core/test/integrations/turboModuleContext.spans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Integration-level checks against real `SentrySpan`s (the rest of
* `turboModuleContext.test.ts` drives the integration through mock clients).
*
* This locks in the contract the `samples/react-native` "TurboModule Playground"
* screen depends on: the `turbo_module.*` attributes are readable off the span
* as soon as `span.end()` returns, so the sample can render them on device
* without waiting for the transaction to be sent — and the very same values
* reach the transaction event.
*
* Everything lives in a single test on purpose: `setupOnce` (which installs the
* module wrappers) is only ever run once per integration name per process, so a
* second client in the same file would silently leave the modules unwrapped.
*/
import type { Event, Span, TransactionEvent } from '@sentry/core';

import {
getCurrentScope,
getGlobalScope,
getIsolationScope,
setCurrentClient,
spanToJSON,
startNewTrace,
startSpanManual,
} from '@sentry/core';

import { turboModuleContextIntegration } from '../../src/js/integrations/turboModuleContext';
import { _resetTurboModuleAggregator } from '../../src/js/turbomodule/turboModuleAggregator';
import { _resetTurboModuleTracker } from '../../src/js/turbomodule/turboModuleTracker';
import { _resetWrappedModules } from '../../src/js/turbomodule/wrapTurboModule';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

const SYNC_CALL_COUNT = 5;

describe('turboModuleContextIntegration with real spans', () => {
beforeEach(() => {
_resetTurboModuleTracker();
_resetTurboModuleAggregator();
_resetWrappedModules();
getCurrentScope().clear();
getIsolationScope().clear();
getGlobalScope().clear();
});

afterEach(() => {
TestClient.sendEventCalled = undefined;
});

it('exposes turbo_module attributes on the ended span and on the transaction event', async () => {
// Arrange
const syncModule = { add: (a: number, b: number): number => a + b };
const asyncModule = { getPlatform: (): Promise<string> => Promise.resolve('test') };

const client = new TestClient(
getDefaultTestClientOptions({
tracesSampleRate: 1.0,
integrations: [
turboModuleContextIntegration({
modules: [
{ name: 'NativeSampleModule', module: syncModule },
{ name: 'NativePlatformSampleModule', module: asyncModule },
],
aggregateFlushIntervalMs: 0,
}),
],
}),
);
setCurrentClient(client);
client.init();

// The event goes through the async `_prepareEvent` pipeline, so wait for the
// send instead of reading `eventQueue` synchronously.
const sentEvent = new Promise<Event>(resolve => {
TestClient.sendEventCalled = resolve;
});

// Act
let onSpanData: Record<string, unknown> = {};
await startNewTrace(async () => {
await startSpanManual({ name: 'turbo_module.sample', forceTransaction: true }, async (span: Span) => {
let sum = 0;
for (let index = 1; index <= SYNC_CALL_COUNT; index++) {
sum = syncModule.add(sum, index);
}
expect(sum).toBe(15);

await asyncModule.getPlatform();

span.end();
// `SentrySpan.end()` emits `spanEnd` — where the integration writes the
// attributes — before it seals the span, and spans created through the
// core span API are never sealed at all, so this read sees them.
onSpanData = (spanToJSON(span).data ?? {}) as Record<string, unknown>;
});
});
const transaction = (await sentEvent) as TransactionEvent;

// Assert
const expectedAttributes = {
'turbo_module.total_call_count': SYNC_CALL_COUNT + 1,
'turbo_module.total_error_count': 0,
'turbo_module.unique_methods': 2,
'turbo_module.NativeSampleModule.add.call_count': SYNC_CALL_COUNT,
'turbo_module.NativeSampleModule.add.error_count': 0,
'turbo_module.NativePlatformSampleModule.getPlatform.call_count': 1,
'turbo_module.NativePlatformSampleModule.getPlatform.error_count': 0,
};

expect(onSpanData).toEqual(expect.objectContaining(expectedAttributes));

expect(transaction.type).toBe('transaction');
expect(transaction.transaction).toBe('turbo_module.sample');
expect(transaction.contexts?.trace?.data).toEqual(expect.objectContaining(expectedAttributes));
});
});
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),
}),
}),
}),
}),
);
});
});
Loading
Loading