diff --git a/CHANGELOG.md b/CHANGELOG.md index e64a69dd41..9cbbc2eb1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes +- 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)) - Add `SENTRY_PROJECT_ROOT` env var to override project root in Xcode build phase scripts for monorepo setups ([#5961](https://github.com/getsentry/sentry-react-native/pull/5961)) ### Features diff --git a/packages/core/src/js/profiling/integration.ts b/packages/core/src/js/profiling/integration.ts index b5b13b2acb..2dc33e85a4 100644 --- a/packages/core/src/js/profiling/integration.ts +++ b/packages/core/src/js/profiling/integration.ts @@ -279,9 +279,17 @@ 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 || '', diff --git a/packages/core/test/profiling/utils.test.ts b/packages/core/test/profiling/utils.test.ts new file mode 100644 index 0000000000..1ab8630ca0 --- /dev/null +++ b/packages/core/test/profiling/utils.test.ts @@ -0,0 +1,150 @@ +jest.mock('../../src/js/utils/environment'); +jest.mock('../../src/js/profiling/debugid'); + +import type { Event } from '@sentry/core'; + +import type { AndroidCombinedProfileEvent, CombinedProfileEvent } from '../../src/js/profiling/types'; + +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'; + +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'); + }); +});