diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0909587a..0b8182c454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ ## Unreleased +### Features + +- Add JavaScript (Hermes) samples to UI profiles on Android 15 and newer ([#6966](https://github.com/getsentry/sentry-react-native/pull/6966)) + - When `_experiments.profilingOptions` is set, the SDK sends the Hermes samples as profile chunks of the native profiler session, so the profile of a transaction shows its JavaScript functions. + ### Fixes - Enable native logs on Android when `enableLogs` is set ([#6967](https://github.com/getsentry/sentry-react-native/pull/6967)) diff --git a/packages/core/src/js/profiling/convertHermesProfile.ts b/packages/core/src/js/profiling/convertHermesProfile.ts index 704c4466ef..b4ca58e3c5 100644 --- a/packages/core/src/js/profiling/convertHermesProfile.ts +++ b/packages/core/src/js/profiling/convertHermesProfile.ts @@ -26,13 +26,16 @@ const JS_THREAD_PRIORITY = 1; * * @returns Sentry profile or null if no samples are found. */ -export function convertToSentryProfile(hermesProfile: Hermes.Profile): RawThreadCpuProfile | null { +export function convertToSentryProfile( + hermesProfile: Hermes.Profile, + maxElapsedSinceStartNs: number = MAX_PROFILE_DURATION_NS, +): RawThreadCpuProfile | null { if (hermesProfile.samples.length === 0) { debug.warn('[Profiling] No samples found in profile.'); return null; } - const { samples, hermesStacks, jsThreads } = mapSamples(hermesProfile.samples); + const { samples, hermesStacks, jsThreads } = mapSamples(hermesProfile.samples, maxElapsedSinceStartNs); const { frames, hermesStackFrameIdToSentryFrameIdMap } = mapFrames(hermesProfile.stackFrames); diff --git a/packages/core/src/js/profiling/hermesProfileChunk.ts b/packages/core/src/js/profiling/hermesProfileChunk.ts new file mode 100644 index 0000000000..dc331fbea1 --- /dev/null +++ b/packages/core/src/js/profiling/hermesProfileChunk.ts @@ -0,0 +1,61 @@ +import type { Client, ProfileChunk } from '@sentry/core'; + +import { uuid4 } from '@sentry/core'; + +import type * as Hermes from './hermes'; + +import { getDefaultEnvironment } from '../utils/environment'; +import { convertToSentryProfile } from './convertHermesProfile'; +import { getDebugMetadata } from './debugid'; + +/** + * Thread id of the Hermes samples in a profile chunk. + * + * The native profiler also samples the JS thread under its OS thread id. Sentry merges all chunks of + * a profiler id, so the Hermes samples need their own id. Linux thread ids stay below 2^22. + */ +export const HERMES_CHUNK_THREAD_ID = String(2 ** 32); + +/** Relay rejects profile chunks that are longer than 66 seconds. */ +const MAX_CHUNK_DURATION_NS = 66e9; + +/** + * Converts a Hermes profile to a profile chunk for the native profiler session `profilerId`. + * + * Hermes timestamps use a monotonic clock, so the samples are placed relative to + * `startTimestampMs`, the Unix time when the Hermes sampler started. + */ +export function createHermesProfileChunk( + hermesProfile: Hermes.Profile, + startTimestampMs: number, + profilerId: string, + client: Client, +): ProfileChunk | null { + const profile = convertToSentryProfile(hermesProfile, MAX_CHUNK_DURATION_NS); + if (!profile) { + return null; + } + + const options = client.getOptions(); + const sdk = client.getSdkMetadata()?.sdk; + return { + chunk_id: uuid4(), + profiler_id: profilerId, + client_sdk: { name: sdk?.name ?? '', version: sdk?.version ?? '' }, + platform: 'javascript', + version: '2', + release: options.release ?? '', + environment: options.environment ?? getDefaultEnvironment(), + debug_meta: { images: getDebugMetadata() }, + profile: { + frames: profile.frames, + stacks: profile.stacks, + samples: profile.samples.map(sample => ({ + stack_id: sample.stack_id, + thread_id: HERMES_CHUNK_THREAD_ID, + timestamp: startTimestampMs / 1e3 + Number(sample.elapsed_since_start_ns) / 1e9, + })), + thread_metadata: { [HERMES_CHUNK_THREAD_ID]: profile.thread_metadata[profile.active_thread_id] ?? {} }, + }, + }; +} diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index b671cb5a41..47acf7d864 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -1,11 +1,24 @@ -import type { Event, Integration, Span } from '@sentry/core'; +import type { Client, Event, Integration, ProfileChunk, ProfileChunkEnvelope, Span } from '@sentry/core'; -import { debug, getRootSpan, hasSpansEnabled, spanIsSampled, spanToJSON } from '@sentry/core'; +import { + createEnvelope, + debug, + getRootSpan, + getSdkMetadataForEnvelopeHeader, + hasSpansEnabled, + spanIsSampled, + spanToJSON, + uuid4, +} from '@sentry/core'; +import { Platform } from 'react-native'; import type { ReactNativeClient } from '../client'; import type { ProfilingLifecycle, ProfilingOptions, ReactNativeClientOptions } from '../options'; +import type * as Hermes from './hermes'; +import { isHermesEnabled } from '../utils/environment'; import { NATIVE } from '../wrapper'; +import { createHermesProfileChunk, HERMES_CHUNK_THREAD_ID } from './hermesProfileChunk'; // `Sentry.profiler` from `@sentry/core` looks the integration up by this name. export const UI_PROFILING_INTEGRATION_NAME = 'ProfilingIntegration'; @@ -19,6 +32,12 @@ const SPAN_THREAD_ID = 'thread.id'; */ export const TRACE_STOP_DELAY_MS = 60_000; +/** Matches the chunk duration of the native profilers. */ +export const HERMES_CHUNK_DURATION_MS = 60_000; + +/** sentry-java uses the Perfetto profiler from API 35. vroom can merge only its sample format with Hermes chunks. */ +const MIN_HERMES_CHUNK_ANDROID_API = 35; + interface UIProfilingIntegration extends Integration { _profiler: { start: () => void; @@ -30,6 +49,11 @@ interface ProfileSession { profilerId: Promise; } +interface HermesChunk { + profilerId: string; + startTimestampMs: number; +} + /** Returns the UI profiling options, including the deprecated `androidProfilingOptions`. */ export function getProfilingOptions(options: ReactNativeClientOptions): ProfilingOptions | undefined { return options._experiments?.profilingOptions ?? options._experiments?.androidProfilingOptions; @@ -51,10 +75,15 @@ export const uiProfilingIntegration = (): Integration => { let session: ProfileSession | undefined; let stopTimeout: ReturnType | undefined; let isNativeReady = false; + let isClosed = false; let isManualRequested = false; let isAppStartPending = false; let jsThreadId: string | null = null; let resolveAppStartSession: ((profilerId: string | null) => void) | undefined; + let profilingClient: Client | undefined; + let sendsHermesChunks = false; + let hermesChunk: HermesChunk | undefined; + let hermesChunkTimeout: ReturnType | undefined; const shouldRun = (): boolean => lifecycle === 'trace' ? isAppStartPending || activeRootSpanIds.size > 0 : isManualRequested; @@ -64,14 +93,86 @@ export const uiProfilingIntegration = (): Integration => { stopTimeout = undefined; }; + const startHermesChunk = (profilerId: string): void => { + try { + if (!NATIVE.startProfiling(false)) { + return; + } + } catch (error) { + debug.warn('[Profiling] Failed to start the Hermes profiler.', error); + return; + } + hermesChunk = { profilerId, startTimestampMs: Date.now() }; + hermesChunkTimeout = setTimeout(rotateHermesChunk, HERMES_CHUNK_DURATION_MS); + }; + + const rotateHermesChunk = (): void => { + const profilerId = hermesChunk?.profilerId; + if (profilerId) { + collectHermesChunk(); + startHermesChunk(profilerId); + } + }; + + const stopHermesSampler = (): { chunk: HermesChunk; hermesProfile: Hermes.Profile } | undefined => { + clearTimeout(hermesChunkTimeout); + hermesChunkTimeout = undefined; + const chunk = hermesChunk; + hermesChunk = undefined; + if (!chunk) { + return undefined; + } + + try { + const profiles = NATIVE.stopProfiling(); + return profiles ? { chunk, hermesProfile: profiles.hermesProfile } : undefined; + } catch (error) { + debug.warn('[Profiling] Failed to stop the Hermes profiler.', error); + return undefined; + } + }; + + const collectHermesChunk = (): void => { + const stopped = stopHermesSampler(); + if (!stopped || !profilingClient) { + return; + } + const { chunk, hermesProfile } = stopped; + const profileChunk = createHermesProfileChunk( + hermesProfile, + chunk.startTimestampMs, + chunk.profilerId, + profilingClient, + ); + if (profileChunk) { + sendProfileChunk(profilingClient, profileChunk); + } + }; + + const startHermesChunks = (newSession: ProfileSession): void => { + if (!sendsHermesChunks) { + return; + } + newSession.profilerId.then( + profilerId => { + // The native SDK returns no id when it does not profile this session. + if (profilerId && session === newSession) { + startHermesChunk(profilerId); + } + }, + () => {}, + ); + }; + const stopNativeProfiler = (): void => { clearStopTimeout(); + collectHermesChunk(); session = undefined; NATIVE.stopProfiler().then(undefined, () => {}); }; const syncNativeProfiler = (): void => { - if (!isNativeReady) { + if (!isNativeReady || isClosed) { return; } @@ -80,6 +181,7 @@ export const uiProfilingIntegration = (): Integration => { if (!session) { const newSession: ProfileSession = { profilerId: NATIVE.startProfiler() }; session = newSession; + startHermesChunks(newSession); activeRootSpanIds.forEach(spanId => { // A root span that started under an earlier session keeps that profile. if (!sessionByRootSpanId.has(spanId)) { @@ -133,6 +235,7 @@ export const uiProfilingIntegration = (): Integration => { const integration: UIProfilingIntegration = { name: UI_PROFILING_INTEGRATION_NAME, setup(client: ReactNativeClient): void { + profilingClient = client; const profilingOptions = getProfilingOptions(client.getOptions()); if (!profilingOptions) { debug.warn('[Profiling] `_experiments.profilingOptions` is not set. UI profiling is disabled.'); @@ -145,9 +248,16 @@ export const uiProfilingIntegration = (): Integration => { isManualRequested = lifecycle === 'manual' && startOnAppStart; isAppStartPending = lifecycle === 'trace' && startOnAppStart; + sendsHermesChunks = + Platform.OS === 'android' && + Number(Platform.Version) >= MIN_HERMES_CHUNK_ANDROID_API && + isHermesEnabled() && + // The transaction profiler uses the same Hermes sampler. + !((client.getOptions().profilesSampleRate ?? 0) > 0); + const tracingEnabled = hasSpansEnabled(client.getOptions()); if (tracingEnabled) { - jsThreadId = NATIVE.getCurrentThreadId(); + jsThreadId = sendsHermesChunks ? HERMES_CHUNK_THREAD_ID : NATIVE.getCurrentThreadId(); client.on('spanStart', onSpanStart); client.on('spanEnd', onSpanEnd); } else if (lifecycle === 'trace') { @@ -163,9 +273,13 @@ export const uiProfilingIntegration = (): Integration => { resolveAppStartSession = resolve; }), }; + startHermesChunks(session); } client.on('afterInit', () => { + if (isClosed) { + return; + } isNativeReady = true; const resolve = resolveAppStartSession; resolveAppStartSession = undefined; @@ -179,9 +293,19 @@ export const uiProfilingIntegration = (): Integration => { syncNativeProfiler(); }); + // `close` flushes before it disables the client, so the last chunk is sent here. + client.on('flush', rotateHermesChunk); + client.on('close', () => { + isClosed = true; clearStopTimeout(); + stopHermesSampler(); resolveAppStartSession?.(null); + resolveAppStartSession = undefined; + if (session) { + session = undefined; + NATIVE.stopProfiler().then(undefined, () => {}); + } }); }, processEvent(event: Event): Event | PromiseLike { @@ -212,3 +336,14 @@ export const uiProfilingIntegration = (): Integration => { }; return integration; }; + +function sendProfileChunk(client: Client, chunk: ProfileChunk): void { + const sdk = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata()); + const envelope = createEnvelope( + { event_id: uuid4(), sent_at: new Date().toISOString(), ...(sdk && { sdk }) }, + [[{ type: 'profile_chunk', platform: 'javascript' }, chunk]], + ); + client.sendEnvelope(envelope).then(null, error => { + debug.error('[Profiling] Failed to send the Hermes profile chunk.', error); + }); +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 201398ca4c..a64f00179e 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -74,6 +74,7 @@ interface SentryNativeWrapper { _NativeClientError: Error; _DisabledNativeError: Error; + _platformProfilers: boolean; _setPrimitiveProcessor: (processor: (value: Primitive) => void) => void; _processItem(envelopeItem: EnvelopeItem): EnvelopeItem; @@ -826,6 +827,7 @@ export const NATIVE: SentryNativeWrapper = { throw this._NativeClientError; } + this._platformProfilers = platformProfilers; const { started, error } = RNSentry.startProfiling(platformProfilers); if (started) { debug.log('[NATIVE] Start Profiling'); @@ -891,10 +893,10 @@ export const NATIVE: SentryNativeWrapper = { debug.error('[NATIVE] Stop Profiling Failed', error); return null; } - if (Platform.OS === 'ios' && !nativeProfile) { + if (this._platformProfilers && Platform.OS === 'ios' && !nativeProfile) { debug.warn('[NATIVE] Stop Profiling Failed: No Native Profile'); } - if (Platform.OS === 'android' && !androidProfile) { + if (this._platformProfilers && Platform.OS === 'android' && !androidProfile) { debug.warn('[NATIVE] Stop Profiling Failed: No Android Profile'); } @@ -1214,6 +1216,7 @@ export const NATIVE: SentryNativeWrapper = { }, _DisabledNativeError: new SentryError('Native is disabled'), + _platformProfilers: false, _NativeClientError: new SentryError("Native Client is not available, can't start on native."), diff --git a/packages/core/test/profiling/hermesProfileChunk.test.ts b/packages/core/test/profiling/hermesProfileChunk.test.ts new file mode 100644 index 0000000000..d63b55018e --- /dev/null +++ b/packages/core/test/profiling/hermesProfileChunk.test.ts @@ -0,0 +1,74 @@ +import { createHermesProfileChunk, HERMES_CHUNK_THREAD_ID } from '../../src/js/profiling/hermesProfileChunk'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +import { createMockMinimalValidHermesProfile } from './fixtures'; + +describe('createHermesProfileChunk', () => { + it('creates a JavaScript profile chunk for the profiler id', () => { + const client = new TestClient(getDefaultTestClientOptions({ release: 'app@1.0.0', environment: 'staging' })); + + const chunk = createHermesProfileChunk( + createMockMinimalValidHermesProfile(), + 1_700_000_000_000, + '0123456789abcdef0123456789abcdef', + client, + ); + + expect(chunk).toMatchObject({ + chunk_id: expect.stringMatching(/^[0-9a-f]{32}$/), + profiler_id: '0123456789abcdef0123456789abcdef', + platform: 'javascript', + version: '2', + release: 'app@1.0.0', + environment: 'staging', + profile: { + frames: [{ function: '[root]', in_app: false }], + stacks: [[0]], + thread_metadata: { [HERMES_CHUNK_THREAD_ID]: { name: 'JavaScriptThread', priority: 1 } }, + }, + }); + }); + + it('places the samples after the start timestamp on the Hermes chunk thread', () => { + const client = new TestClient(getDefaultTestClientOptions()); + + const chunk = createHermesProfileChunk( + createMockMinimalValidHermesProfile(), + 1_700_000_000_000, + '0123456789abcdef0123456789abcdef', + client, + ); + + expect(chunk?.profile.samples).toEqual([ + { stack_id: 0, thread_id: HERMES_CHUNK_THREAD_ID, timestamp: 1_700_000_000 }, + { stack_id: 0, thread_id: HERMES_CHUNK_THREAD_ID, timestamp: 1_700_000_000.00001 }, + ]); + }); + + it('keeps samples after the transaction profile duration limit', () => { + const client = new TestClient(getDefaultTestClientOptions()); + const hermesProfile = createMockMinimalValidHermesProfile(); + hermesProfile.samples[1]!.ts = '45000010'; + + const chunk = createHermesProfileChunk( + hermesProfile, + 1_700_000_000_000, + '0123456789abcdef0123456789abcdef', + client, + ); + + expect(chunk?.profile.samples[1]?.timestamp).toBe(1_700_000_045); + }); + + it('returns null for a profile without samples', () => { + const client = new TestClient(getDefaultTestClientOptions()); + + const chunk = createHermesProfileChunk( + { samples: [], stackFrames: {}, traceEvents: [] }, + 1_700_000_000_000, + '0123456789abcdef0123456789abcdef', + client, + ); + + expect(chunk).toBeNull(); + }); +}); diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index 29694e4536..a1e7614f53 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -1,4 +1,4 @@ -import type { Event } from '@sentry/core'; +import type { Envelope, Event } from '@sentry/core'; import { debug, @@ -9,18 +9,30 @@ import { startInactiveSpan, startSpan, } from '@sentry/core'; +import { Platform } from 'react-native'; import type { ProfilingOptions } from '../../src/js/options'; -import { TRACE_STOP_DELAY_MS, uiProfilingIntegration } from '../../src/js/profiling/uiProfilingIntegration'; +import { HERMES_CHUNK_THREAD_ID } from '../../src/js/profiling/hermesProfileChunk'; +import { + HERMES_CHUNK_DURATION_MS, + TRACE_STOP_DELAY_MS, + uiProfilingIntegration, +} from '../../src/js/profiling/uiProfilingIntegration'; +import { isHermesEnabled } from '../../src/js/utils/environment'; import { getDefaultTestClientOptions, setupTestClient, TestClient } from '../mocks/client'; import { NATIVE } from '../mockWrapper'; +import { createMockMinimalValidHermesProfile } from './fixtures'; jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); +jest.mock('../../src/js/utils/environment', () => ({ + ...jest.requireActual('../../src/js/utils/environment'), + isHermesEnabled: jest.fn(() => false), +})); function setupClient( profilingOptions: ProfilingOptions, - clientOptions: { tracesSampleRate?: number } = {}, + clientOptions: { tracesSampleRate?: number; profilesSampleRate?: number } = {}, ): TestClient { return setupTestClient({ ...clientOptions, @@ -41,6 +53,7 @@ describe('uiProfilingIntegration', () => { afterEach(() => { jest.useRealTimers(); jest.clearAllMocks(); + jest.restoreAllMocks(); getCurrentScope().setClient(undefined); }); @@ -231,6 +244,40 @@ describe('uiProfilingIntegration', () => { expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); }); + it('stops the native profiler when the client closes', async () => { + const client = setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + client.emit('close'); + + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['a manual start', { lifecycle: 'manual' }], + ['the app start profile', { lifecycle: 'manual', startOnAppStart: true }], + ] as const)( + 'does not start the native profiler for %s when native init ends after close', + (_, profilingOptions) => { + const client = new TestClient( + getDefaultTestClientOptions({ + _experiments: { profilingOptions }, + integrations: [uiProfilingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + profiler.startProfiler(); + client.emit('close'); + // @ts-expect-error Only available on ReactNativeClient + client.emit('afterInit'); + + expect(NATIVE.startProfiler).not.toHaveBeenCalled(); + }, + ); + it('waits for the native SDK before it starts the profiler', () => { const client = new TestClient( getDefaultTestClientOptions({ @@ -279,4 +326,156 @@ describe('uiProfilingIntegration', () => { expect(getTransaction(client, 'root')?.contexts?.profile).toEqual({ profiler_id: 'first-profiler-id' }); }); }); + + describe('Hermes profile chunks', () => { + const PROFILER_ID = '0123456789abcdef0123456789abcdef'; + + function getSentChunkItems(sendEnvelope: jest.SpyInstance): unknown[] { + return sendEnvelope.mock.calls.map(([envelope]: [Envelope]) => envelope[1][0]); + } + + beforeEach(() => { + jest.replaceProperty(Platform, 'OS', 'android'); + jest.spyOn(Platform, 'Version', 'get').mockReturnValue(35); + (isHermesEnabled as jest.Mock).mockReturnValue(true); + NATIVE.startProfiler.mockResolvedValue(PROFILER_ID); + NATIVE.startProfiling.mockReturnValue(true); + NATIVE.stopProfiling.mockReturnValue({ hermesProfile: createMockMinimalValidHermesProfile() }); + }); + + afterEach(() => { + (isHermesEnabled as jest.Mock).mockReturnValue(false); + NATIVE.startProfiler.mockResolvedValue(null); + NATIVE.startProfiling.mockReturnValue(false); + NATIVE.stopProfiling.mockReturnValue(null); + }); + + it('sends the Hermes samples of a native profile session as a profile chunk', async () => { + const client = setupClient({ lifecycle: 'manual' }); + const sendEnvelope = jest.spyOn(client, 'sendEnvelope'); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + profiler.stopProfiler(); + + expect(NATIVE.startProfiling).toHaveBeenCalledExactlyOnceWith(false); + expect(NATIVE.stopProfiling).toHaveBeenCalledTimes(1); + expect(getSentChunkItems(sendEnvelope)).toEqual([ + [ + { type: 'profile_chunk', platform: 'javascript' }, + expect.objectContaining({ profiler_id: PROFILER_ID, platform: 'javascript' }), + ], + ]); + }); + + it('sends a chunk and restarts the Hermes sampler after each chunk duration', async () => { + const client = setupClient({ lifecycle: 'manual' }); + const sendEnvelope = jest.spyOn(client, 'sendEnvelope'); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(HERMES_CHUNK_DURATION_MS); + + expect(NATIVE.stopProfiling).toHaveBeenCalledTimes(1); + expect(NATIVE.startProfiling).toHaveBeenCalledTimes(2); + expect(sendEnvelope).toHaveBeenCalledTimes(1); + }); + + it('starts the Hermes sampler for the app start profile after native init', async () => { + setupClient({ lifecycle: 'manual', startOnAppStart: true }); + + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiling).toHaveBeenCalledExactlyOnceWith(false); + }); + + it('does not start the Hermes sampler when the native SDK does not profile the session', async () => { + NATIVE.startProfiler.mockResolvedValue(null); + setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiling).not.toHaveBeenCalled(); + }); + + it('does not start the Hermes sampler when the session stops before the native start resolves', async () => { + setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + profiler.stopProfiler(); + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiling).not.toHaveBeenCalled(); + }); + + it('does not start the Hermes sampler when the client closes before the native start resolves', async () => { + const client = setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + client.emit('close'); + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiling).not.toHaveBeenCalled(); + }); + + it('sends the current chunk and restarts the Hermes sampler on flush', async () => { + const client = setupClient({ lifecycle: 'manual' }); + const sendEnvelope = jest.spyOn(client, 'sendEnvelope'); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + client.emit('flush'); + + expect(NATIVE.stopProfiling).toHaveBeenCalledTimes(1); + expect(NATIVE.startProfiling).toHaveBeenCalledTimes(2); + expect(sendEnvelope).toHaveBeenCalledTimes(1); + }); + + it('sends the last chunk and stops the Hermes sampler when the client closes', async () => { + jest.useRealTimers(); + const client = setupClient({ lifecycle: 'manual' }); + const sendEnvelope = jest.spyOn(client, 'sendEnvelope'); + + profiler.startProfiler(); + await Promise.resolve(); + await client.close(); + + expect(sendEnvelope).toHaveBeenCalledTimes(1); + expect(NATIVE.startProfiling).toHaveBeenCalledTimes(2); + expect(NATIVE.stopProfiling).toHaveBeenCalledTimes(2); + }); + + it('starts the Hermes sampler when transaction profiling has a zero sample rate', async () => { + setupClient({ lifecycle: 'manual' }, { profilesSampleRate: 0 }); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiling).toHaveBeenCalledExactlyOnceWith(false); + }); + + it('adds the Hermes chunk thread id to spans', () => { + setupClient({ lifecycle: 'trace' }); + + const span = startInactiveSpan({ name: 'root' }); + + expect(spanToJSON(span).data['thread.id']).toBe(HERMES_CHUNK_THREAD_ID); + }); + + it.each([ + ['Android API 34', () => jest.spyOn(Platform, 'Version', 'get').mockReturnValue(34), {}], + ['iOS', () => jest.replaceProperty(Platform, 'OS', 'ios'), {}], + ['JSC', () => (isHermesEnabled as jest.Mock).mockReturnValue(false), {}], + ['transaction profiling', () => {}, { profilesSampleRate: 1 }], + ])('does not start the Hermes sampler on %s', async (_, arrange, clientOptions) => { + arrange(); + setupClient({ lifecycle: 'manual' }, clientOptions); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.startProfiling).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index c0c50b8be7..e4c9add567 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -1341,6 +1341,34 @@ describe('Tests Native Wrapper', () => { }); expect(NATIVE.stopProfiling()).toBe(null); }); + test('stop profiling warns about a missing native profile when platform profilers were started', () => { + const warn = jest.spyOn(debug, 'warn'); + (RNSentry.startProfiling as jest.MockedFunction).mockReturnValue({ + started: true, + }); + (RNSentry.stopProfiling as jest.MockedFunction).mockReturnValue({ + profile: '{ "valid": "hermes" }', + }); + + NATIVE.startProfiling(true); + NATIVE.stopProfiling(); + + expect(warn).toHaveBeenCalledWith('[NATIVE] Stop Profiling Failed: No Native Profile'); + }); + test('stop profiling does not warn about a missing native profile when only Hermes was started', () => { + const warn = jest.spyOn(debug, 'warn'); + (RNSentry.startProfiling as jest.MockedFunction).mockReturnValue({ + started: true, + }); + (RNSentry.stopProfiling as jest.MockedFunction).mockReturnValue({ + profile: '{ "valid": "hermes" }', + }); + + NATIVE.startProfiling(false); + NATIVE.stopProfiling(); + + expect(warn).not.toHaveBeenCalledWith('[NATIVE] Stop Profiling Failed: No Native Profile'); + }); test('stop profiling returns null on invalid json profile', () => { (RNSentry.stopProfiling as jest.MockedFunction).mockReturnValue({ profile: 'invalid',