diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..4ab73ee3f15 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 (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/ILogger;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;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/ILogger;)V + public fun endAndCollect ()Ljava/io/File; + public fun start (J)Z +} + 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..9dfe95a2dca 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,25 @@ 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( + buildInfoProvider, + options.getLogger(), + frameMetricsCollector, + () -> options.getExecutorService(), + () -> + new PerfettoProfiler(context.getApplicationContext(), options.getLogger())) + : 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 6d90bb5ca8e..6b41532aa66 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"; @@ -497,6 +499,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..bd4ce8c5536 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java @@ -0,0 +1,569 @@ +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.PerformanceCollectionData; +import io.sentry.ProfileChunk; +import io.sentry.ProfileLifecycle; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.TracesSampler; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; +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.io.File; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; +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}. + * + *

Currently, this class doesn't do app-start profiling {@link SentryPerformanceProvider}. It is + * created during {@code Sentry.init()}. + * + *

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 and are thread-safe. 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 LazyEvaluator.Evaluator perfettoProfilerSupplier; + + private @Nullable PerfettoProfiler perfettoProfiler = null; + private final @NotNull ChunkMeasurementCollector chunkMeasurements; + 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 BuildInfoProvider buildInfoProvider, + final @NotNull ILogger logger, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, + final @NotNull LazyEvaluator.Evaluator executorServiceSupplier, + final @NotNull LazyEvaluator.Evaluator perfettoProfilerSupplier) { + this.buildInfoProvider = buildInfoProvider; + this.logger = logger; + this.chunkMeasurements = new ChunkMeasurementCollector(frameMetricsCollector); + this.executorServiceSupplier = executorServiceSupplier; + this.perfettoProfilerSupplier = perfettoProfilerSupplier; + } + + @Override + public void startProfiler( + final @NotNull ProfileLifecycle profileLifecycle, + final @NotNull TracesSampler tracesSampler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + shouldStop = false; + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + 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(); + + if (!perfettoProfiler.start(MAX_CHUNK_DURATION_MILLIS)) { + logger.log( + SentryLevel.ERROR, + "Failed to start Perfetto profiling. PerfettoProfiler.start() returned false."); + return; + } + + isRunning = true; + + if (profilerId.equals(SentryId.EMPTY_ID)) { + profilerId = new SentryId(); + } + + if (chunkId.equals(SentryId.EMPTY_ID)) { + chunkId = new SentryId(); + } + + chunkMeasurements.start(performanceCollector, 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); + } + + // Make sure perfetto was 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(); + + final @NotNull Map measurements = chunkMeasurements.stop(); + + final @Nullable File traceFile = perfettoProfiler.endAndCollect(); + + if (traceFile == 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, + measurements, + 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() { + if (perfettoProfiler == null) { + logger.log( + SentryLevel.DEBUG, + "PerfettoContinuousProfiler: creating PerfettoProfiler (apiLevel=%d)", + buildInfoProvider.getSdkInfoVersion()); + perfettoProfiler = perfettoProfilerSupplier.evaluate(); + } + } + + 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; + } + + /** + * Collects measurements for a single profiling chunk: frame metrics (slow/frozen frames, refresh + * rate) and performance data (CPU usage, memory footprint). + * + *

Frame metrics are delivered on the FrameMetrics HandlerThread. The deques use {@link + * ConcurrentLinkedDeque} because the HandlerThread writes and the executor thread reads. + * + *

