From 05ca632ffe9e93e68adee16289a7b8844876fead Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 29 Oct 2026 14:27:58 +0100 Subject: [PATCH 1/6] feat(profiling): Send Hermes samples as UI profile chunks on Android UI profiling on Android showed only native frames, so the profile of a transaction had no JavaScript functions. The integration now runs the Hermes sampler while the native profiler session runs. Every 60 seconds, and when the session stops, it sends the samples as a JavaScript profile_chunk with the native profiler_id. Sentry merges all chunks of a profiler_id into one profile. The feature is limited to Android API 35 and newer. Older devices use the legacy profiler, whose chunks vroom cannot merge with sample-format chunks (it returns HTTP 400). iOS needs the profiler id from sentry-cocoa first. The native Perfetto profiler samples the JS thread under its OS thread id too. Hermes uses the same id, so the Hermes samples get their own thread id (2^32) and spans point to it. Hermes timestamps use a monotonic clock, so each chunk is anchored to the Unix time when the sampler started. The sampler starts only after the native SDK returns a profiler id. It does not run when the session is not sampled or when transaction profiling (profilesSampleRate) uses the sampler. NATIVE.stopProfiling no longer warns about a missing native profile when only Hermes was started. --- CHANGELOG.md | 5 + .../src/js/profiling/convertHermesProfile.ts | 7 +- .../src/js/profiling/hermesProfileChunk.ts | 61 +++++++++ .../js/profiling/uiProfilingIntegration.ts | 111 +++++++++++++++- packages/core/src/js/wrapper.ts | 7 +- .../test/profiling/hermesProfileChunk.test.ts | 74 +++++++++++ .../profiling/uiProfilingIntegration.test.ts | 125 +++++++++++++++++- packages/core/test/wrapper.test.ts | 28 ++++ 8 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/js/profiling/hermesProfileChunk.ts create mode 100644 packages/core/test/profiling/hermesProfileChunk.test.ts 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..01797d8aa5 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -1,11 +1,23 @@ -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 { 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 +31,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 +48,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; @@ -55,6 +78,10 @@ export const uiProfilingIntegration = (): Integration => { 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,8 +91,64 @@ 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(() => { + collectHermesChunk(); + startHermesChunk(profilerId); + }, HERMES_CHUNK_DURATION_MS); + }; + + const collectHermesChunk = (): void => { + clearTimeout(hermesChunkTimeout); + hermesChunkTimeout = undefined; + const chunk = hermesChunk; + hermesChunk = undefined; + if (!chunk || !profilingClient) { + return; + } + + let profiles: ReturnType; + try { + profiles = NATIVE.stopProfiling(); + } catch (error) { + debug.warn('[Profiling] Failed to stop the Hermes profiler.', error); + return; + } + const profileChunk = + profiles && + createHermesProfileChunk(profiles.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, () => {}); }; @@ -80,6 +163,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 +217,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 +230,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 === undefined; + 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,6 +255,7 @@ export const uiProfilingIntegration = (): Integration => { resolveAppStartSession = resolve; }), }; + startHermesChunks(session); } client.on('afterInit', () => { @@ -181,6 +274,7 @@ export const uiProfilingIntegration = (): Integration => { client.on('close', () => { clearStopTimeout(); + collectHermesChunk(); resolveAppStartSession?.(null); }); }, @@ -212,3 +306,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..e6a489bae5 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); }); @@ -279,4 +292,110 @@ 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('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', From 5f441b465845e8e377971fab1ca9be605caa8343 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 29 Oct 2026 15:02:56 +0100 Subject: [PATCH 2/6] fix(profiling): Don't start the Hermes sampler after the client closes The close handler collected the current Hermes chunk but kept the profile session. If NATIVE.startProfiler() resolved after close, the profiler id passed the session check and started the Hermes sampler and its 60 second restart timer again. The close handler now clears the session, so a late profiler id is ignored. --- .../core/src/js/profiling/uiProfilingIntegration.ts | 1 + .../core/test/profiling/uiProfilingIntegration.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index 01797d8aa5..3a6c8c71dc 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -275,6 +275,7 @@ export const uiProfilingIntegration = (): Integration => { client.on('close', () => { clearStopTimeout(); collectHermesChunk(); + session = undefined; resolveAppStartSession?.(null); }); }, diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index e6a489bae5..3ad01bc522 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -374,6 +374,16 @@ describe('uiProfilingIntegration', () => { 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('adds the Hermes chunk thread id to spans', () => { setupClient({ lifecycle: 'trace' }); From 142f706addc983abf2d092f766fe8a7bd626ee2c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 29 Oct 2026 16:13:33 +0100 Subject: [PATCH 3/6] fix(profiling): Send Hermes chunks when profilesSampleRate is 0 The UI profiling integration turned off Hermes chunks whenever profilesSampleRate was set, also when it was 0. At rate 0 the transaction profiler never starts the Hermes sampler, and the React Native SDK does not pass profilesSampleRate to sentry-java, so the native UI profiler still runs. Hermes chunks are now turned off only for a positive profilesSampleRate. --- packages/core/src/js/profiling/uiProfilingIntegration.ts | 2 +- .../core/test/profiling/uiProfilingIntegration.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index 3a6c8c71dc..b019332a67 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -235,7 +235,7 @@ export const uiProfilingIntegration = (): Integration => { Number(Platform.Version) >= MIN_HERMES_CHUNK_ANDROID_API && isHermesEnabled() && // The transaction profiler uses the same Hermes sampler. - client.getOptions().profilesSampleRate === undefined; + !((client.getOptions().profilesSampleRate ?? 0) > 0); const tracingEnabled = hasSpansEnabled(client.getOptions()); if (tracingEnabled) { diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index 3ad01bc522..c7bebe4ec2 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -384,6 +384,15 @@ describe('uiProfilingIntegration', () => { expect(NATIVE.startProfiling).not.toHaveBeenCalled(); }); + 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' }); From 027481c67d7bea908b0f778a908b0b7d6984ecce Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 29 Oct 2026 16:55:42 +0100 Subject: [PATCH 4/6] fix(profiling): Only stop the Hermes sampler when the client closes @sentry/core emits the close hook after it disables the client, so sendEnvelope drops anything sent from there. The close handler built and sent the last Hermes chunk anyway, which only wasted a conversion. It now stops the Hermes sampler without building a chunk. Stopping and sending are split into stopHermesSampler and collectHermesChunk for this. --- .../js/profiling/uiProfilingIntegration.ts | 31 +++++++++++++------ .../profiling/uiProfilingIntegration.test.ts | 12 +++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index b019332a67..f5f6b86c6c 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -14,6 +14,7 @@ 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'; @@ -107,25 +108,36 @@ export const uiProfilingIntegration = (): Integration => { }, HERMES_CHUNK_DURATION_MS); }; - const collectHermesChunk = (): void => { + const stopHermesSampler = (): { chunk: HermesChunk; hermesProfile: Hermes.Profile } | undefined => { clearTimeout(hermesChunkTimeout); hermesChunkTimeout = undefined; const chunk = hermesChunk; hermesChunk = undefined; - if (!chunk || !profilingClient) { - return; + if (!chunk) { + return undefined; } - let profiles: ReturnType; try { - profiles = NATIVE.stopProfiling(); + 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 profileChunk = - profiles && - createHermesProfileChunk(profiles.hermesProfile, chunk.startTimestampMs, chunk.profilerId, profilingClient); + const { chunk, hermesProfile } = stopped; + const profileChunk = createHermesProfileChunk( + hermesProfile, + chunk.startTimestampMs, + chunk.profilerId, + profilingClient, + ); if (profileChunk) { sendProfileChunk(profilingClient, profileChunk); } @@ -274,7 +286,8 @@ export const uiProfilingIntegration = (): Integration => { client.on('close', () => { clearStopTimeout(); - collectHermesChunk(); + // The client is already disabled here, so it would drop the last chunk. + stopHermesSampler(); session = undefined; resolveAppStartSession?.(null); }); diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index c7bebe4ec2..1fae675d59 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -384,6 +384,18 @@ describe('uiProfilingIntegration', () => { expect(NATIVE.startProfiling).not.toHaveBeenCalled(); }); + it('stops the Hermes sampler without sending a chunk when the client closes', async () => { + const client = setupClient({ lifecycle: 'manual' }); + const sendEnvelope = jest.spyOn(client, 'sendEnvelope'); + + profiler.startProfiler(); + await jest.advanceTimersByTimeAsync(0); + client.emit('close'); + + expect(NATIVE.stopProfiling).toHaveBeenCalledTimes(1); + expect(sendEnvelope).not.toHaveBeenCalled(); + }); + it('starts the Hermes sampler when transaction profiling has a zero sample rate', async () => { setupClient({ lifecycle: 'manual' }, { profilesSampleRate: 0 }); From 39616cf21ef2635d3c8647dfef24fd28b5e09fc8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 30 Oct 2026 10:01:59 +0100 Subject: [PATCH 5/6] fix(profiling): Send the current Hermes chunk when the client flushes The last Hermes chunk was lost when the client closed, because @sentry/core disables the client before it emits close. Client.close() flushes first, and flush emits its hook while the client is still enabled and before the transport drains. The integration now sends the current chunk and restarts the sampler on flush, so close sends the last chunk, and Sentry.flush() and the fatal error handler send the JS samples collected so far. The close handler only stops the sampler. --- .../js/profiling/uiProfilingIntegration.ts | 13 +++++++++--- .../profiling/uiProfilingIntegration.test.ts | 21 ++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index f5f6b86c6c..8ae03afa9e 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -102,10 +102,15 @@ export const uiProfilingIntegration = (): Integration => { return; } hermesChunk = { profilerId, startTimestampMs: Date.now() }; - hermesChunkTimeout = setTimeout(() => { + hermesChunkTimeout = setTimeout(rotateHermesChunk, HERMES_CHUNK_DURATION_MS); + }; + + const rotateHermesChunk = (): void => { + const profilerId = hermesChunk?.profilerId; + if (profilerId) { collectHermesChunk(); startHermesChunk(profilerId); - }, HERMES_CHUNK_DURATION_MS); + } }; const stopHermesSampler = (): { chunk: HermesChunk; hermesProfile: Hermes.Profile } | undefined => { @@ -284,9 +289,11 @@ 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', () => { clearStopTimeout(); - // The client is already disabled here, so it would drop the last chunk. stopHermesSampler(); session = undefined; resolveAppStartSession?.(null); diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index 1fae675d59..7985b13b8e 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -384,16 +384,31 @@ describe('uiProfilingIntegration', () => { expect(NATIVE.startProfiling).not.toHaveBeenCalled(); }); - it('stops the Hermes sampler without sending a chunk when the client closes', async () => { + 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('close'); + client.emit('flush'); expect(NATIVE.stopProfiling).toHaveBeenCalledTimes(1); - expect(sendEnvelope).not.toHaveBeenCalled(); + 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 () => { From 651e78835065458c0934c5a8bdc48f1e072bc1b1 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 4 Nov 2026 08:30:01 +0100 Subject: [PATCH 6/6] fix(profiling): Stop the native UI profiler and stay stopped after close The close handler cleared the profile session without calling NATIVE.stopProfiler(), so a running native session was no longer tracked and Sentry.profiler.stopProfiler() could not stop it. It now stops the native profiler when a session runs. Close also did not make the integration final. If native init finished after close, afterInit still called NATIVE.startProfiler() for the app start profile, or synced a pending manual start and started a new session. The integration now ignores afterInit and does not sync the native profiler after close, and close clears the app start resolver. --- .../js/profiling/uiProfilingIntegration.ts | 13 +++++-- .../profiling/uiProfilingIntegration.test.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index 8ae03afa9e..47acf7d864 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -75,6 +75,7 @@ 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; @@ -171,7 +172,7 @@ export const uiProfilingIntegration = (): Integration => { }; const syncNativeProfiler = (): void => { - if (!isNativeReady) { + if (!isNativeReady || isClosed) { return; } @@ -276,6 +277,9 @@ export const uiProfilingIntegration = (): Integration => { } client.on('afterInit', () => { + if (isClosed) { + return; + } isNativeReady = true; const resolve = resolveAppStartSession; resolveAppStartSession = undefined; @@ -293,10 +297,15 @@ export const uiProfilingIntegration = (): Integration => { client.on('flush', rotateHermesChunk); client.on('close', () => { + isClosed = true; clearStopTimeout(); stopHermesSampler(); - session = undefined; resolveAppStartSession?.(null); + resolveAppStartSession = undefined; + if (session) { + session = undefined; + NATIVE.stopProfiler().then(undefined, () => {}); + } }); }, processEvent(event: Event): Event | PromiseLike { diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index 7985b13b8e..a1e7614f53 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -244,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({