feat(profiling): Add Android ProfilingManager (Perfetto) support#5251
feat(profiling): Add Android ProfilingManager (Perfetto) support#525143jay wants to merge 32 commits into
Conversation
|
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. This PR will not appear in the changelog. 🤖 This preview updates automatically when you update the PR. |
📲 Install BuildsAndroid
|
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 8558cac | 306.16 ms | 355.24 ms | 49.09 ms |
| 8687935 | 332.52 ms | 362.23 ms | 29.71 ms |
| 9054d65 | 330.94 ms | 403.24 ms | 72.30 ms |
| fc5ccaf | 322.49 ms | 405.25 ms | 82.76 ms |
| 8c1fb22 | 316.62 ms | 352.78 ms | 36.16 ms |
| d8912da | 329.94 ms | 389.68 ms | 59.74 ms |
| d15471f | 315.61 ms | 360.22 ms | 44.61 ms |
| b6702b0 | 395.86 ms | 409.98 ms | 14.12 ms |
| 62b579c | 318.48 ms | 367.71 ms | 49.24 ms |
| ed33deb | 312.34 ms | 369.71 ms | 57.37 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 8558cac | 0 B | 0 B | 0 B |
| 8687935 | 1.58 MiB | 2.19 MiB | 619.17 KiB |
| 9054d65 | 1.58 MiB | 2.29 MiB | 723.38 KiB |
| fc5ccaf | 1.58 MiB | 2.13 MiB | 557.54 KiB |
| 8c1fb22 | 0 B | 0 B | 0 B |
| d8912da | 0 B | 0 B | 0 B |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| b6702b0 | 1.58 MiB | 2.12 MiB | 551.79 KiB |
| 62b579c | 0 B | 0 B | 0 B |
| ed33deb | 1.58 MiB | 2.13 MiB | 559.52 KiB |
Previous results on branch: claude/dreamy-solomon
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| cee9ee9 | 342.21 ms | 405.42 ms | 63.21 ms |
| 60b848d | 300.86 ms | 376.37 ms | 75.51 ms |
| 577aa3c | 328.02 ms | 394.80 ms | 66.78 ms |
| 7da193d | 319.39 ms | 375.24 ms | 55.85 ms |
| 0db7260 | 329.68 ms | 375.67 ms | 45.99 ms |
| c26f799 | 319.88 ms | 358.02 ms | 38.14 ms |
| 4c9db13 | 318.56 ms | 372.96 ms | 54.40 ms |
| b3c0878 | 316.40 ms | 345.51 ms | 29.11 ms |
| a5a2277 | 406.45 ms | 510.56 ms | 104.11 ms |
| 15f94e1 | 319.26 ms | 339.63 ms | 20.38 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| cee9ee9 | 0 B | 0 B | 0 B |
| 60b848d | 0 B | 0 B | 0 B |
| 577aa3c | 0 B | 0 B | 0 B |
| 7da193d | 0 B | 0 B | 0 B |
| 0db7260 | 0 B | 0 B | 0 B |
| c26f799 | 0 B | 0 B | 0 B |
| 4c9db13 | 0 B | 0 B | 0 B |
| b3c0878 | 0 B | 0 B | 0 B |
| a5a2277 | 0 B | 0 B | 0 B |
| 15f94e1 | 0 B | 0 B | 0 B |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Serialize uses field instead of getter for
meta_lengthSentryEnvelopeItemHeader.serialize()now usesgetMetaLength()(captured once in a local) so callable-backed Perfetto chunks correctly emitmeta_lengthin envelope headers.
Or push these changes by commenting:
@cursor push 56eb859503
Preview (56eb859503)
diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java
--- a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java
+++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java
@@ -219,8 +219,9 @@
if (itemCount != null) {
writer.name(JsonKeys.ITEM_COUNT).value(itemCount);
}
- if (metaLength != null) {
- writer.name(JsonKeys.META_LENGTH).value(metaLength);
+ final @Nullable Integer metaLengthValue = getMetaLength();
+ if (metaLengthValue != null) {
+ writer.name(JsonKeys.META_LENGTH).value(metaLengthValue);
}
writer.name(JsonKeys.LENGTH).value(getLength());
if (unknown != null) {This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
4e173d3 to
b4b28c9
Compare
Adds a new boolean option `useProfilingManager` that gates whether the SDK uses Android's ProfilingManager API (API 35+) for Perfetto-based profiling. On devices below API 35 where ProfilingManager is not available, no profiling data is collected — the legacy Debug-based profiler is not used as a fallback. Wired through SentryOptions and ManifestMetadataReader (AndroidManifest meta-data). Defaults to false (opt-in). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds UI controls to the profiling sample activity for testing both legacy and Perfetto profiling paths. Enables useProfilingManager flag in the sample manifest for API 35+ testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show active profiler status line with (i) info button to show SDK config (sample rates, lifecycle mode, use-profiling-manager) - Conditionally show Start(Manual) or Start(Transaction) button based on profileLifecycle mode, since each is a no-op in the wrong mode - Hide duration seekbar in MANUAL mode (only affects transaction length) - Remove inline profiling result TextView; show results via Toast and in the (i) dialog instead - Apply AppTheme.Main to fix edge-to-edge clipping on API 35+ - Add indices to the bitmap list items so user can see the list view jumping around Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ager is set When useProfilingManager is true, SentryPerformanceProvider now skips creating the legacy Debug-based profiler at app start. This ensures AndroidOptionsInitializer creates a Perfetto profiler instead, without needing special handover logic between the two profiling engines. The useProfilingManager flag is persisted in SentryAppStartProfilingOptions (written at end of Sentry.init(), read on next app launch) so the decision is available before SDK initialization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> squash into options commit
…Profiler Introduces PerfettoProfiler, which uses Android's ProfilingManager system service (API 35+) for Perfetto-based stack sampling. When useProfilingManager is enabled, AndroidContinuousProfiler selects PerfettoProfiler at init time via createWithProfilingManager(); on older devices no profiling data is collected and the legacy Debug-based profiler is not used as a fallback. Key changes: - PerfettoProfiler: calls requestProfiling(STACK_SAMPLING), waits for ProfilingResult via CountDownLatch, reads .pftrace via getResultFilePath() - AndroidContinuousProfiler: factory methods createLegacy() / createWithProfilingManager() replace the public constructor; init() split into initLegacy() / initProfilingManager() for clarity; stopFuture uses cancel(false) to avoid interrupting the Perfetto result wait - AndroidOptionsInitializer: branches on isUseProfilingManager() to select the correct factory method - SentryEnvelopeItem: fromPerfettoProfileChunk() builds a single envelope item with meta_length header separating JSON metadata from binary .pftrace - SentryEnvelopeItemHeader: adds metaLength field for the binary format - ProfileChunk: adds contentType and version fields; Builder.setContentType() - SentryClient: routes Perfetto chunks to fromPerfettoProfileChunk() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b4b28c9 to
83b1f1a
Compare
10c415f to
873bad3
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Unconditional
shouldStopreset causes unintended profiler continuationshouldStopis now reset only in TRACE starts and in MANUAL starts that actually proceed, so a skipped MANUAL start no longer clears a pending TRACE stop.
- ✅ Fixed: Missing API level guard for PerfettoContinuousProfiler creation
setupProfilernow guardsuseProfilingManagerbehind an API 35+ check and falls back toNoOpContinuousProfileron lower API levels to avoid loading Perfetto classes.
Or push these changes by commenting:
@cursor push ce6f706c8b
Preview (ce6f706c8b)
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java
--- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java
@@ -337,6 +337,17 @@
performanceCollector.start(chunkId.toString());
}
} else {
+ if (options.isUseProfilingManager()
+ && buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ options
+ .getLogger()
+ .log(
+ SentryLevel.INFO,
+ "useProfilingManager is enabled, but API level is below %d. Continuous profiling is disabled.",
+ Build.VERSION_CODES.VANILLA_ICE_CREAM);
+ options.setContinuousProfiler(NoOpContinuousProfiler.getInstance());
+ return;
+ }
final @NotNull SentryFrameMetricsCollector frameMetricsCollector =
Objects.requireNonNull(
options.getFrameMetricsCollector(), "options.getFrameMetricsCollector is required");
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java
--- a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java
@@ -107,7 +107,6 @@
final @NotNull ProfileLifecycle profileLifecycle,
final @NotNull TracesSampler tracesSampler) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
- shouldStop = false;
if (shouldSample) {
isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble());
shouldSample = false;
@@ -118,6 +117,7 @@
}
switch (profileLifecycle) {
case TRACE:
+ shouldStop = false;
activeTraceCount = Math.max(0, activeTraceCount); // safety check.
activeTraceCount++;
break;
@@ -128,6 +128,7 @@
"Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping.");
return;
}
+ shouldStop = false;
break;
}
if (!isRunning()) {
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt
--- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt
@@ -376,7 +376,16 @@
assertTrue(fixture.sentryOptions.continuousProfiler is AndroidContinuousProfiler)
}
+ @Config(sdk = [34])
@Test
+ fun `init with profiling manager below API 35 sets no-op continuous profiler`() {
+ fixture.initSut(configureOptions = { isUseProfilingManager = true }, useRealContext = true)
+
+ assertEquals(NoOpTransactionProfiler.getInstance(), fixture.sentryOptions.transactionProfiler)
+ assertEquals(NoOpContinuousProfiler.getInstance(), fixture.sentryOptions.continuousProfiler)
+ }
+
+ @Test
fun `init with profilesSampleRate should set Android transaction profiler`() {
fixture.initSut(configureOptions = { profilesSampleRate = 1.0 })
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt
--- a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt
@@ -132,4 +132,24 @@
"Profiler should continue running after chunk restart — shouldStop must be reset on start",
)
}
+
+ @Test
+ fun `manual start while trace profiling is running does not cancel pending trace stop`() {
+ val profiler = fixture.getSut()
+
+ profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler)
+ assertTrue(profiler.isRunning)
+
+ profiler.stopProfiler(ProfileLifecycle.TRACE)
+ profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler)
+
+ fixture.executor.runAll()
+
+ assertFalse(profiler.isRunning)
+ verify(fixture.mockLogger)
+ .log(
+ eq(SentryLevel.WARNING),
+ eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."),
+ )
+ }
}This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
…eader SentryEnvelopeItemHeader.serialize() checked the raw metaLength field instead of calling getMetaLength(), so the callable path used by Perfetto profile chunks was never invoked and meta_length was never written to the envelope header JSON. Refactor SentryEnvelopeItemHeader to remove the metaLength field entirely — all constructors now store a single calculateMetaLength callable. Eager constructors (deserializer) wrap the Integer in a lambda. All constructors delegate to one private primary constructor. In fromPerfettoProfileChunk, replace the round-trip through ProfileChunk.setMetaLength/getMetaLength with a local AtomicReference shared between the CachedItem lambda and the header callable, keeping meta_length as an envelope transport concern rather than in ProfileChunk
…uousProfiler Separate the Perfetto/ProfilingManager profiling backend into its own IContinuousProfiler implementation to keep the two backends independent. - AndroidContinuousProfiler is restored to legacy-only (no Perfetto fields, no conditional branches, no @SuppressLint annotations) - PerfettoContinuousProfiler is a new @RequiresApi(35) class that delegates to PerfettoProfiler and always sets content_type="perfetto" - AndroidOptionsInitializer branches on useProfilingManager to pick the right implementation - Consistent locking: startInternal/stopInternal both require caller to hold the lock, with callers wrapped accordingly - Renamed rootSpanCounter to activeTraceCount in PerfettoContinuousProfiler - Extracted tryResolveScopes/onScopesAvailable from initScopes in both classes - Fixed duplicate listener bug in PerfettoProfiler (was using local lambda instead of class-scope profilingResultListener)
96dae7f to
11331ea
Compare
markushi
left a comment
There was a problem hiding this comment.
Looking good overall! I left a few comments.
Guard with buildInfoProvider.getSdkInfoVersion() >= VANILLA_ICE_CREAM. On API < 35, log a warning and leave the profiler as NoOp.
…ization ChunkMeasurementCollector#stop was passing its data maps by reference into ProfileMeasurement. The collected data is submitted async, so races with the next ChunkMeasurementCollector#start which reset the referenced maps Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ACProfiler is legacy, no need to modify the code.
The .api file declared getMetaLength()/setMetaLength(int) on ProfileChunk, but those methods don't exist in the source. Related stale entries on SentryEnvelopeItemHeader (8-arg private ctor and package-private getMetaLength) were also removed. Regenerated via: ./gradlew :sentry:apiDump Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In SentryEnvelopeItemHeader.serialize(), meta_length was being evaluated before length. For Perfetto profile chunks, the metaLength value is only populated as a side effect of materializing the payload, which the length computation triggers. Added a regression test that drives header.serialize() directly without first touching item.data. Also renamed the existing test to describe what it actually covers (payload layout), since the old name overpromised header coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…easurements ChunkMeasurementCollector was shipping raw clock values in elapsed_since_start_ns: System.nanoTime() for frame metrics and System.currentTimeMillis()*1e6 for cpu/memory. Cause: Conversions that the legacy AndroidProfiler performs were dropped when the collector was extracted. Fix: Restore both, refactor to make the logic more intuitive
… guard against orphaned profiler restart Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ac104ed. Configure here.
| } | ||
| } else { | ||
| logger.log(SentryLevel.DEBUG, "Profile chunk finished."); | ||
| } |
There was a problem hiding this comment.
Async callback ignores late shouldStop causing extra chunk
Medium Severity
In stopInternal, shouldRestart is captured while the lock is held, then passed to the endAndCollect callback. When the callback runs asynchronously (OS hasn't delivered the Perfetto result yet), it uses the stale shouldRestart value without re-checking shouldStop. If stopProfiler() or close(false) is called between the capture and the async callback, onChunkCollected will proceed with shouldRestart=true and call startInternal(), starting an unnecessary extra profiling chunk. The onChunkCollected method checks isRunning and isClosed but not shouldStop, so the late stop request is missed. The legacy AndroidContinuousProfiler doesn't have this race because its restart decision is made synchronously while holding the lock.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ac104ed. Configure here.



📜 Description
Adds opt-in
useProfilingManageroption that uses Android'sProfilingManagerAPI (API 35+) for Perfetto-based stack sampling instead of the legacyDebug.startMethodTracingSamplingengine.PerfettoContinuousProfileris mutually exclusive withAndroidContinuousProfiler— the option gates which implementation is created at init time. The legacy path is unchanged.Why a new ContinuousProfiler class
The first few commits wire the Perfetto backend into
AndroidContinuousProfiler(ported from an earlier branch). The later commits extract a standalonePerfettoContinuousProfilerbecause:AndroidContinuousProfilerhas a lot of state and theif (perfetto) { ... } else { legacy }branching makes paths hard to follow => the two codepaths will never be active at the same time.startProfiler,stopProfiler,close(true),reevaluateSamplingConcurrentLinkedDeque(code)new HandlerThread("...SentryFrameMetricsCollector")stopInternal(true)— scheduled chunk timer. AlsosendChunk()submits work here.new Thread(r, "SentryExecutorServiceThreadFactory-" + cnt++)onRateLimitChanged— inline callback (code)new Thread(r, "SentryAsyncConnection-" + cnt++)onRateLimitChanged— rate limit expiry (code);close(false)— session timeout (code); not a direct caller butCompositePerformanceCollectorrunssetup()andcollect()every 100ms (code)new Timer(true)in RateLimiter, LifecycleWatcher, CompositePerformanceCollectorstartProfiler(TRACE)(code),stopProfiler(TRACE)(code)PerfettoContinuousProfiler+PerfettoProfilermeans fewer@SuppressLint("NewApi")scattered throughAndroidContinuousProfilerKey files
SentryOptions.useProfilingManager— opt-in flag, readable from manifestio.sentry.profiling.use-profiling-managerPerfettoContinuousProfiler—IContinuousProfilerimpl,@RequiresApi(35), delegates toPerfettoProfilerPerfettoProfiler— wrapsProfilingManager.requestProfiling(PROFILING_TYPE_STACK_SAMPLING, ...)SentryEnvelopeItem.fromPerfettoProfileChunk()— binary envelope format withmeta_lengthheaderAndroidContinuousProfiler— legacy only, no Perfetto references💡 Motivation and Context
Android's
ProfilingManager(API 35+) provides OS-level Perfetto stack sampling. The legacyDebug.startMethodTracingSamplingpath is preserved unchanged. On API < 35 withuseProfilingManager=true, profiling is disabled (no silent fallback).💚 How did you test it?
content_type: "perfetto".pftracefiles and inspected in Perfetto UIUnit Tests
Run all Tests:
PerfettoContinuousProfilerTestSentryOptionsTest,ManifestMetadataReaderTest,SentryEnvelopeItemTest📝 Checklist
sendDefaultPIIis enabled.Testing locally
🔮 Next steps
PROFILING_TYPE_STACK_SAMPLINGtraces (ProfilingManager doesn't seem to includelinux.process_statsdata source)