Performance data is collected by the {@link CompositePerformanceCollector}'s Timer thread + * every 100ms and returned as a list on {@code stop()}. + */ + private static class ChunkMeasurementCollector { + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; + private @Nullable String frameMetricsListenerId = null; + private @Nullable CompositePerformanceCollector performanceCollector = null; + private @Nullable String chunkId = null; + + private final @NotNull ConcurrentLinkedDeque + slowFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + frozenFrameRenderMeasurements = new ConcurrentLinkedDeque<>(); + private final @NotNull ConcurrentLinkedDeque + screenFrameRateMeasurements = new ConcurrentLinkedDeque<>(); + + ChunkMeasurementCollector(final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { + this.frameMetricsCollector = frameMetricsCollector; + } + + void start( + final @Nullable CompositePerformanceCollector performanceCollector, + final @NotNull String chunkId) { + this.performanceCollector = performanceCollector; + this.chunkId = chunkId; + + // Start frame metrics collection (runs on the FrameMetrics HandlerThread) + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.clear(); + screenFrameRateMeasurements.clear(); + frameMetricsListenerId = + 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)); + } + } + }); + + // Start performance collection (runs on CompositePerformanceCollector's Timer thread) + if (performanceCollector != null) { + performanceCollector.start(chunkId); + } + } + + /** + * Stops all collection, builds and returns the combined measurements map containing frame + * metrics and performance data (CPU, memory). + */ + @NotNull + Map stop() { + final @NotNull Map measurements = new HashMap<>(); + // Stop frame metrics + frameMetricsCollector.stopCollection(frameMetricsListenerId); + frameMetricsListenerId = null; + addFrameDataToMeasurements(measurements); + + // Stop performance collection + @Nullable List performanceData = null; + if (performanceCollector != null && chunkId != null) { + performanceData = performanceCollector.stop(chunkId); + addPerformanceDataToMeasurements(performanceData, measurements); + } + performanceCollector = null; + chunkId = null; + + return measurements; + } + + private void addFrameDataToMeasurements( + final @NotNull Map measurements) { + if (!slowFrameRenderMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + } + } + + private static void addPerformanceDataToMeasurements( + final @Nullable List performanceData, + final @NotNull Map measurements) { + if (performanceData == null || performanceData.isEmpty()) { + return; + } + final @NotNull ArrayDeque cpuUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + final @NotNull ArrayDeque memoryUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + final @NotNull ArrayDeque nativeMemoryUsageMeasurements = + new ArrayDeque<>(performanceData.size()); + + for (final @NotNull PerformanceCollectionData data : performanceData) { + final long nanoTimestamp = data.getNanoTimestamp(); + final @Nullable Double cpuUsagePercentage = data.getCpuUsagePercentage(); + final @Nullable Long usedHeapMemory = data.getUsedHeapMemory(); + final @Nullable Long usedNativeMemory = data.getUsedNativeMemory(); + + if (cpuUsagePercentage != null) { + cpuUsageMeasurements.addLast( + new ProfileMeasurementValue(nanoTimestamp, cpuUsagePercentage, nanoTimestamp)); + } + if (usedHeapMemory != null) { + memoryUsageMeasurements.addLast( + new ProfileMeasurementValue(nanoTimestamp, usedHeapMemory, nanoTimestamp)); + } + if (usedNativeMemory != null) { + nativeMemoryUsageMeasurements.addLast( + new ProfileMeasurementValue(nanoTimestamp, usedNativeMemory, nanoTimestamp)); + } + } + + if (!cpuUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_CPU_USAGE, + new ProfileMeasurement(ProfileMeasurement.UNIT_PERCENT, cpuUsageMeasurements)); + } + if (!memoryUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_MEMORY_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, memoryUsageMeasurements)); + } + if (!nativeMemoryUsageMeasurements.isEmpty()) { + measurements.put( + ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT, + new ProfileMeasurement(ProfileMeasurement.UNIT_BYTES, nativeMemoryUsageMeasurements)); + } + } + } +} 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..a67b0cb6b86 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java @@ -0,0 +1,201 @@ +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 java.io.File; +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; + +/** Wraps Android's {@link ProfilingManager} API for Perfetto stack sampling. */ +@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 ILogger logger; + private @Nullable CancellationSignal cancellationSignal = null; + private volatile boolean isRunning = false; + private @Nullable ProfilingResult profilingResult = null; + private @Nullable CountDownLatch resultLatch = null; + + /** + * 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 ILogger logger) { + this.context = context; + 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 boolean start(final long durationMs) { + if (isRunning) { + logger.log(SentryLevel.WARNING, "Perfetto profiling has already started..."); + return false; + } + + final @Nullable ProfilingManager profilingManager = + (ProfilingManager) context.getSystemService(Context.PROFILING_SERVICE); + if (profilingManager == null) { + logger.log(SentryLevel.WARNING, "ProfilingManager is not available."); + return false; + } + + 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 false; + } + + isRunning = true; + return true; + } + + /** + * Cancels the current profiling session and blocks until the result is available (up to 5 + * seconds). Returns the trace file on success, or null on error/timeout. + */ + public @Nullable File endAndCollect() { + if (!isRunning) { + logger.log(SentryLevel.WARNING, "Perfetto profiler not running"); + return null; + } + isRunning = false; + + 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; + } + + return traceFile; + } + + 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..e26e948f39f 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 @@ -5,7 +5,6 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CompositePerformanceCollector -import io.sentry.DataCategory import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.IScopes @@ -18,10 +17,8 @@ import io.sentry.TracesSampler import io.sentry.TransactionContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.profilemeasurements.ProfileMeasurement -import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import io.sentry.transport.RateLimiter import java.io.File import java.util.concurrent.Future import kotlin.test.AfterTest @@ -30,12 +27,10 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.mockito.Mockito import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -51,6 +46,7 @@ import org.mockito.kotlin.whenever class AndroidContinuousProfilerTest { private lateinit var context: Context private val fixture = Fixture() + private lateinit var mocks: ProfilerMocks private class Fixture { private val mockDsn = "http://key@localhost/proj" @@ -92,7 +88,7 @@ class AndroidContinuousProfilerTest { transaction1 = SentryTracer(TransactionContext("", ""), scopes) transaction2 = SentryTracer(TransactionContext("", ""), scopes) transaction3 = SentryTracer(TransactionContext("", ""), scopes) - return AndroidContinuousProfiler( + return AndroidContinuousProfiler.createLegacy( buildInfoProvider, frameMetricsCollector, options.logger, @@ -143,6 +139,8 @@ class AndroidContinuousProfilerTest { Sentry.setCurrentScopes(fixture.scopes) fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + mocks = + ProfilerMocks(fixture.executor, fixture.mockTracesSampler, fixture.mockLogger, fixture.scopes) } @AfterTest @@ -151,110 +149,148 @@ class AndroidContinuousProfilerTest { fixture.mockedSentry.close() } + // -- TODO: Could be shared with PerfettoContinuousProfiler with some refactoring -- + @Test - fun `isRunning reflects profiler status`() { - val profiler = fixture.getSut() + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - assertFalse(profiler.isRunning) } @Test - fun `stopProfiler stops the profiler after chunk is finished`() { + fun `profiler starts performance collector on start`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector val profiler = fixture.getSut() + verify(performanceCollector, never()).start(any()) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + verify(performanceCollector).start(any()) + } + + @Test + fun `profiler stops performance collector on stop`() { + val performanceCollector = mock() + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(performanceCollector, never()).stop(any()) profiler.stopProfiler(ProfileLifecycle.MANUAL) - assertTrue(profiler.isRunning) - assertNotEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertNotEquals(SentryId.EMPTY_ID, profiler.chunkId) - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart fixture.executor.runAll() - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) + verify(performanceCollector).stop(any()) } @Test - fun `profiler multiple starts are ignored in manual mode`() { + fun `profiler stops collecting frame metrics when it stops`() { val profiler = fixture.getSut() + val frameMetricsCollectorId = "id" + whenever(fixture.frameMetricsCollector.startCollection(any())) + .thenReturn(frameMetricsCollectorId) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(0, profiler.rootSpanCounter) + verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) } @Test - fun `profiler multiple starts are accepted in trace mode`() { - val profiler = fixture.getSut() + fun `profiler sends chunk with measurements`() { + val performanceCollector = mock() + val collectionData = PerformanceCollectionData(10) - // rootSpanCounter is incremented when the profiler starts in trace mode - assertEquals(0, profiler.rootSpanCounter) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - verify(fixture.mockLogger, never()) - .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) - assertTrue(profiler.isRunning) - assertEquals(2, profiler.rootSpanCounter) + collectionData.usedHeapMemory = 2 + collectionData.usedNativeMemory = 3 + collectionData.cpuUsagePercentage = 3.0 + whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until - // rootSpanCounter is 0 - profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.options.compositePerformanceCollector = performanceCollector + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - assertEquals(1, profiler.rootSpanCounter) - assertTrue(profiler.isRunning) - - // only when rootSpanCounter is 0 the profiler stops - profiler.stopProfiler(ProfileLifecycle.TRACE) fixture.executor.runAll() - assertEquals(0, profiler.rootSpanCounter) - assertFalse(profiler.isRunning) + verify(fixture.scopes) + .captureProfileChunk( + check { + assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) + assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) + } + ) } + // -- Shared tests (see ContinuousProfilerTestCases.kt) -- + @Test - fun `profiler logs a warning on start if not sampled`() { - val profiler = fixture.getSut() - whenever(fixture.mockTracesSampler.sampleSessionProfile(any())).thenReturn(false) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) - } + fun `isRunning reflects profiler status`() = fixture.getSut().testIsRunningReflectsStatus(mocks) @Test - fun `profiler evaluates sessionSampleRate only the first time`() { - val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, the sessionSampleRate is not evaluated again - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - } + fun `stopProfiler stops the profiler after chunk is finished`() = + fixture.getSut().testStopProfilerStopsAfterChunkFinished(mocks) + + @Test + fun `profiler multiple starts are accepted in trace mode`() = + fixture.getSut().testMultipleStartsAcceptedInTraceMode(mocks) + + @Test + fun `profiler logs a warning on start if not sampled`() = + fixture.getSut().testLogsWarningIfNotSampled(mocks) + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() = + fixture.getSut().testEvaluatesSessionSampleRateOnlyOnce(mocks) + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() = + fixture.getSut().testReevaluateSamplingOnNextStart(mocks) + + @Test + fun `profiler stops and restart for each chunk`() = + fixture.getSut().testStopsAndRestartsForEachChunk(mocks) + + @Test + fun `profiler sends chunk on each restart`() = fixture.getSut().testSendsChunkOnRestart(mocks) + + @Test fun `profiler sends another chunk on stop`() = fixture.getSut().testSendsChunkOnStop(mocks) + + @Test + fun `close without terminating stops all profiles after chunk is finished`() = + fixture.getSut().testCloseWithoutTerminatingStopsAfterChunk(mocks) + + @Test + fun `profiler does not send chunks after close`() = + fixture.getSut().testDoesNotSendChunksAfterClose(mocks) + + @Test fun `profiler stops when rate limited`() = fixture.getSut().testStopsWhenRateLimited(mocks) + + @Test + fun `profiler does not start when rate limited`() = + fixture.getSut().testDoesNotStartWhenRateLimited(mocks) @Test - fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() { + fun `profiler does not start when offline`() = + fixture + .getSut { + it.connectionStatusProvider = mock { provider -> + whenever(provider.connectionStatus) + .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + } + .testDoesNotStartWhenOffline(mocks) + + // -- Legacy-specific tests (AndroidContinuousProfiler only) -- + + @Test + fun `profiler multiple starts are ignored in manual mode`() { val profiler = fixture.getSut() - verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) - // The first time the profiler is started, the sessionSampleRate is evaluated profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // When reevaluateSampling is called, the sessionSampleRate is not evaluated immediately - profiler.reevaluateSampling() - verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) - // Then, when the profiler starts again, the sessionSampleRate is reevaluated + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.mockTracesSampler, times(2)).sampleSessionProfile(any()) + verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + assertTrue(profiler.isRunning) + assertEquals(0, profiler.rootSpanCounter) } @Test @@ -268,25 +304,14 @@ class AndroidContinuousProfilerTest { assertFalse(profiler.isRunning) } - @Test - fun `profiler ignores profilesSampleRate`() { - val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - } - @Test fun `profiler evaluates profilingTracesDirPath options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.cacheDirPath = null } verify(fixture.mockLogger, never()) .log( SentryLevel.WARNING, "Disabling profiling because no profiling traces dir path is defined in options.", ) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -298,13 +323,9 @@ class AndroidContinuousProfilerTest { @Test fun `profiler evaluates profilingTracesHz options only on first start`() { - // We create the profiler, and nothing goes wrong val profiler = fixture.getSut { it.profilingTracesHz = 0 } verify(fixture.mockLogger, never()) .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) - - // Regardless of how many times the profiler is started, the option is evaluated and logged only - // once profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.mockLogger, times(1)) @@ -338,47 +359,11 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() - // We assert that no trace files are written assertTrue(File(fixture.options.profilingTracesDirPath!!).list()!!.isEmpty()) verify(fixture.mockLogger) .log(eq(SentryLevel.ERROR), eq("Error while stopping profiling: "), any()) } - @Test - fun `profiler starts performance collector on start`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - verify(performanceCollector, never()).start(any()) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector).start(any()) - } - - @Test - fun `profiler stops performance collector on stop`() { - val performanceCollector = mock() - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(performanceCollector, never()).stop(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(performanceCollector).stop(any()) - } - - @Test - fun `profiler stops collecting frame metrics when it stops`() { - val profiler = fixture.getSut() - val frameMetricsCollectorId = "id" - whenever(fixture.frameMetricsCollector.startCollection(any())) - .thenReturn(frameMetricsCollectorId) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - fixture.executor.runAll() - verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) - } - @Test fun `profiler stops profiling and clear scheduled job on close`() { val profiler = fixture.getSut() @@ -388,7 +373,6 @@ class AndroidContinuousProfilerTest { profiler.close(true) assertFalse(profiler.isRunning) - // The timeout scheduled job should be cleared val androidProfiler = profiler.getProperty("profiler") val scheduledJob = androidProfiler?.getProperty?>("scheduledFinish") assertNull(scheduledJob) @@ -398,166 +382,8 @@ class AndroidContinuousProfilerTest { assertTrue(stopFuture.isCancelled || stopFuture.isDone) } - @Test - fun `profiler stops and restart for each chunk`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - val oldChunkId = profiler.chunkId - - fixture.executor.runAll() - verify(fixture.mockLogger) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - - fixture.executor.runAll() - verify(fixture.mockLogger, times(2)) - .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) - assertTrue(profiler.isRunning) - assertNotEquals(oldChunkId, profiler.chunkId) - } - - @Test - fun `profiler sends chunk on each restart`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - // Now the executor is used to send the chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `profiler sends chunk with measurements`() { - val performanceCollector = mock() - val collectionData = PerformanceCollectionData(10) - - collectionData.usedHeapMemory = 2 - collectionData.usedNativeMemory = 3 - collectionData.cpuUsagePercentage = 3.0 - whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) - - fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We run the executor service to stop the profiler - fixture.executor.runAll() - // Then we run it again to send the profile chunk - fixture.executor.runAll() - verify(fixture.scopes) - .captureProfileChunk( - check { - assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_FOOTPRINT) - assertContains(it.measurements, ProfileMeasurement.ID_MEMORY_NATIVE_FOOTPRINT) - } - ) - } - - @Test - fun `profiler sends another chunk on stop`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the profiler restart (chunk finish) - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We stop the profiler, which should send a chunk - fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) - } - - @Test - fun `close without terminating stops all profiles after chunk is finished`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running - profiler.close(false) - assertTrue(profiler.isRunning) - // However, close() already resets the rootSpanCounter - assertEquals(0, profiler.rootSpanCounter) - - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart - fixture.executor.runAll() - assertFalse(profiler.isRunning) - } - - @Test - fun `profiler does not send chunks after close`() { - val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertTrue(profiler.isRunning) - - // We close the profiler, which should prevent sending additional chunks - profiler.close(true) - - // The executor used to send the chunk doesn't do anything - fixture.executor.runAll() - verify(fixture.scopes, never()).captureProfileChunk(any()) - } - - @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) - - // If the SDK is rate limited, the profiler should stop - 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.")) - } - - @Test - fun `profiler does not start when rate limited`() { - val profiler = fixture.getSut() - val rateLimiter = mock() - whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) - whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) - - // If the SDK is rate limited, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - 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.")) - } - - @Test - fun `profiler does not start when offline`() { - val profiler = - fixture.getSut { - it.connectionStatusProvider = mock { provider -> - whenever(provider.connectionStatus) - .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) - } - } - - // If the device is offline, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - assertEquals(SentryId.EMPTY_ID, profiler.chunkId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) - } - fun withMockScopes(closure: () -> Unit) = - Mockito.mockStatic(Sentry::class.java).use { + mockStatic(Sentry::class.java).use { it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) closure.invoke() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt new file mode 100644 index 00000000000..5e4e0504ddf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt @@ -0,0 +1,194 @@ +package io.sentry.android.core + +import io.sentry.DataCategory +import io.sentry.IContinuousProfiler +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.SentryLevel +import io.sentry.TracesSampler +import io.sentry.protocol.SentryId +import io.sentry.test.DeferredExecutorService +import io.sentry.transport.RateLimiter +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Shared dependencies for profiler test cases. Each test class creates one from its own fixture. + */ +class ProfilerMocks( + val executor: DeferredExecutorService, + val tracesSampler: TracesSampler, + val logger: ILogger, + val scopes: IScopes, +) + +// -- Shared test cases as extension functions on IContinuousProfiler -- + +fun IContinuousProfiler.testIsRunningReflectsStatus(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testStopProfilerStopsAfterChunkFinished(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + assertTrue(isRunning) + assertNotEquals(SentryId.EMPTY_ID, profilerId) + assertNotEquals(SentryId.EMPTY_ID, chunkId) + mocks.executor.runAll() + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) +} + +fun IContinuousProfiler.testMultipleStartsAcceptedInTraceMode(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertTrue(isRunning) + + stopProfiler(ProfileLifecycle.TRACE) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testLogsWarningIfNotSampled(mocks: ProfilerMocks) { + whenever(mocks.tracesSampler.sampleSessionProfile(any())).thenReturn(false) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + verify(mocks.logger) + .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) +} + +fun IContinuousProfiler.testEvaluatesSessionSampleRateOnlyOnce(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testReevaluateSamplingOnNextStart(mocks: ProfilerMocks) { + verify(mocks.tracesSampler, never()).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + reevaluateSampling() + verify(mocks.tracesSampler, times(1)).sampleSessionProfile(any()) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + verify(mocks.tracesSampler, times(2)).sampleSessionProfile(any()) +} + +fun IContinuousProfiler.testStopsAndRestartsForEachChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + val oldChunkId = chunkId + + mocks.executor.runAll() + verify(mocks.logger).log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + + mocks.executor.runAll() + verify(mocks.logger, times(2)) + .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(isRunning) + assertNotEquals(oldChunkId, chunkId) +} + +fun IContinuousProfiler.testSendsChunkOnRestart(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testSendsChunkOnStop(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + verify(mocks.scopes).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testCloseWithoutTerminatingStopsAfterChunk(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + assertTrue(isRunning) + close(false) + assertTrue(isRunning) + mocks.executor.runAll() + assertFalse(isRunning) +} + +fun IContinuousProfiler.testDoesNotSendChunksAfterClose(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + close(true) + mocks.executor.runAll() + verify(mocks.scopes, never()).captureProfileChunk(any()) +} + +fun IContinuousProfiler.testStopsWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + (this as RateLimiter.IRateLimitObserver).onRateLimitChanged(rateLimiter) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenRateLimited(mocks: ProfilerMocks) { + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi)).thenReturn(true) + whenever(mocks.scopes.rateLimiter).thenReturn(rateLimiter) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) +} + +fun IContinuousProfiler.testDoesNotStartWhenOffline(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertFalse(isRunning) + assertEquals(SentryId.EMPTY_ID, profilerId) + assertEquals(SentryId.EMPTY_ID, chunkId) + verify(mocks.logger).log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) +} + +fun IContinuousProfiler.testCanBeStartedAgainAfterStopCycle(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + stopProfiler(ProfileLifecycle.MANUAL) + mocks.executor.runAll() + assertFalse(isRunning) + + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + assertTrue(isRunning) + mocks.executor.runAll() + assertTrue(isRunning, "shouldStop must be reset on start") +} 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 81b73d5dea7..0881c138e87 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..5b65559877b --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -0,0 +1,135 @@ +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.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 frameMetricsCollector: SentryFrameMetricsCollector = mock() + + val scopes: IScopes = 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(true) + } + + fun getSut(): PerfettoContinuousProfiler { + options.executorService = executor + whenever(scopes.options).thenReturn(options) + return PerfettoContinuousProfiler( + buildInfo, + mockLogger, + frameMetricsCollector, + { options.executorService }, + { mockPerfettoProfiler }, + ) + } + } + + @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.")) + } + + @Test + fun `manual profiler can be started again after a full start-stop cycle`() { + // DeferredExecutorService captures scheduled runnables instead of waiting. + // executor.runAll() fires them immediately, simulating the 60s chunk timer elapsing. + val profiler = fixture.getSut() + + // Session 1: start profiling, then stop it + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + // Simulate the 60s chunk timer firing — stopInternal(restartProfiler=true) runs, + // sees shouldStop=true, and does NOT restart. Profiler stops. + fixture.executor.runAll() + assertFalse(profiler.isRunning) + + // Session 2: start profiling again + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + // Simulate the 60s chunk timer firing — stopInternal(restartProfiler=true) runs. + // shouldStop must have been reset to false by startProfiler, so the profiler + // should restart for the next chunk. + fixture.executor.runAll() + assertTrue( + profiler.isRunning, + "Profiler should continue running after chunk restart — shouldStop must be reset on start", + ) + } +} 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"> -