feat(profiling): Add Android ProfilingManager (Perfetto) support#5251
feat(profiling): Add Android ProfilingManager (Perfetto) support#5251
Conversation
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>
…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>
|
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. |
Sentry Build Distribution
|
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 092f017 | 353.13 ms | 433.84 ms | 80.71 ms |
| 694d587 | 379.62 ms | 400.80 ms | 21.18 ms |
| dba088c | 321.78 ms | 364.59 ms | 42.82 ms |
| 1564554 | 323.06 ms | 336.68 ms | 13.62 ms |
| d217708 | 355.34 ms | 381.39 ms | 26.05 ms |
| 22f4345 | 312.78 ms | 347.40 ms | 34.62 ms |
| d15471f | 322.58 ms | 396.08 ms | 73.50 ms |
| 9054d65 | 330.94 ms | 403.24 ms | 72.30 ms |
| ee747ae | 374.71 ms | 455.18 ms | 80.47 ms |
| 83884a0 | 334.46 ms | 400.92 ms | 66.46 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 092f017 | 0 B | 0 B | 0 B |
| 694d587 | 1.58 MiB | 2.19 MiB | 620.06 KiB |
| dba088c | 1.58 MiB | 2.13 MiB | 558.99 KiB |
| 1564554 | 1.58 MiB | 2.20 MiB | 635.33 KiB |
| d217708 | 1.58 MiB | 2.10 MiB | 532.97 KiB |
| 22f4345 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| 9054d65 | 1.58 MiB | 2.29 MiB | 723.38 KiB |
| ee747ae | 1.58 MiB | 2.10 MiB | 530.95 KiB |
| 83884a0 | 1.58 MiB | 2.29 MiB | 722.97 KiB |
sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java
Show resolved
Hide resolved
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.
| } | ||
| if (metaLength != null) { | ||
| writer.name(JsonKeys.META_LENGTH).value(metaLength); | ||
| } |
There was a problem hiding this comment.
Serialize uses field instead of getter for meta_length
High Severity
The serialize() method checks the raw metaLength field (if (metaLength != null)) instead of calling getMetaLength(). For Perfetto profile chunks, the header is constructed via the Callable<Integer> getMetaLength constructor, which sets this.metaLength = null and stores the callable in this.getMetaLength. So meta_length is never written to the envelope header JSON. This is inconsistent with how length is handled on the very next line, which correctly uses the getLength() getter. Without meta_length, the server cannot determine where JSON metadata ends and the binary Perfetto trace begins, making every Perfetto profile chunk unparseable.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4e173d3. Configure here.
…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) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…miting Verify that onRateLimitChanged stops the profiler, resets profiler/chunk IDs, and logs the expected warning. Run with: ./gradlew :sentry-android-core:testDebugUnitTest --tests "io.sentry.android.core.PerfettoContinuousProfilerTest" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ousProfiler Currently PerfettoContinuousProfiler is not doing app-start profiling. Because of this, scopes are always available. Remove the legacy patterns that were carried over from AndroidContinuousProfiler: - Replace tryResolveScopes/onScopesAvailable with resolveScopes() that returns @NotNull IScopes and logs an error if scopes is unexpectedly unavailable - Remove payloadBuilders list, payloadLock, and sendChunks() buffering; replace with sendChunk() that sends a single chunk immediately - Remove scopes != null guards and SentryNanotimeDate fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add lock to isRunning(), getProfilerId(), getChunkId() so all public getters are synchronized with writes in startInternal/stopInternal - Add lock to reevaluateSampling() and remove volatile from shouldSample; all accesses are now under the same lock - Add Caller must hold lock javadoc to resolveScopes() - Add class-level javadoc documenting the threading/locking policy - Replace ArrayDeque with ConcurrentLinkedDeque in PerfettoProfiler for frame measurement collections; these are written by the FrameMetrics HandlerThread and read by the executor thread in endAndCollect() 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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| scopes.captureProfileChunk(builder.build(options)); | ||
| }); | ||
| } catch (Throwable e) { | ||
| options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunk.", e); |
4e173d3 to
b4b28c9
Compare
| public void close(final boolean isTerminating) { | ||
| try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { | ||
| activeTraceCount = 0; | ||
| shouldStop = true; | ||
| if (isTerminating) { | ||
| stopInternal(false); | ||
| isClosed.set(true); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Bug: Calling close(isTerminating=false) on the profiler does not stop it immediately, allowing it to run for up to 60 more seconds, violating the interface contract.
Severity: MEDIUM
Suggested Fix
The close(isTerminating=false) method should be updated to call stopInternal(false) to ensure the profiler is stopped immediately, consistent with the behavior of close(isTerminating=true) and the interface documentation.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java#L171-L180
Potential issue: The `close(boolean isTerminating)` method in
`PerfettoContinuousProfiler` does not immediately stop profiling when called with
`isTerminating=false`. It only sets a `shouldStop` flag, allowing the profiler to
continue running and consuming resources for up to 60 seconds until its next scheduled
check. This violates the `IContinuousProfiler.close()` interface contract, which implies
an immediate stop. Other parts of the code, like `onRateLimitChanged()`, correctly call
`stopInternal(false)` to stop profiling instantly, demonstrating the intended behavior.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 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 b4b28c9. Configure here.
| startInternal(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
shouldStop never reset, breaking profiler restart after stop
Medium Severity
The shouldStop flag is set to true in stopProfiler but never reset to false when startProfiler is called again. After a stop/start cycle, shouldStop remains true, so when the chunk timer fires and stopInternal(true) checks if (restartProfiler && !shouldStop), it won't restart — the profiler silently stops after just one chunk instead of continuing indefinitely.
Reviewed by Cursor Bugbot for commit b4b28c9. 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.PerfettoContinuousProfilerstartProfiler,stopProfiler,close(true),reevaluateSampling[RateLimiter.java:317](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/transport/RateLimiter.java#L317)new Timer(true)onRateLimitChanged— rate limit expiry at[RateLimiter.java:324](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/transport/RateLimiter.java#L324)[LifecycleWatcher.java:106](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java#L106)new Timer(true)close(false)— session timeout at[LifecycleWatcher.java:117](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java#L117)SentryAsyncConnection-N[AsyncHttpTransport.java:221](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java#L221)new Thread(r, "SentryAsyncConnection-" + cnt++)onRateLimitChanged— immediate 429 notification at[RateLimiter.java:313](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/transport/RateLimiter.java#L313)startProfiler(TRACE)at[OtelSentrySpanProcessor.java:122](https://github.com/getsentry/sentry-java/blob/main/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java#L122),stopProfiler(TRACE)at[OtelSentrySpanProcessor.java:181](https://github.com/getsentry/sentry-java/blob/main/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java#L181)SentryExecutorServiceThreadFactory-N[SentryExecutorService.java:157](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/SentryExecutorService.java#L157)new Thread(r, "SentryExecutorServiceThreadFactory-" + cnt++)stopInternal(true)— scheduled chunk timer. AlsosendChunk()submits work here.[DefaultCompositePerformanceCollector.java:93](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java#L93)new Timer(true)performanceCollector.start()/.stop()are called fromstartInternal/stopInternalunder the lock. The Timer thread runs[setup()](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java#L100)and[collect()](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java#L129)every 100ms ([line 150](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java#L150)), collecting CPU ([AndroidCpuCollector](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java)) and memory ([AndroidMemoryCollector](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMemoryCollector.java),[JavaMemoryCollector](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/JavaMemoryCollector.java)) stats.[SentryFrameMetricsCollector.java:107-112](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java#L107)new HandlerThread("...SentryFrameMetricsCollector")PerfettoProfiler's[ConcurrentLinkedDeque](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java#L49)frame measurements via[onFrameMetricCollected](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java#L132). Also feeds[SpanFrameMetricsCollector](https://github.com/getsentry/sentry-java/blob/main/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java#L28)with frame data on the same thread.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 UIPerfettoContinuousProfilerTest(rate limiting),SentryOptionsTest,ManifestMetadataReaderTest,SentryEnvelopeItemTestJAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :sentry-android-core:testDebugUnitTest --tests "io.sentry.android.core.PerfettoContinuousProfilerTest"📝 Checklist
sendDefaultPIIis enabled.Testing locally
🔮 Next steps
PROFILING_TYPE_STACK_SAMPLINGtraces (ProfilingManager doesn't seem to includelinux.process_statsdata source)