Repository navigation
feat(profiling): Send Hermes samples as UI profile chunks on Android #6966
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1c17e9c
a4c2f7f
13c7c2d
bef8dbf
d02c5d7
7837007
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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] ?? {} }, | ||
| }, | ||
| }; | ||
| } |
| 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'; | ||
|
|
@@ -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<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; | ||
|
|
@@ -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; | ||
|
|
@@ -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, () => {}); | ||
| }; | ||
|
|
@@ -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)) { | ||
|
|
@@ -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.'); | ||
|
|
@@ -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') { | ||
|
|
@@ -163,6 +272,7 @@ export const uiProfilingIntegration = (): Integration => { | |
| resolveAppStartSession = resolve; | ||
| }), | ||
| }; | ||
| startHermesChunks(session); | ||
| } | ||
|
|
||
| client.on('afterInit', () => { | ||
|
|
@@ -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 on lines
295
to
300
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Final Hermes chunk send is dropped on client close
Evidence
Identified by Warden · find-bugs · RM2-25L
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||
| }, | ||
|
|
@@ -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); | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.