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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
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] ?? {} },
},
};
}
143 changes: 139 additions & 4 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 @@
*/
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 @@
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 @@ -51,10 +75,15 @@
let session: ProfileSession | undefined;
let stopTimeout: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | undefined;

const shouldRun = (): boolean =>
lifecycle === 'trace' ? isAppStartPending || activeRootSpanIds.size > 0 : isManualRequested;
Expand All @@ -64,14 +93,86 @@
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;
}

Expand All @@ -80,6 +181,7 @@
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 +235,7 @@
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 +248,16 @@
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,9 +273,13 @@
resolveAppStartSession = resolve;
}),
};
startHermesChunks(session);
}

client.on('afterInit', () => {
if (isClosed) {
return;
}
isNativeReady = true;
const resolve = resolveAppStartSession;
resolveAppStartSession = undefined;
Expand All @@ -179,9 +293,19 @@
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();

Check warning on line 302 in packages/core/src/js/profiling/uiProfilingIntegration.ts

View check run for this annotation

@sentry/warden / warden: code-review

Flush handler restarts Hermes during client close

`client.on('flush', rotateHermesChunk)` restarts the Hermes sampler on close (close flushes first); the close handler then stops and discards that new session. Collect the final chunk on flush without restarting during shutdown so teardown does not call `startProfiling` again.
Comment on lines +297 to +302

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.

Flush handler restarts Hermes during client close

client.on('flush', rotateHermesChunk) restarts the Hermes sampler on close (close flushes first); the close handler then stops and discards that new session. Collect the final chunk on flush without restarting during shutdown so teardown does not call startProfiling again.

Evidence
  • rotateHermesChunk() always does collectHermesChunk() then startHermesChunk(profilerId).
  • Client.close() flushes before emit(close); the test expects startProfiling twice and stopProfiling twice on close.
  • The close handler calls stopHermesSampler() (discard, no send), so the post-flush restart is thrown away.
  • Past teardown issue: Hermes left running during RN teardown caused pthread_kill SIGABRT (fix(android): Stop Hermes profiler on React instance teardown #6035).

Identified by Warden · code-review · LXE-3WQ

resolveAppStartSession?.(null);
resolveAppStartSession = undefined;
if (session) {
session = undefined;
NATIVE.stopProfiler().then(undefined, () => {});
}
});
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
Comment thread
alwx marked this conversation as resolved.
Resolved
},
processEvent(event: Event): Event | PromiseLike<Event> {
Expand Down Expand Up @@ -212,3 +336,14 @@
};
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