Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## 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.

## 8.33.0

### Changes
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/js/profiling/convertHermesProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/js/profiling/hermesProfileChunk.ts
Original file line number Diff line number Diff line change
@@ -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] ?? {} },
},
};
}
132 changes: 129 additions & 3 deletions packages/core/src/js/profiling/uiProfilingIntegration.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -30,6 +49,11 @@ interface ProfileSession {
profilerId: Promise<string | null>;
}

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;
Expand All @@ -55,6 +79,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<typeof setTimeout> | undefined;

const shouldRun = (): boolean =>
lifecycle === 'trace' ? isAppStartPending || activeRootSpanIds.size > 0 : isManualRequested;
Expand All @@ -64,8 +92,80 @@ 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, () => {});
};
Expand All @@ -80,6 +180,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)) {
Expand Down Expand Up @@ -133,6 +234,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.');
Expand All @@ -145,9 +247,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') {
Expand All @@ -163,6 +272,7 @@ export const uiProfilingIntegration = (): Integration => {
resolveAppStartSession = resolve;
}),
};
startHermesChunks(session);
}

client.on('afterInit', () => {
Expand All @@ -179,8 +289,13 @@ 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();
stopHermesSampler();
session = undefined;
resolveAppStartSession?.(null);
});
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
Comment on lines 295 to 300

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final Hermes chunk send is dropped on client close

collectHermesChunk() on close calls client.sendEnvelope() after Client.close() sets enabled: false, so the final Hermes chunk is discarded. Send via the transport/NATIVE before native teardown, or stop Hermes without relying on the disabled client send path.

Evidence
  • Close handler calls collectHermesChunk() then sendProfileChunk() → client.sendEnvelope(envelope).
  • ReactNativeClient.close() awaits super.close() before NATIVE.closeNativeSdk(); core Client.close() flushes, sets enabled = false, then emits close.
  • ReactNativeClient.sendEnvelope() only sends when this._isEnabled() is true; otherwise it logs Transport disabled and returns.
  • Normal stopNativeProfiler() still sends while the client is enabled; only the close path loses the chunk.

Identified by Warden · find-bugs · RM2-25L

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: Client.close() sets enabled = false before it emits close, so the chunk was dropped. Fixed in d02c5d7. The close handler now only stops the Hermes sampler and does not build or send a chunk. There is no hook before the client is disabled, and apps rarely call Sentry.close(), so the SDK does not try to send the last partial chunk. Added a test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix attempt detected (commit d02c5d7)

The close handler now stops the Hermes sampler but discards the returned profile instead of sending it, so the final chunk is still lost.

The original issue appears unresolved. Please review and try again.

Evaluated by Warden

@alwx alwx Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

},
Expand Down Expand Up @@ -212,3 +327,14 @@ export const uiProfilingIntegration = (): Integration => {
};
return integration;
};

function sendProfileChunk(client: Client, chunk: ProfileChunk): void {
const sdk = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata());
const envelope = createEnvelope<ProfileChunkEnvelope>(
{ 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);
});
}
7 changes: 5 additions & 2 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface SentryNativeWrapper {

_NativeClientError: Error;
_DisabledNativeError: Error;
_platformProfilers: boolean;

_setPrimitiveProcessor: (processor: (value: Primitive) => void) => void;
_processItem(envelopeItem: EnvelopeItem): EnvelopeItem;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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."),

Expand Down
Loading
Loading