Skip to content

feat(profiling): Add Android ProfilingManager (Perfetto) support#5251

Open
43jay wants to merge 14 commits intomainfrom
claude/dreamy-solomon
Open

feat(profiling): Add Android ProfilingManager (Perfetto) support#5251
43jay wants to merge 14 commits intomainfrom
claude/dreamy-solomon

Conversation

@43jay
Copy link
Copy Markdown
Collaborator

@43jay 43jay commented Mar 31, 2026

📜 Description

Adds opt-in useProfilingManager option that uses Android's ProfilingManager API (API 35+) for Perfetto-based stack sampling instead of the legacy Debug.startMethodTracingSampling engine.

PerfettoContinuousProfiler is mutually exclusive with AndroidContinuousProfiler — 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 standalone PerfettoContinuousProfiler because:

  1. Mutually exclusiveAndroidContinuousProfiler has a lot of state and the if (perfetto) { ... } else { legacy } branching makes paths hard to follow => the two codepaths will never be active at the same time.
  2. Threading — a large # different threads are involved and reasoning about locking is harder with two backends in one class
Thread Callers Creation site
Caller's thread (main/app) startProfiler, stopProfiler, close(true), reevaluateSampling Not created by Sentry
FrameMetrics HandlerThread Writes frame measurements to PerfettoProfiler's ConcurrentLinkedDeque (code) new HandlerThread("...SentryFrameMetricsCollector")
SentryExecutorServiceThreadFactory-N stopInternal(true) — scheduled chunk timer. Also sendChunk() submits work here. new Thread(r, "SentryExecutorServiceThreadFactory-" + cnt++)
SentryAsyncConnection-N onRateLimitChanged — inline callback (code) new Thread(r, "SentryAsyncConnection-" + cnt++)
Timer daemon onRateLimitChanged — rate limit expiry (code); close(false) — session timeout (code); not a direct caller but CompositePerformanceCollector runs setup() and collect() every 100ms (code) JDK internal — new Timer(true) in RateLimiter, LifecycleWatcher, CompositePerformanceCollector
OTel span processor startProfiler(TRACE) (code), stopProfiler(TRACE) (code) Created by OpenTelemetry SDK — not Sentry-controlled
  1. App-start profiling — the legacy profiler has special null-scopes handling for app-start. ProfilingManager doesn't support app-start, so this complexity doesn't apply
  2. API level annotations — confining all ProfilingManager call sites to PerfettoContinuousProfiler + PerfettoProfiler means fewer @SuppressLint("NewApi") scattered through AndroidContinuousProfiler

Key files

  • SentryOptions.useProfilingManager — opt-in flag, readable from manifest io.sentry.profiling.use-profiling-manager
  • PerfettoContinuousProfilerIContinuousProfiler impl, @RequiresApi(35), delegates to PerfettoProfiler
  • PerfettoProfiler — wraps ProfilingManager.requestProfiling(PROFILING_TYPE_STACK_SAMPLING, ...)
  • SentryEnvelopeItem.fromPerfettoProfileChunk() — binary envelope format with meta_length header
  • AndroidContinuousProfiler — legacy only, no Perfetto references

💡 Motivation and Context

Android's ProfilingManager (API 35+) provides OS-level Perfetto stack sampling. The legacy Debug.startMethodTracingSampling path is preserved unchanged. On API < 35 with useProfilingManager=true, profiling is disabled (no silent fallback).

💚 How did you test it?

  • Manual testing on Pixel Fold AVD (API 35) — verified Perfetto chunks captured with content_type: "perfetto"
  • Extracted .pftrace files and inspected in Perfetto UI
  • Unit tests: PerfettoContinuousProfilerTest (rate limiting), SentryOptionsTest, ManifestMetadataReaderTest, SentryEnvelopeItemTest
  • Run: JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :sentry-android-core:testDebugUnitTest --tests "io.sentry.android.core.PerfettoContinuousProfilerTest"

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

Testing locally

# Disable ProfilingManager rate limiting (required for repeated testing)
adb shell device_config put profiling_testing rate_limiter.disabled true

# Watch logcat for the file path
adb logcat -s Sentry | grep "ProfilingResult"

# Pull the .pftrace file (can't adb pull from app-private dir, use run-as + cat)
PKG="io.sentry.samples.android"
REMOTE_DIR="/data/user/0/$PKG/files/profiling"
adb shell "run-as $PKG cat '$REMOTE_DIR/<filename>'" > ~/Desktop/profile.pftrace

# Open in https://ui.perfetto.dev/

