From 4c601503bd0072195b91173d0ff6ccff56c78717 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 11:58:16 +0200 Subject: [PATCH 1/4] fix(profiling): Fix app start transaction profile timestamp offset Use the actual profiling start timestamp for the profile's timestamp field instead of the adjusted transaction start_timestamp. For app start transactions, the transaction start is adjusted backward to the app start time, but the profile samples are relative to when profiling actually started (JS VM start). This caused a misalignment in the Sentry UI. Fixes #4511 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++++ packages/core/src/js/profiling/integration.ts | 6 +++++- packages/core/src/js/profiling/types.ts | 2 ++ packages/core/src/js/profiling/utils.ts | 20 +++++++++++++++---- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6418b28f9a..2bdddb01f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Fixes + +- Fix app start transaction profile offset by using the actual profiling start timestamp instead of the adjusted app start time ([#4511](https://github.com/getsentry/sentry-react-native/issues/4511)) + ### Features - Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947)) diff --git a/packages/core/src/js/profiling/integration.ts b/packages/core/src/js/profiling/integration.ts index b5b13b2acb..819393bf34 100644 --- a/packages/core/src/js/profiling/integration.ts +++ b/packages/core/src/js/profiling/integration.ts @@ -279,9 +279,13 @@ export function stopProfiling( return null; } + hermesProfileEvent.profilingStartTimestampNs = profileStartTimestampNs; + if (collectedProfiles.androidProfile) { const durationNs = profileEndTimestampNs - profileStartTimestampNs; - return createAndroidWithHermesProfile(hermesProfileEvent, collectedProfiles.androidProfile, durationNs); + const androidProfile = createAndroidWithHermesProfile(hermesProfileEvent, collectedProfiles.androidProfile, durationNs); + androidProfile.profilingStartTimestampNs = profileStartTimestampNs; + return androidProfile; } else if (collectedProfiles.nativeProfile) { return addNativeProfileToHermesProfile(hermesProfileEvent, collectedProfiles.nativeProfile); } diff --git a/packages/core/src/js/profiling/types.ts b/packages/core/src/js/profiling/types.ts index 871c975403..3c12ea6c87 100644 --- a/packages/core/src/js/profiling/types.ts +++ b/packages/core/src/js/profiling/types.ts @@ -15,6 +15,7 @@ export type HermesProfileEvent = { transaction: { active_thread_id: string; }; + profilingStartTimestampNs?: number; }; /* @@ -31,6 +32,7 @@ export type AndroidCombinedProfileEvent = { android_api_level: number; duration_ns: string; active_thread_id: string; + profilingStartTimestampNs?: number; }; /* diff --git a/packages/core/src/js/profiling/utils.ts b/packages/core/src/js/profiling/utils.ts index 37556fb80f..cc4154ce3c 100644 --- a/packages/core/src/js/profiling/utils.ts +++ b/packages/core/src/js/profiling/utils.ts @@ -88,14 +88,20 @@ export function enrichCombinedProfileWithEventContext( } } + const { profilingStartTimestampNs, ...profileWithoutInternalFields } = profile; + return { - ...profile, + ...profileWithoutInternalFields, event_id: profile_id, runtime: { name: 'hermes', version: '', // TODO: get hermes version }, - timestamp: event.start_timestamp ? new Date(event.start_timestamp * 1000).toISOString() : new Date().toISOString(), + timestamp: profilingStartTimestampNs + ? new Date(profilingStartTimestampNs / 1e6).toISOString() + : event.start_timestamp + ? new Date(event.start_timestamp * 1000).toISOString() + : new Date().toISOString(), release: event.release || '', environment: event.environment || getDefaultEnvironment(), os: { @@ -130,8 +136,10 @@ export function enrichAndroidProfileWithEventContext( profile: AndroidCombinedProfileEvent, event: Event, ): AndroidProfileEvent | null { + const { profilingStartTimestampNs, ...profileWithoutInternalFields } = profile; + return { - ...profile, + ...profileWithoutInternalFields, debug_meta: { images: getDebugMetadata(), }, @@ -152,7 +160,11 @@ export function enrichAndroidProfileWithEventContext( profile_id, - timestamp: event.start_timestamp ? new Date(event.start_timestamp * 1000).toISOString() : new Date().toISOString(), + timestamp: profilingStartTimestampNs + ? new Date(profilingStartTimestampNs / 1e6).toISOString() + : event.start_timestamp + ? new Date(event.start_timestamp * 1000).toISOString() + : new Date().toISOString(), release: event.release || '', dist: event.dist || '', From fef6f6c83e6990f06448cefa35f976602d7036e1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 12:00:42 +0200 Subject: [PATCH 2/4] fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bdddb01f5..fdd7a88a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixes -- Fix app start transaction profile offset by using the actual profiling start timestamp instead of the adjusted app start time ([#4511](https://github.com/getsentry/sentry-react-native/issues/4511)) +- Fix app start transaction profile offset by using the actual profiling start timestamp instead of the adjusted app start time ([#5962](https://github.com/getsentry/sentry-react-native/issues/5962)) ### Features From 583b386c64a9b64e3e91d8cbffe40a154109177d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 12:08:06 +0200 Subject: [PATCH 3/4] test(profiling): Add tests for profile timestamp offset fix Add unit tests for enrichCombinedProfileWithEventContext and enrichAndroidProfileWithEventContext verifying that: - profilingStartTimestampNs is used for the profile timestamp when set - Falls back to event.start_timestamp when not set - profilingStartTimestampNs is stripped from serialized output Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/test/profiling/utils.test.ts | 146 +++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 packages/core/test/profiling/utils.test.ts diff --git a/packages/core/test/profiling/utils.test.ts b/packages/core/test/profiling/utils.test.ts new file mode 100644 index 0000000000..28e64f18f4 --- /dev/null +++ b/packages/core/test/profiling/utils.test.ts @@ -0,0 +1,146 @@ +jest.mock('../../src/js/utils/environment'); +jest.mock('../../src/js/profiling/debugid'); + +import type { Event } from '@sentry/core'; + +import { getDebugMetadata } from '../../src/js/profiling/debugid'; +import type { AndroidCombinedProfileEvent, CombinedProfileEvent } from '../../src/js/profiling/types'; +import { enrichAndroidProfileWithEventContext, enrichCombinedProfileWithEventContext } from '../../src/js/profiling/utils'; +import { getDefaultEnvironment } from '../../src/js/utils/environment'; +import { createMockMinimalValidAndroidProfile, createMockMinimalValidHermesProfileEvent } from './fixtures'; + +describe('enrichCombinedProfileWithEventContext', () => { + beforeEach(() => { + (getDefaultEnvironment as jest.Mock).mockReturnValue('production'); + (getDebugMetadata as jest.Mock).mockReturnValue([]); + }); + + function createMockEvent(overrides?: Partial): Event { + return { + event_id: 'test-event-id', + transaction: 'test-transaction', + release: 'test-release', + environment: 'test-env', + start_timestamp: 1000, + contexts: { + trace: { + trace_id: '12345678901234567890123456789012', + }, + os: { name: 'iOS', version: '17.0' }, + device: {}, + }, + ...overrides, + }; + } + + test('should use profilingStartTimestampNs for timestamp when available', () => { + const profilingStartTimestampNs = 1500 * 1e9; // 1500 seconds in ns + const profile: CombinedProfileEvent = { + ...createMockMinimalValidHermesProfileEvent(), + profilingStartTimestampNs, + }; + const event = createMockEvent({ start_timestamp: 1000 }); // earlier than profiling start + + const result = enrichCombinedProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result!.timestamp).toBe(new Date(profilingStartTimestampNs / 1e6).toISOString()); + // Should NOT use event.start_timestamp + expect(result!.timestamp).not.toBe(new Date(1000 * 1000).toISOString()); + }); + + test('should fall back to event.start_timestamp when profilingStartTimestampNs is not set', () => { + const profile: CombinedProfileEvent = createMockMinimalValidHermesProfileEvent(); + const event = createMockEvent({ start_timestamp: 1000 }); + + const result = enrichCombinedProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result!.timestamp).toBe(new Date(1000 * 1000).toISOString()); + }); + + test('should not include profilingStartTimestampNs in the output', () => { + const profile: CombinedProfileEvent = { + ...createMockMinimalValidHermesProfileEvent(), + profilingStartTimestampNs: 1500 * 1e9, + }; + const event = createMockEvent(); + + const result = enrichCombinedProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result).not.toHaveProperty('profilingStartTimestampNs'); + }); +}); + +describe('enrichAndroidProfileWithEventContext', () => { + beforeEach(() => { + (getDefaultEnvironment as jest.Mock).mockReturnValue('production'); + (getDebugMetadata as jest.Mock).mockReturnValue([]); + }); + + function createMockEvent(overrides?: Partial): Event { + return { + event_id: 'test-event-id', + transaction: 'test-transaction', + release: 'test-release', + environment: 'test-env', + start_timestamp: 1000, + contexts: { + trace: { + trace_id: '12345678901234567890123456789012', + }, + os: { name: 'Android', version: '14' }, + device: {}, + }, + ...overrides, + }; + } + + function createMockAndroidCombinedProfile( + overrides?: Partial, + ): AndroidCombinedProfileEvent { + const hermesProfileEvent = createMockMinimalValidHermesProfileEvent(); + return { + platform: 'android', + sampled_profile: createMockMinimalValidAndroidProfile().sampled_profile, + js_profile: hermesProfileEvent.profile, + android_api_level: 34, + duration_ns: '1000000', + active_thread_id: '123', + ...overrides, + }; + } + + test('should use profilingStartTimestampNs for timestamp when available', () => { + const profilingStartTimestampNs = 1500 * 1e9; + const profile = createMockAndroidCombinedProfile({ profilingStartTimestampNs }); + const event = createMockEvent({ start_timestamp: 1000 }); + + const result = enrichAndroidProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result!.timestamp).toBe(new Date(profilingStartTimestampNs / 1e6).toISOString()); + expect(result!.timestamp).not.toBe(new Date(1000 * 1000).toISOString()); + }); + + test('should fall back to event.start_timestamp when profilingStartTimestampNs is not set', () => { + const profile = createMockAndroidCombinedProfile(); + const event = createMockEvent({ start_timestamp: 1000 }); + + const result = enrichAndroidProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result!.timestamp).toBe(new Date(1000 * 1000).toISOString()); + }); + + test('should not include profilingStartTimestampNs in the output', () => { + const profile = createMockAndroidCombinedProfile({ profilingStartTimestampNs: 1500 * 1e9 }); + const event = createMockEvent(); + + const result = enrichAndroidProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result).not.toHaveProperty('profilingStartTimestampNs'); + }); +}); From a7841292937f8a0ac706e46b71d9ab6d8a398e11 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 12:42:53 +0200 Subject: [PATCH 4/4] style(profiling): Fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/js/profiling/integration.ts | 6 +++++- packages/core/test/profiling/utils.test.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/js/profiling/integration.ts b/packages/core/src/js/profiling/integration.ts index 819393bf34..2dc33e85a4 100644 --- a/packages/core/src/js/profiling/integration.ts +++ b/packages/core/src/js/profiling/integration.ts @@ -283,7 +283,11 @@ export function stopProfiling( if (collectedProfiles.androidProfile) { const durationNs = profileEndTimestampNs - profileStartTimestampNs; - const androidProfile = createAndroidWithHermesProfile(hermesProfileEvent, collectedProfiles.androidProfile, durationNs); + const androidProfile = createAndroidWithHermesProfile( + hermesProfileEvent, + collectedProfiles.androidProfile, + durationNs, + ); androidProfile.profilingStartTimestampNs = profileStartTimestampNs; return androidProfile; } else if (collectedProfiles.nativeProfile) { diff --git a/packages/core/test/profiling/utils.test.ts b/packages/core/test/profiling/utils.test.ts index 28e64f18f4..1ab8630ca0 100644 --- a/packages/core/test/profiling/utils.test.ts +++ b/packages/core/test/profiling/utils.test.ts @@ -3,9 +3,13 @@ jest.mock('../../src/js/profiling/debugid'); import type { Event } from '@sentry/core'; -import { getDebugMetadata } from '../../src/js/profiling/debugid'; import type { AndroidCombinedProfileEvent, CombinedProfileEvent } from '../../src/js/profiling/types'; -import { enrichAndroidProfileWithEventContext, enrichCombinedProfileWithEventContext } from '../../src/js/profiling/utils'; + +import { getDebugMetadata } from '../../src/js/profiling/debugid'; +import { + enrichAndroidProfileWithEventContext, + enrichCombinedProfileWithEventContext, +} from '../../src/js/profiling/utils'; import { getDefaultEnvironment } from '../../src/js/utils/environment'; import { createMockMinimalValidAndroidProfile, createMockMinimalValidHermesProfileEvent } from './fixtures';