diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..0b0ea05200b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -41,8 +41,8 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android } public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { - public fun (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/util/LazyEvaluator$Evaluator;)V public fun close (Z)V + public static fun createLegacy (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/util/LazyEvaluator$Evaluator;)Lio/sentry/android/core/AndroidContinuousProfiler; public fun getChunkId ()Lio/sentry/protocol/SentryId; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRootSpanCounter ()I @@ -338,6 +338,25 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Lio/sentry/util/LazyEvaluator$Evaluator;)V + public fun close (Z)V + public fun getActiveTraceCount ()I + public fun getChunkId ()Lio/sentry/protocol/SentryId; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun isRunning ()Z + public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V + public fun reevaluateSampling ()V + public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V + public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V +} + +public class io/sentry/android/core/PerfettoProfiler { + public fun (Landroid/content/Context;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;)V + public fun endAndCollect ()Lio/sentry/android/core/AndroidProfiler$ProfileEndData; + public fun start (J)Lio/sentry/android/core/AndroidProfiler$ProfileStartData; +} + public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V public fun getOrder ()Ljava/lang/Long; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 41362c9d93e..30c9a503bde 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -68,13 +68,29 @@ public class AndroidContinuousProfiler private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private final AutoClosableReentrantLock payloadLock = new AutoClosableReentrantLock(); - public AndroidContinuousProfiler( + public static AndroidContinuousProfiler createLegacy( final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryFrameMetricsCollector frameMetricsCollector, final @NotNull ILogger logger, final @Nullable String profilingTracesDirPath, final int profilingTracesHz, final @NotNull LazyEvaluator.Evaluator executorServiceSupplier) { + return new AndroidContinuousProfiler( + buildInfoProvider, + frameMetricsCollector, + executorServiceSupplier, + logger, + profilingTracesHz, + profilingTracesDirPath); + } + + private AndroidContinuousProfiler( + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier, + final @NotNull ILogger logger, + final int profilingTracesHz, + final @Nullable String profilingTracesDirPath) { this.logger = logger; this.frameMetricsCollector = frameMetricsCollector; this.buildInfoProvider = buildInfoProvider; @@ -89,6 +105,7 @@ private void init() { return; } isInitialized = true; + if (profilingTracesDirPath == null) { logger.log( SentryLevel.WARNING, @@ -152,21 +169,24 @@ public void startProfiler( } } - private void initScopes() { + private void tryResolveScopes() { if ((scopes == null || scopes == NoOpScopes.getInstance()) && Sentry.getCurrentScopes() != NoOpScopes.getInstance()) { - this.scopes = Sentry.getCurrentScopes(); - this.performanceCollector = - Sentry.getCurrentScopes().getOptions().getCompositePerformanceCollector(); - final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); - if (rateLimiter != null) { - rateLimiter.addRateLimitObserver(this); - } + onScopesAvailable(Sentry.getCurrentScopes()); + } + } + + private void onScopesAvailable(final @NotNull IScopes resolvedScopes) { + this.scopes = resolvedScopes; + this.performanceCollector = resolvedScopes.getOptions().getCompositePerformanceCollector(); + final @Nullable RateLimiter rateLimiter = resolvedScopes.getRateLimiter(); + if (rateLimiter != null) { + rateLimiter.addRateLimitObserver(this); } } private void start() { - initScopes(); + tryResolveScopes(); // Debug.startMethodTracingSampling() is only available since Lollipop, but Android Profiler // causes crashes on api 21 -> https://github.com/getsentry/sentry-java/issues/3392 @@ -259,7 +279,7 @@ public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { } private void stop(final boolean restartProfiler) { - initScopes(); + tryResolveScopes(); try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (stopFuture != null) { stopFuture.cancel(true); @@ -297,14 +317,15 @@ private void stop(final boolean restartProfiler) { // start profiling), meaning there's no scopes to send the chunks. In that case, we store // the data in a list and send it when the next chunk is finished. try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { - payloadBuilders.add( + final ProfileChunk.Builder builder = new ProfileChunk.Builder( profilerId, chunkId, endData.measurementsMap, endData.traceFile, startProfileChunkTimestamp, - ProfileChunk.PLATFORM_ANDROID)); + ProfileChunk.PLATFORM_ANDROID); + payloadBuilders.add(builder); } } 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 index 5f7fad69b5d..7e098ea8627 100644 --- 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 @@ -2,6 +2,7 @@ import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME; +import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; @@ -293,6 +294,7 @@ static void initializeIntegrationsAndProcessors( } /** Setup the correct profiler (transaction or continuous) based on the options. */ + @SuppressLint("NewApi") private static void setupProfiler( final @NotNull SentryAndroidOptions options, final @NotNull Context context, @@ -335,16 +337,24 @@ private static void setupProfiler( performanceCollector.start(chunkId.toString()); } } else { + final @NotNull SentryFrameMetricsCollector frameMetricsCollector = + Objects.requireNonNull( + options.getFrameMetricsCollector(), "options.getFrameMetricsCollector is required"); options.setContinuousProfiler( - new AndroidContinuousProfiler( - buildInfoProvider, - Objects.requireNonNull( - options.getFrameMetricsCollector(), - "options.getFrameMetricsCollector is required"), - options.getLogger(), - options.getProfilingTracesDirPath(), - options.getProfilingTracesHz(), - () -> options.getExecutorService())); + options.isUseProfilingManager() + ? new PerfettoContinuousProfiler( + context, + buildInfoProvider, + frameMetricsCollector, + options.getLogger(), + () -> options.getExecutorService()) + : AndroidContinuousProfiler.createLegacy( + buildInfoProvider, + frameMetricsCollector, + options.getLogger(), + options.getProfilingTracesDirPath(), + options.getProfilingTracesHz(), + () -> options.getExecutorService())); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 822d7fbbe08..0cf09dbe51a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -108,6 +108,8 @@ final class ManifestMetadataReader { static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; + static final String USE_PROFILING_MANAGER = "io.sentry.profiling.use-profiling-manager"; + static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; @@ -494,6 +496,9 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); + options.setUseProfilingManager( + readBool(metadata, logger, USE_PROFILING_MANAGER, options.isUseProfilingManager())); + options.setEnableScopePersistence( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); 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 new file mode 100644 index 00000000000..f35a0f74db4 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java @@ -0,0 +1,399 @@ +package io.sentry.android.core; + +import static io.sentry.DataCategory.All; +import static io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED; + +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.CompositePerformanceCollector; +import io.sentry.DataCategory; +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpScopes; +import io.sentry.ProfileChunk; +import io.sentry.ProfileLifecycle; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.TracesSampler; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.protocol.SentryId; +import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; +import io.sentry.util.SentryRandom; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +/** + * Continuous profiler that uses Android's {@link android.os.ProfilingManager} (API 35+) to capture + * Perfetto stack-sampling traces. + * + *

This class is intentionally separate from {@link AndroidContinuousProfiler} to keep the two + * profiling backends independent. All ProfilingManager API usage is confined to this file and + * {@link PerfettoProfiler}. + * + *

Unlike the legacy profiler, this class is not used for app-start profiling. It is created + * during {@code Sentry.init()}, so scopes are always available when {@link #startProfiler} is + * called. + * + *

Thread safety: all mutable state is guarded by a single {@link + * io.sentry.util.AutoClosableReentrantLock}. Public entry points ({@link #startProfiler}, {@link + * #stopProfiler}, {@link #close}, {@link #onRateLimitChanged}, {@link #reevaluateSampling}, and the + * getters) acquire the lock themselves. Private methods {@code startInternal} and {@code + * stopInternal} require the caller to hold the lock. + */ +@ApiStatus.Internal +@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) +public class PerfettoContinuousProfiler + implements IContinuousProfiler, RateLimiter.IRateLimitObserver { + + private static final long MAX_CHUNK_DURATION_MILLIS = 60000; + + private final @NotNull ILogger logger; + private final @NotNull LazyEvaluator.Evaluator executorServiceSupplier; + private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull android.content.Context appContext; + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + + private boolean isInitialized = false; + private @Nullable PerfettoProfiler perfettoProfiler = null; + private boolean isRunning = false; + private @Nullable IScopes scopes; + private @Nullable CompositePerformanceCollector performanceCollector; + private @Nullable Future stopFuture; + private @NotNull SentryId profilerId = SentryId.EMPTY_ID; + private @NotNull SentryId chunkId = SentryId.EMPTY_ID; + private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false); + private @NotNull SentryDate startProfileChunkTimestamp = new io.sentry.SentryNanotimeDate(); + private boolean shouldSample = true; + private boolean shouldStop = false; + private boolean isSampled = false; + private int activeTraceCount = 0; + + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + public PerfettoContinuousProfiler( + final @NotNull android.content.Context context, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull ILogger logger, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier) { + this.appContext = context.getApplicationContext(); + this.buildInfoProvider = buildInfoProvider; + this.frameMetricsCollector = frameMetricsCollector; + this.logger = logger; + this.executorServiceSupplier = executorServiceSupplier; + } + + @Override + public void startProfiler( + final @NotNull ProfileLifecycle profileLifecycle, + final @NotNull TracesSampler tracesSampler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + if (!isSampled) { + logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); + return; + } + switch (profileLifecycle) { + case TRACE: + activeTraceCount = Math.max(0, activeTraceCount); // safety check. + activeTraceCount++; + break; + case MANUAL: + if (isRunning()) { + logger.log( + SentryLevel.WARNING, + "Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."); + return; + } + break; + } + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + startInternal(); + } + } + } + + @Override + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + activeTraceCount--; + activeTraceCount = Math.max(0, activeTraceCount); // safety check + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (activeTraceCount > 0) { + return; + } + shouldStop = true; + break; + case MANUAL: + shouldStop = true; + break; + } + } + } + + /** + * Stop the profiler as soon as we are rate limited, to avoid the performance overhead. + * + * @param rateLimiter the {@link RateLimiter} instance to check categories against + */ + @Override + public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) { + if (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stopInternal(false); + } + } + // If we are not rate limited anymore, we don't do anything: the profile is broken, so it's + // useless to restart it automatically + } + + @Override + public void close(final boolean isTerminating) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + activeTraceCount = 0; + shouldStop = true; + if (isTerminating) { + stopInternal(false); + isClosed.set(true); + } + } + } + + @Override + public @NotNull SentryId getProfilerId() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return profilerId; + } + } + + @Override + public @NotNull SentryId getChunkId() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return chunkId; + } + } + + @Override + public boolean isRunning() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return isRunning; + } + } + + /** + * Resolves scopes on first call. Since PerfettoContinuousProfiler is created during Sentry.init() + * and never used for app-start profiling, scopes is guaranteed to be available by the time + * startProfiler is called. + * + *

Caller must hold {@link #lock}. + */ + private @NotNull IScopes resolveScopes() { + if (scopes != null && scopes != NoOpScopes.getInstance()) { + return scopes; + } + final @NotNull IScopes currentScopes = Sentry.getCurrentScopes(); + if (currentScopes == NoOpScopes.getInstance()) { + logger.log( + SentryLevel.ERROR, + "PerfettoContinuousProfiler: scopes not available. This is unexpected."); + return currentScopes; + } + this.scopes = currentScopes; + this.performanceCollector = currentScopes.getOptions().getCompositePerformanceCollector(); + final @Nullable RateLimiter rateLimiter = currentScopes.getRateLimiter(); + if (rateLimiter != null) { + rateLimiter.addRateLimitObserver(this); + } + return scopes; + } + + /** Caller must hold {@link #lock}. */ + private void startInternal() { + final @NotNull IScopes scopes = resolveScopes(); + ensureProfiler(); + + if (perfettoProfiler == null) { + return; + } + + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null + && (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi))) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stopInternal(false); + return; + } + + // If device is offline, we don't start the profiler, to avoid flooding the cache + if (scopes.getOptions().getConnectionStatusProvider().getConnectionStatus() == DISCONNECTED) { + logger.log(SentryLevel.WARNING, "Device is offline. Stopping profiler."); + stopInternal(false); + return; + } + + startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now(); + + final AndroidProfiler.ProfileStartData startData = + perfettoProfiler.start(MAX_CHUNK_DURATION_MILLIS); + // check if profiling started + if (startData == null) { + return; + } + + isRunning = true; + + if (profilerId.equals(SentryId.EMPTY_ID)) { + profilerId = new SentryId(); + } + + if (chunkId.equals(SentryId.EMPTY_ID)) { + chunkId = new SentryId(); + } + + if (performanceCollector != null) { + performanceCollector.start(chunkId.toString()); + } + + try { + stopFuture = + executorServiceSupplier + .evaluate() + .schedule( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + stopInternal(true); + } + }, + MAX_CHUNK_DURATION_MILLIS); + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", + e); + shouldStop = true; + } + } + + /** Caller must hold {@link #lock}. */ + private void stopInternal(final boolean restartProfiler) { + if (stopFuture != null) { + stopFuture.cancel(false); + } + // check if profiler was created and it's running + if (perfettoProfiler == null || !isRunning) { + profilerId = SentryId.EMPTY_ID; + chunkId = SentryId.EMPTY_ID; + return; + } + + final @NotNull IScopes scopes = resolveScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + + if (performanceCollector != null) { + performanceCollector.stop(chunkId.toString()); + } + + final AndroidProfiler.ProfileEndData endData = perfettoProfiler.endAndCollect(); + + // check if profiler ended successfully + if (endData == null) { + logger.log( + SentryLevel.ERROR, + "An error occurred while collecting a profile chunk, and it won't be sent."); + } else { + final ProfileChunk.Builder builder = + new ProfileChunk.Builder( + profilerId, + chunkId, + endData.measurementsMap, + endData.traceFile, + startProfileChunkTimestamp, + ProfileChunk.PLATFORM_ANDROID); + builder.setContentType("perfetto"); + sendChunk(builder, scopes, options); + } + + isRunning = false; + // A chunk is finished. Next chunk will have a different id. + chunkId = SentryId.EMPTY_ID; + + if (restartProfiler && !shouldStop) { + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + startInternal(); + } else { + // When the profiler is stopped manually, we have to reset its id + profilerId = SentryId.EMPTY_ID; + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } + } + + private void ensureProfiler() { + logger.log( + SentryLevel.DEBUG, + "PerfettoContinuousProfiler.ensureProfiler() isInitialized=%s, apiLevel=%d", + isInitialized, + buildInfoProvider.getSdkInfoVersion()); + + if (!isInitialized) { + perfettoProfiler = new PerfettoProfiler(appContext, frameMetricsCollector, logger); + isInitialized = true; + } + } + + public void reevaluateSampling() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + shouldSample = true; + } + } + + private void sendChunk( + final @NotNull ProfileChunk.Builder builder, + final @NotNull IScopes scopes, + final @NotNull SentryOptions options) { + try { + options + .getExecutorService() + .submit( + () -> { + if (isClosed.get()) { + return; + } + scopes.captureProfileChunk(builder.build(options)); + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunk.", e); + } + } + + @VisibleForTesting + @Nullable + Future getStopFuture() { + return stopFuture; + } + + @VisibleForTesting + public int getActiveTraceCount() { + return activeTraceCount; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java new file mode 100644 index 00000000000..a5053eda746 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java @@ -0,0 +1,281 @@ +package io.sentry.android.core; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ProfilingManager; +import android.os.ProfilingResult; +import androidx.annotation.RequiresApi; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM) +public class PerfettoProfiler { + + private static final long RESULT_TIMEOUT_SECONDS = 5; + + // Bundle keys matching ProfilingManager constants + private static final String KEY_DURATION_MS = "KEY_DURATION_MS"; + private static final String KEY_FREQUENCY_HZ = "KEY_FREQUENCY_HZ"; + + /** Fixed sampling frequency for Perfetto stack sampling. Not configurable by the developer. */ + private static final int PROFILING_FREQUENCY_HZ = 100; + + private final @NotNull Context context; + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + private final @NotNull ILogger logger; + private @Nullable CancellationSignal cancellationSignal = null; + private @Nullable String frameMetricsCollectorId; + private volatile boolean isRunning = false; + private @Nullable ProfilingResult profilingResult = null; + private @Nullable CountDownLatch resultLatch = null; + + // ConcurrentLinkedDeque because onFrameMetricCollected (HandlerThread) and endAndCollect + // (executor thread) can access these concurrently. + private final @NotNull ConcurrentLinkedDeque + slowFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + frozenFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + screenFrameRateMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull Map measurementsMap = new HashMap<>(); + + /** + * Callback invoked exactly once per {@code requestProfiling} call, either on success (with a file + * path) or on error (with an error code). Cancelling via {@link CancellationSignal} also triggers + * this callback. + */ + private final @NotNull Consumer profilingResultListener; + + public PerfettoProfiler( + final @NotNull Context context, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull ILogger logger) { + this.context = context; + this.frameMetricsCollector = frameMetricsCollector; + this.logger = logger; + this.profilingResultListener = + result -> { + logger.log( + SentryLevel.DEBUG, + "Perfetto ProfilingResult received: errorCode=%d, filePath=%s", + result.getErrorCode(), + result.getResultFilePath()); + profilingResult = result; + if (resultLatch != null) { + resultLatch.countDown(); + } + }; + } + + public @Nullable AndroidProfiler.ProfileStartData start(final long durationMs) { + if (isRunning) { + logger.log(SentryLevel.WARNING, "Perfetto profiling has already started..."); + return null; + } + + final @Nullable ProfilingManager profilingManager = + (ProfilingManager) context.getSystemService(Context.PROFILING_SERVICE); + if (profilingManager == null) { + logger.log(SentryLevel.WARNING, "ProfilingManager is not available."); + return null; + } + + measurementsMap.clear(); + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.clear(); + screenFrameRateMeasurements.clear(); + + cancellationSignal = new CancellationSignal(); + resultLatch = new CountDownLatch(1); + profilingResult = null; + + final Bundle params = new Bundle(); + params.putInt(KEY_DURATION_MS, (int) durationMs); + params.putInt(KEY_FREQUENCY_HZ, PROFILING_FREQUENCY_HZ); + + try { + profilingManager.requestProfiling( + ProfilingManager.PROFILING_TYPE_STACK_SAMPLING, + params, + "sentry-profiling", + cancellationSignal, + Runnable::run, + profilingResultListener); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to request Perfetto profiling.", e); + cancellationSignal = null; + resultLatch = null; + return null; + } + + frameMetricsCollectorId = + frameMetricsCollector.startCollection( + new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { + float lastRefreshRate = 0; + + @Override + public void onFrameMetricCollected( + final long frameStartNanos, + final long frameEndNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final float refreshRate) { + final long timestampNanos = new SentryNanotimeDate().nanoTimestamp(); + if (isFrozen) { + frozenFrameRenderMeasurements.addLast( + new ProfileMeasurementValue(frameEndNanos, durationNanos, timestampNanos)); + } else if (isSlow) { + slowFrameRenderMeasurements.addLast( + new ProfileMeasurementValue(frameEndNanos, durationNanos, timestampNanos)); + } + if (refreshRate != lastRefreshRate) { + lastRefreshRate = refreshRate; + screenFrameRateMeasurements.addLast( + new ProfileMeasurementValue(frameEndNanos, refreshRate, timestampNanos)); + } + } + }); + + isRunning = true; + return new AndroidProfiler.ProfileStartData( + System.nanoTime(), + android.os.Process.getElapsedCpuTime(), + io.sentry.DateUtils.getCurrentDateTime()); + } + + public @Nullable AndroidProfiler.ProfileEndData endAndCollect() { + if (!isRunning) { + logger.log(SentryLevel.WARNING, "Perfetto profiler not running"); + return null; + } + isRunning = false; + + frameMetricsCollector.stopCollection(frameMetricsCollectorId); + + if (cancellationSignal != null) { + cancellationSignal.cancel(); + cancellationSignal = null; + } + + if (resultLatch != null) { + try { + if (!resultLatch.await(RESULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + logger.log(SentryLevel.WARNING, "Timed out waiting for Perfetto profiling result."); + return null; + } + } catch (InterruptedException e) { + logger.log(SentryLevel.WARNING, "Interrupted while waiting for Perfetto profiling result."); + Thread.currentThread().interrupt(); + return null; + } + } + + if (profilingResult == null) { + logger.log(SentryLevel.WARNING, "Perfetto profiling result is null."); + return null; + } + + final int errorCode = profilingResult.getErrorCode(); + if (errorCode != ProfilingResult.ERROR_NONE) { + switch (errorCode) { + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS: + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_SYSTEM: + logger.log( + SentryLevel.DEBUG, + "Perfetto profiling failed: %s." + + " To disable during development run:" + + " adb shell device_config put profiling_testing rate_limiter.disabled true", + errorCodeToString(errorCode)); + break; + default: + logger.log( + SentryLevel.WARNING, + "Perfetto profiling failed with %s (error code %d): %s." + + " See https://developer.android.com/reference/android/os/ProfilingResult", + errorCodeToString(errorCode), + errorCode, + profilingResult.getErrorMessage()); + break; + } + return null; + } + + final @Nullable String resultFilePath = profilingResult.getResultFilePath(); + if (resultFilePath == null) { + logger.log(SentryLevel.WARNING, "Perfetto profiling result file path is null."); + return null; + } + + final File traceFile = new File(resultFilePath); + if (!traceFile.exists() || traceFile.length() == 0) { + logger.log(SentryLevel.WARNING, "Perfetto trace file does not exist or is empty."); + return null; + } + + if (!slowFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement(ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + } + + return new AndroidProfiler.ProfileEndData( + System.nanoTime(), + android.os.Process.getElapsedCpuTime(), + false, + traceFile, + measurementsMap); + } + + boolean isRunning() { + return isRunning; + } + + private static @NotNull String errorCodeToString(final int errorCode) { + switch (errorCode) { + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS: + return "ERROR_FAILED_RATE_LIMIT_PROCESS"; + case ProfilingResult.ERROR_FAILED_RATE_LIMIT_SYSTEM: + return "ERROR_FAILED_RATE_LIMIT_SYSTEM"; + case ProfilingResult.ERROR_FAILED_INVALID_REQUEST: + return "ERROR_FAILED_INVALID_REQUEST"; + case ProfilingResult.ERROR_FAILED_PROFILING_IN_PROGRESS: + return "ERROR_FAILED_PROFILING_IN_PROGRESS"; + case ProfilingResult.ERROR_FAILED_POST_PROCESSING: + return "ERROR_FAILED_POST_PROCESSING"; + case ProfilingResult.ERROR_UNKNOWN: + return "ERROR_UNKNOWN"; + default: + return "UNKNOWN_ERROR_CODE"; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 7e43d626b34..51799998c6f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -129,6 +129,14 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri return; } + if (profilingOptions.isUseProfilingManager()) { + logger.log( + SentryLevel.DEBUG, + "useProfilingManager is enabled. Skipping legacy app-start profiling — " + + "ProfilingManager will be initialized after Sentry.init()."); + return; + } + if (profilingOptions.isContinuousProfilingEnabled() && profilingOptions.isStartProfilerOnAppStart()) { createAndStartContinuousProfiler(context, profilingOptions, appStartMetrics); @@ -163,7 +171,7 @@ private void createAndStartContinuousProfiler( final @NotNull SentryExecutorService startupExecutorService = new SentryExecutorService(); final @NotNull IContinuousProfiler appStartContinuousProfiler = - new AndroidContinuousProfiler( + AndroidContinuousProfiler.createLegacy( buildInfoProvider, new SentryFrameMetricsCollector( context.getApplicationContext(), logger, buildInfoProvider), diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 162e56c36e3..a35d2cf5039 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -92,7 +92,7 @@ class AndroidContinuousProfilerTest { transaction1 = SentryTracer(TransactionContext("", ""), scopes) transaction2 = SentryTracer(TransactionContext("", ""), scopes) transaction3 = SentryTracer(TransactionContext("", ""), scopes) - return AndroidContinuousProfiler( + return AndroidContinuousProfiler.createLegacy( buildInfoProvider, frameMetricsCollector, options.logger, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index ba01a9ecf7a..0439c51a0e6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1469,6 +1469,31 @@ class ManifestMetadataReaderTest { assertFalse(fixture.options.isEnableAppStartProfiling) } + @Test + fun `applyMetadata reads useProfilingManager flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.USE_PROFILING_MANAGER to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isUseProfilingManager) + } + + @Test + fun `applyMetadata reads useProfilingManager flag to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isUseProfilingManager) + } + @Test fun `applyMetadata reads enableScopePersistence flag to options`() { // Arrange 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 new file mode 100644 index 00000000000..23ee35601b9 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -0,0 +1,120 @@ +package io.sentry.android.core + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DataCategory +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.test.injectForField +import io.sentry.transport.RateLimiter +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class PerfettoContinuousProfilerTest { + private lateinit var context: Context + private val fixture = Fixture() + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val buildInfo = + mock { + whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.VANILLA_ICE_CREAM) + } + val executor = DeferredExecutorService() + val mockedSentry = mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + val mockPerfettoProfiler = mock() + + val scopes: IScopes = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() + + val options = + spy(SentryAndroidOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + whenever(mockPerfettoProfiler.start(any())) + .thenReturn( + AndroidProfiler.ProfileStartData( + System.nanoTime(), + 0L, + io.sentry.DateUtils.getCurrentDateTime(), + ) + ) + } + + fun getSut(): PerfettoContinuousProfiler { + options.executorService = executor + whenever(scopes.options).thenReturn(options) + val profiler = + PerfettoContinuousProfiler( + ApplicationProvider.getApplicationContext(), + buildInfo, + frameMetricsCollector, + mockLogger, + { options.executorService }, + ) + // Inject mock PerfettoProfiler to avoid hitting real ProfilingManager API + profiler.injectForField("perfettoProfiler", mockPerfettoProfiler) + profiler.injectForField("isInitialized", true) + return profiler + } + } + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + Sentry.setCurrentScopes(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + } + + @AfterTest + fun clear() { + fixture.mockedSentry.close() + } + + @Test + fun `profiler stops when rate limited`() { + val profiler = fixture.getSut() + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + profiler.onRateLimitChanged(rateLimiter) + assertFalse(profiler.isRunning) + assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + assertEquals(SentryId.EMPTY_ID, profiler.chunkId) + verify(fixture.mockLogger) + .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 548e5e8ac0d..438fdfcb803 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -66,7 +66,8 @@ + android:exported="false" + android:theme="@style/AppTheme.Main" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index 8626c12c6c8..427b33a7934 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt @@ -1,13 +1,16 @@ package io.sentry.samples.android +import android.os.Build import android.os.Bundle import android.view.View import android.widget.SeekBar import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import io.sentry.ITransaction +import io.sentry.ProfileLifecycle import io.sentry.ProfilingTraceData import io.sentry.Sentry import io.sentry.SentryEnvelopeItem @@ -22,6 +25,8 @@ class ProfilingActivity : AppCompatActivity() { private lateinit var binding: ActivityProfilingBinding private val executors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) private var profileFinished = true + private var manualProfilingActive = false + private var lastProfilingResult: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -30,7 +35,7 @@ class ProfilingActivity : AppCompatActivity() { this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (profileFinished) { + if (profileFinished && !manualProfilingActive) { isEnabled = false onBackPressedDispatcher.onBackPressed() } else { @@ -42,6 +47,50 @@ class ProfilingActivity : AppCompatActivity() { ) binding = ActivityProfilingBinding.inflate(layoutInflater) + val options = Sentry.getCurrentScopes().options + val isPerfetto = options.isUseProfilingManager && Build.VERSION.SDK_INT >= 35 + val isContinuousEnabled = options.isContinuousProfilingEnabled + val lifecycle = options.profileLifecycle + + // Status line: summarize the active profiler + binding.profilingStatus.text = + when { + !isContinuousEnabled -> getString(R.string.profiling_status_none) + isPerfetto -> getString(R.string.profiling_status_perfetto) + else -> getString(R.string.profiling_status_legacy) + } + + // Info button: show detailed config and last result in a dialog + binding.profilingInfo.setOnClickListener { + val config = buildString { + appendLine("traces.profiling.lifecycle: ${lifecycle.name}") + appendLine("profiling.use-profiling-manager: ${options.isUseProfilingManager}") + appendLine("Build.VERSION.SDK_INT: ${Build.VERSION.SDK_INT}") + appendLine("traces.profiling.session-sample-rate: ${options.profileSessionSampleRate}") + appendLine("traces.sample-rate: ${options.tracesSampleRate}") + if (lastProfilingResult != null) { + appendLine() + append(lastProfilingResult) + } + } + AlertDialog.Builder(this) + .setTitle(R.string.profiling_config_title) + .setMessage(config) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + // Show only the controls relevant to the current lifecycle mode + when (lifecycle) { + ProfileLifecycle.MANUAL -> { + binding.profilingStartTransaction.visibility = View.GONE + // Duration slider only controls transaction length — irrelevant for manual mode + binding.profilingDurationText.visibility = View.GONE + binding.profilingDurationSeekbar.visibility = View.GONE + } + ProfileLifecycle.TRACE -> binding.profilingStartTransactionManual.visibility = View.GONE + } + binding.profilingDurationSeekbar.setOnSeekBarChangeListener( object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(p0: SeekBar, p1: Int, p2: Boolean) { @@ -76,7 +125,8 @@ class ProfilingActivity : AppCompatActivity() { binding.profilingList.adapter = ProfilingListAdapter() binding.profilingList.layoutManager = LinearLayoutManager(this) - binding.profilingStart.setOnClickListener { + // Transaction-based profiling (existing) + binding.profilingStartTransaction.setOnClickListener { binding.profilingProgressBar.visibility = View.VISIBLE profileFinished = false val seconds = getProfileDuration() @@ -92,6 +142,33 @@ class ProfilingActivity : AppCompatActivity() { } .start() } + + // Manual continuous profiling (exercises Perfetto path on API 35+) + binding.profilingStartTransactionManual.setOnClickListener { + if (!manualProfilingActive) { + Sentry.startProfiler() + manualProfilingActive = true + profileFinished = false + binding.profilingStartTransactionManual.text = getString(R.string.profiling_stop_manual) + binding.profilingProgressBar.visibility = View.VISIBLE + + // Start background work to generate interesting profile data + val threads = getBackgroundThreads() + repeat(threads) { executors.submit { runMathOperations() } } + executors.submit { swipeList() } + + Toast.makeText(this, R.string.profiling_manual_started, Toast.LENGTH_SHORT).show() + } else { + Sentry.stopProfiler() + manualProfilingActive = false + profileFinished = true + binding.profilingStartTransactionManual.text = getString(R.string.profiling_start_manual) + binding.profilingProgressBar.visibility = View.GONE + + Toast.makeText(this, R.string.profiling_manual_stopped, Toast.LENGTH_SHORT).show() + } + } + setContentView(binding.root) Sentry.reportFullyDisplayed() } @@ -136,9 +213,10 @@ class ProfilingActivity : AppCompatActivity() { val bos = ByteArrayOutputStream() GZIPOutputStream(bos).bufferedWriter().use { it.write(String(itemData)) } + lastProfilingResult = + getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) binding.root.post { - binding.profilingResult.text = - getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) + Toast.makeText(this, "Profile captured — tap (i) for details", Toast.LENGTH_SHORT).show() } } catch (e: Exception) { e.printStackTrace() diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt index bf025118c80..75617be69ad 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingListAdapter.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageView +import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import io.sentry.samples.android.databinding.ProfilingItemListBinding import kotlin.random.Random @@ -17,6 +18,7 @@ class ProfilingListAdapter : RecyclerView.Adapter() { } override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.indexView.text = "${position + 1}" holder.imageView.setImageBitmap(generateBitmap()) } @@ -37,5 +39,6 @@ class ProfilingListAdapter : RecyclerView.Adapter() { } class ViewHolder(binding: ProfilingItemListBinding) : RecyclerView.ViewHolder(binding.root) { + val indexView: TextView = binding.benchmarkItemListIndex val imageView: ImageView = binding.benchmarkItemListImage } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml index 8100834f78b..8207d9d2288 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_profiling.xml @@ -3,7 +3,33 @@ xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + + + + + + + + - + android:orientation="horizontal" + android:gravity="center"> -