🔮 Next steps

  • Remove CountdownLatch from PerfettoProfiler
  • Refactor AndroidContinuousProfilerTest to extend existing test scenarios to PerfettoContinuousProfilerTest
  • Verify backend ingest WAE
  • Investigate missing thread names in PROFILING_TYPE_STACK_SAMPLING traces (ProfilingManager doesn't seem to include linux.process_stats data source)
  • Docs and CHANGELOG update once PR is stable #skip-changelog

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 31, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 10c415f

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 31, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


This PR will not appear in the changelog.


🤖 This preview updates automatically when you update the PR.

@sentry
Copy link
Copy Markdown

sentry bot commented Mar 31, 2026

Sentry Build Distribution

App Name App ID Version Configuration Install Page
SDK Size io.sentry.tests.size 8.38.0 (1) release Install Build

Configure sentry-android build distribution settings

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 319.39 ms 375.24 ms 55.85 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
d15471f 369.38 ms 459.08 ms 89.70 ms
9fbb112 359.71 ms 421.85 ms 62.14 ms
1df7eb6 397.04 ms 429.64 ms 32.60 ms
ee747ae 400.46 ms 423.61 ms 23.15 ms
ee747ae 357.79 ms 421.84 ms 64.05 ms
b193867 331.08 ms 397.06 ms 65.98 ms
a416a65 333.78 ms 410.37 ms 76.59 ms
b193867 319.59 ms 403.09 ms 83.50 ms
b6cfb57 372.92 ms 507.77 ms 134.85 ms
ee35ac3 346.83 ms 435.48 ms 88.65 ms

App size

Revision Plain With Sentry Diff
d15471f 1.58 MiB 2.13 MiB 559.54 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
1df7eb6 1.58 MiB 2.10 MiB 532.97 KiB
ee747ae 1.58 MiB 2.10 MiB 530.95 KiB
ee747ae 1.58 MiB 2.10 MiB 530.95 KiB
b193867 1.58 MiB 2.19 MiB 620.00 KiB
a416a65 1.58 MiB 2.12 MiB 555.26 KiB
b193867 1.58 MiB 2.19 MiB 620.00 KiB
b6cfb57 1.58 MiB 2.28 MiB 718.80 KiB
ee35ac3 1.58 MiB 2.13 MiB 558.77 KiB

Previous results on branch: claude/dreamy-solomon

Startup times

Revision Plain With Sentry Diff
c26f799 319.88 ms 358.02 ms 38.14 ms
b3c0878 316.40 ms 345.51 ms 29.11 ms

App size

Revision Plain With Sentry Diff
c26f799 0 B 0 B 0 B
b3c0878 0 B 0 B 0 B

@43jay 43jay marked this pull request as ready for review April 7, 2026 20:47
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_length
    • SentryEnvelopeItemHeader.serialize() now uses getMetaLength() (captured once in a local) so callable-backed Perfetto chunks correctly emit meta_length in envelope headers.

Create PR

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.

@43jay 43jay force-pushed the claude/dreamy-solomon branch from 4e173d3 to b4b28c9 Compare April 7, 2026 21:23
@43jay 43jay marked this pull request as draft April 9, 2026 17:30
43jay and others added 13 commits April 9, 2026 14:06
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>
…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 AtomicInteger
shared between the CachedItem lambda and the header callable, keeping
meta_length as an envelope transport concern rather than in
ProfileChunk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…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)
…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"
…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>
- Lock in isRunning(), getProfilerId(), getChunkId() so all public
  getters are synchronized with writes in startInternal/stopInternal
- Lock in reevaluateSampling()
- Remove volatile from shouldSample;  all accesses are now under the same lock
- 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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…startProfiler

shouldStop was always true, so we'd ever only collect max one
profile session (startProfiler(MANUAL)->stopProfiler(MANUAL)
plus one more chunk before profiler stopped itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…artProfiler

Same fix as PerfettoContinuousProfiler — shouldStop was never reset to
false after stopProfiler, so a stop/start cycle would leave
shouldStop=true and silently stop the profiler after one chunk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@43jay 43jay force-pushed the claude/dreamy-solomon branch from b4b28c9 to 83b1f1a Compare April 9, 2026 20:36
@43jay 43jay marked this pull request as ready for review April 9, 2026 20:36
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 10c415f. Configure here.

() -> {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
stopInternal(true);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lock held during blocking endAndCollect risks ANR

Medium Severity

The scheduled chunk timer acquires the reentrant lock and then calls stopInternal, which calls perfettoProfiler.endAndCollect(). That method calls resultLatch.await(5, SECONDS), blocking the thread while the lock is held. Any call to startProfiler, stopProfiler, isRunning, getChunkId, getProfilerId, or close from another thread (including the main/UI thread) will be blocked for up to 5 seconds. On Android, a 5-second main-thread block causes an ANR. The legacy AndroidContinuousProfiler doesn't have this problem because its endAndCollect is non-blocking.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10c415f. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love this cursor bot thing. was planning this work already -> see todos

null,
profileChunk.getPlatform(),
null,
(Callable<Integer>) metaLength::get);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMetaLength returns -1 instead of null before evaluation

Medium Severity

The metaLength is initialized as new AtomicInteger(-1), and the callable metaLength::get always returns a non-null Integer (autoboxed from int). So getMetaLength() never returns null when the callable is set — it returns -1 before data evaluation. In SentryEnvelopeItemHeader.serialize(), getMetaLength() is called before getLength(), so if the header is ever serialized without item.getData() being called first, meta_length: -1 is written. The current JsonSerializer flow protects against this, but the contract of returning @Nullable Integer is violated.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10c415f. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants