From 76944a0cd3452e7ec5d4aea569b73fe765c92791 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 3 Jun 2026 10:34:12 +0200 Subject: [PATCH 1/3] feat(profiling): Add measurements to Android profiling Pass memory, CPU, and frame measurements from the Android SDK's ProfileEndData through the React Native bridge into profile events. The data was already collected by the native profiler but never forwarded to JS. Closes #3641 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + .../io/sentry/react/RNSentryModuleImpl.java | 23 ++++++++++ packages/core/src/js/profiling/integration.ts | 22 +++++++++- packages/core/src/js/profiling/nativeTypes.ts | 10 +++++ packages/core/src/js/profiling/types.ts | 1 + packages/core/test/profiling/fixtures.ts | 27 ++++++++++++ .../profiling/integration.android.test.ts | 43 ++++++++++++++++++- packages/core/test/profiling/utils.test.ts | 33 ++++++++++++++ 8 files changed, 158 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237ba8308a..d005713432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add memory, CPU, and frame measurements to Android profiling ([#3641](https://github.com/getsentry/sentry-react-native/issues/3641)) - Add `enableAutoConsoleLogs` option to opt out of automatic `console.*` capture while keeping `enableLogs: true` for manual `Sentry.logger.*` calls ([#6235](https://github.com/getsentry/sentry-react-native/pull/6235)) - Warn when Gradle resolves `sentry-android` to a version incompatible with the SDK ([#6238](https://github.com/getsentry/sentry-react-native/pull/6238)) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 7fb53b8ef5..531080ca01 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -51,6 +51,8 @@ import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; import io.sentry.protocol.Geo; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; @@ -908,6 +910,27 @@ public WritableMap stopProfiling() { androidProfile.putString("sampled_profile", base64AndroidProfile); androidProfile.putInt("android_api_level", buildInfo.getSdkInfoVersion()); androidProfile.putString("build_id", getProguardUuid()); + + if (end.measurementsMap != null && !end.measurementsMap.isEmpty()) { + WritableMap measurements = new WritableNativeMap(); + for (Map.Entry entry : end.measurementsMap.entrySet()) { + WritableMap measurement = new WritableNativeMap(); + measurement.putString("unit", entry.getValue().getUnit()); + WritableArray values = new WritableNativeArray(); + if (entry.getValue().getValues() != null) { + for (ProfileMeasurementValue pmv : entry.getValue().getValues()) { + WritableMap value = new WritableNativeMap(); + value.putString("elapsed_since_start_ns", pmv.getRelativeStartNs()); + value.putDouble("value", pmv.getValue()); + values.pushMap(value); + } + } + measurement.putArray("values", values); + measurements.putMap(entry.getKey(), measurement); + } + androidProfile.putMap("measurements", measurements); + } + result.putMap("androidProfile", androidProfile); } } catch (Throwable e) { // NOPMD - We don't want to crash in any case diff --git a/packages/core/src/js/profiling/integration.ts b/packages/core/src/js/profiling/integration.ts index 2dc33e85a4..7f9692593a 100644 --- a/packages/core/src/js/profiling/integration.ts +++ b/packages/core/src/js/profiling/integration.ts @@ -305,12 +305,32 @@ export function createAndroidWithHermesProfile( nativeAndroid: NativeAndroidProfileEvent, durationNs: number, ): AndroidCombinedProfileEvent { + const { measurements: nativeMeasurements, ...rest } = nativeAndroid; + let measurements: AndroidCombinedProfileEvent['measurements']; + if (nativeMeasurements) { + measurements = {}; + for (const key of Object.keys(nativeMeasurements)) { + const nativeMeasurement = nativeMeasurements[key]; + if (!nativeMeasurement) { + continue; + } + measurements[key] = { + unit: nativeMeasurement.unit, + values: nativeMeasurement.values.map(v => ({ + elapsed_since_start_ns: Number(v.elapsed_since_start_ns), + value: v.value, + })), + }; + } + } + return { - ...nativeAndroid, + ...rest, platform: 'android', js_profile: hermes.profile, duration_ns: durationNs.toString(10), active_thread_id: hermes.transaction.active_thread_id, + ...(measurements && Object.keys(measurements).length > 0 && { measurements }), }; } diff --git a/packages/core/src/js/profiling/nativeTypes.ts b/packages/core/src/js/profiling/nativeTypes.ts index 583fd363ee..b61d5bfdcc 100644 --- a/packages/core/src/js/profiling/nativeTypes.ts +++ b/packages/core/src/js/profiling/nativeTypes.ts @@ -57,4 +57,14 @@ export interface NativeAndroidProfileEvent { * Proguard mapping file hash */ build_id?: string; + measurements?: Record< + string, + { + unit: string; + values: { + elapsed_since_start_ns: string; + value: number; + }[]; + } + >; } diff --git a/packages/core/src/js/profiling/types.ts b/packages/core/src/js/profiling/types.ts index 3c12ea6c87..143d5506e9 100644 --- a/packages/core/src/js/profiling/types.ts +++ b/packages/core/src/js/profiling/types.ts @@ -33,6 +33,7 @@ export type AndroidCombinedProfileEvent = { duration_ns: string; active_thread_id: string; profilingStartTimestampNs?: number; + measurements?: AndroidProfileEvent['measurements']; }; /* diff --git a/packages/core/test/profiling/fixtures.ts b/packages/core/test/profiling/fixtures.ts index f406a613f3..8f696c1ad5 100644 --- a/packages/core/test/profiling/fixtures.ts +++ b/packages/core/test/profiling/fixtures.ts @@ -166,3 +166,30 @@ export function createMockMinimalValidAndroidProfile(): NativeAndroidProfileEven build_id: 'mocked-build-id', }; } + +export function createMockMinimalValidAndroidProfileWithMeasurements(): NativeAndroidProfileEvent { + return { + ...createMockMinimalValidAndroidProfile(), + measurements: { + frozen_frame_renders: { + unit: 'nanosecond', + values: [{ elapsed_since_start_ns: '1000000', value: 800000000 }], + }, + slow_frame_renders: { + unit: 'nanosecond', + values: [{ elapsed_since_start_ns: '2000000', value: 20000000 }], + }, + cpu_usage: { + unit: 'percent', + values: [ + { elapsed_since_start_ns: '0', value: 35.5 }, + { elapsed_since_start_ns: '5000000', value: 42.1 }, + ], + }, + memory_footprint: { + unit: 'byte', + values: [{ elapsed_since_start_ns: '0', value: 104857600 }], + }, + }, + }; +} diff --git a/packages/core/test/profiling/integration.android.test.ts b/packages/core/test/profiling/integration.android.test.ts index eb5d324a60..6bf3c38aac 100644 --- a/packages/core/test/profiling/integration.android.test.ts +++ b/packages/core/test/profiling/integration.android.test.ts @@ -1,7 +1,11 @@ import type { AndroidCombinedProfileEvent } from '../../src/js/profiling/types'; import { createAndroidWithHermesProfile } from '../../src/js/profiling/integration'; -import { createMockMinimalValidAndroidProfile, createMockMinimalValidHermesProfileEvent } from './fixtures'; +import { + createMockMinimalValidAndroidProfile, + createMockMinimalValidAndroidProfileWithMeasurements, + createMockMinimalValidHermesProfileEvent, +} from './fixtures'; describe('merge Hermes and Android profiles - createAndroidWithHermesProfile', () => { it('should create Android profile structure with hermes profile', () => { @@ -49,4 +53,41 @@ describe('merge Hermes and Android profiles - createAndroidWithHermesProfile', ( active_thread_id: '123', }); }); + + it('should include measurements when present in native Android profile', () => { + const androidProfile = createMockMinimalValidAndroidProfileWithMeasurements(); + const result = createAndroidWithHermesProfile(createMockMinimalValidHermesProfileEvent(), androidProfile, 987); + + expect(result.measurements).toEqual({ + frozen_frame_renders: { + unit: 'nanosecond', + values: [{ elapsed_since_start_ns: 1000000, value: 800000000 }], + }, + slow_frame_renders: { + unit: 'nanosecond', + values: [{ elapsed_since_start_ns: 2000000, value: 20000000 }], + }, + cpu_usage: { + unit: 'percent', + values: [ + { elapsed_since_start_ns: 0, value: 35.5 }, + { elapsed_since_start_ns: 5000000, value: 42.1 }, + ], + }, + memory_footprint: { + unit: 'byte', + values: [{ elapsed_since_start_ns: 0, value: 104857600 }], + }, + }); + }); + + it('should not include measurements when absent from native Android profile', () => { + const result = createAndroidWithHermesProfile( + createMockMinimalValidHermesProfileEvent(), + createMockMinimalValidAndroidProfile(), + 987, + ); + + expect(result.measurements).toBeUndefined(); + }); }); diff --git a/packages/core/test/profiling/utils.test.ts b/packages/core/test/profiling/utils.test.ts index 1ab8630ca0..ab8d427e1f 100644 --- a/packages/core/test/profiling/utils.test.ts +++ b/packages/core/test/profiling/utils.test.ts @@ -147,4 +147,37 @@ describe('enrichAndroidProfileWithEventContext', () => { expect(result).not.toBeNull(); expect(result).not.toHaveProperty('profilingStartTimestampNs'); }); + + test('should include measurements when present', () => { + const measurements = { + cpu_usage: { + unit: 'percent' as const, + values: [ + { elapsed_since_start_ns: 0, value: 35.5 }, + { elapsed_since_start_ns: 5000000, value: 42.1 }, + ], + }, + memory_footprint: { + unit: 'byte' as const, + values: [{ elapsed_since_start_ns: 0, value: 104857600 }], + }, + }; + const profile = createMockAndroidCombinedProfile({ measurements }); + const event = createMockEvent(); + + const result = enrichAndroidProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result!.measurements).toEqual(measurements); + }); + + test('should not include measurements when absent', () => { + const profile = createMockAndroidCombinedProfile(); + const event = createMockEvent(); + + const result = enrichAndroidProfileWithEventContext('profile-id', profile, event); + + expect(result).not.toBeNull(); + expect(result!.measurements).toBeUndefined(); + }); }); From 817959e730b7ba4d06aefc7041630aa44243b921 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 3 Jun 2026 10:34:45 +0200 Subject: [PATCH 2/3] chore: Update changelog PR link for #6250 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d005713432..a9bbe7ed81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Add memory, CPU, and frame measurements to Android profiling ([#3641](https://github.com/getsentry/sentry-react-native/issues/3641)) +- Add memory, CPU, and frame measurements to Android profiling ([#6250](https://github.com/getsentry/sentry-react-native/pull/6250)) - Add `enableAutoConsoleLogs` option to opt out of automatic `console.*` capture while keeping `enableLogs: true` for manual `Sentry.logger.*` calls ([#6235](https://github.com/getsentry/sentry-react-native/pull/6235)) - Warn when Gradle resolves `sentry-android` to a version incompatible with the SDK ([#6238](https://github.com/getsentry/sentry-react-native/pull/6238)) From 2b5745b946a1b90c88ed43329f6bd6f0042b03c0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 3 Jun 2026 11:51:10 +0200 Subject: [PATCH 3/3] fix(android): Reuse SDK frame metrics collector for profiling The standalone SentryFrameMetricsCollector created in initializeAndroidProfiler() missed the onActivityStarted callback because the activity was already started, so currentWindow was never set and no frame metrics were collected. Reuse the collector from SentryAndroidOptions instead, which is already tracking the active window from Sentry.init(). Co-Authored-By: Claude Opus 4.6 --- .../java/io/sentry/react/RNSentryModuleImpl.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 531080ca01..0e1053d42b 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -825,11 +825,23 @@ private void initializeAndroidProfiler() { } final String tracesFilesDirPath = getProfilingTracesDirPath(); + SentryFrameMetricsCollector collector = null; + try { + final SentryOptions options = Sentry.getCurrentScopes().getOptions(); + if (options instanceof SentryAndroidOptions) { + collector = ((SentryAndroidOptions) options).getFrameMetricsCollector(); + } + } catch (Throwable ignored) { // NOPMD - Best-effort + } + if (collector == null) { + collector = new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo); + } + androidProfiler.set( new AndroidProfiler( tracesFilesDirPath, (int) SECONDS.toMicros(1) / profilingTracesHz, - new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), + collector, () -> executorService, logger)); }