Skip to content

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

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

feat(profiling): Add Android ProfilingManager (Perfetto) support#5251
43jay wants to merge 10 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 Creation site Callers on PerfettoContinuousProfiler
Caller's thread (main/app) Not created by Sentry startProfiler, stopProfiler, close(true), reevaluateSampling
Timer daemon (RateLimiter) JDK internal — [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)
Timer daemon (LifecycleWatcher) JDK internal — [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)
OTel span processor Created by OpenTelemetry SDK — not Sentry-controlled 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. Also sendChunk() submits work here.
Timer daemon (CompositePerformanceCollector) JDK internal — [DefaultCompositePerformanceCollector.java:93](https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java#L93) new Timer(true) Not a direct caller. performanceCollector.start()/.stop() are called from startInternal/stopInternal under 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.
FrameMetrics HandlerThread [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") Not a direct caller. Writes to 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.
  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

  • Investigate missing thread names in PROFILING_TYPE_STACK_SAMPLING traces (ProfilingManager doesn't seem to include linux.process_stats data source)
  • Add more unit tests - e.g. no connectivity, lifecycleMode=TRACE
  • Verify backend ingest WAE
  • Try to break the impl / cover more edge cases
  • Docs update once feature is stable
  • Add CHANGELOG after getting some review feedback #skip-changelog

43jay and others added 4 commits March 30, 2026 14:21
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>
@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 b4b28c9

@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.37.1 (1) release Install Build

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 319.88 ms 358.02 ms 38.14 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

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

Previous results on branch: claude/dreamy-solomon

Startup times

Revision Plain With Sentry Diff
b3c0878 316.40 ms 345.51 ms 29.11 ms

App size

Revision Plain With Sentry Diff
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.

}
if (metaLength != null) {
writer.name(JsonKeys.META_LENGTH).value(metaLength);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e173d3. Configure here.

43jay and others added 6 commits April 7, 2026 16:59
…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);
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.

ERROR

@43jay 43jay force-pushed the claude/dreamy-solomon branch from 4e173d3 to b4b28c9 Compare April 7, 2026 21:23
Comment on lines +171 to +180
public void close(final boolean isTerminating) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
activeTraceCount = 0;
shouldStop = true;
if (isTerminating) {
stopInternal(false);
isClosed.set(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.

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.

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.

There are 2 total unresolved issues (including 1 from previous review).

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 b4b28c9. Configure here.

startInternal();
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b4b28c9. 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.

1 participant