From c9c50a57a22138fde711b10e6530f68977ad5081 Mon Sep 17 00:00:00 2001 From: stefanosiano Date: Mon, 7 Nov 2022 16:39:55 +0100 Subject: [PATCH 1/2] added ProfileMeasurements to AndroidTransactionProfiler and ProfilingTraceData added frameMetrics (slow and frozen frames) and screen refresh rate added a SentryFrameMetricsCollector to collect frameMetrics changed a benchmark device on Saucelabs as it's not available anymore --- .sauce/sentry-uitest-android-benchmark.yml | 2 +- .../api/sentry-android-core.api | 23 ++ .../core/AndroidOptionsInitializer.java | 4 +- .../core/AndroidTransactionProfiler.java | 122 +++++++-- .../core/SentryFrameMetricsCollector.java | 206 ++++++++++++++++ .../core/AndroidTransactionProfilerTest.kt | 62 ++++- .../SentryFrameMetricsCollectorTest.kt | 233 ++++++++++++++++++ .../io/sentry/uitest/android/BaseUiTest.kt | 4 + .../io/sentry/uitest/android/EnvelopeTests.kt | 26 +- sentry/api/sentry.api | 61 ++++- .../main/java/io/sentry/JsonSerializer.java | 7 + .../java/io/sentry/ProfilingTraceData.java | 39 ++- .../ProfileMeasurement.java | 150 +++++++++++ .../ProfileMeasurementValue.java | 120 +++++++++ .../test/java/io/sentry/JsonSerializerTest.kt | 137 +++++++++- 15 files changed, 1154 insertions(+), 42 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetricsCollector.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/SentryFrameMetricsCollectorTest.kt create mode 100644 sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java create mode 100644 sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java diff --git a/.sauce/sentry-uitest-android-benchmark.yml b/.sauce/sentry-uitest-android-benchmark.yml index dfa0174b809..d1e8ac4db2b 100644 --- a/.sauce/sentry-uitest-android-benchmark.yml +++ b/.sauce/sentry-uitest-android-benchmark.yml @@ -32,7 +32,7 @@ suites: - name: "Android 10 (api 29)" devices: - - id: OnePlus_7_Pro_real # OnePlus 7 Pro - api 29 (10) + - id: OnePlus_7T_real_us # OnePlus 7T - api 29 (10) - id: Nokia_7_1_real_us # Nokia 7.1 - api 29 (10) # At the time of writing (July, 4, 2022), the market share per android version is: diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 4bdcfecd270..cf12bcf0c1b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -164,6 +164,29 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setProfilingTracesIntervalMillis (I)V } +public final class io/sentry/android/core/SentryFrameMetricsCollector : android/app/Application$ActivityLifecycleCallbacks { + public fun (Landroid/content/Context;Lio/sentry/SentryOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun (Landroid/content/Context;Lio/sentry/SentryOptions;Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/SentryFrameMetricsCollector$WindowFrameMetricsManager;)V + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun startCollection (Lio/sentry/android/core/SentryFrameMetricsCollector$FrameMetricsCollectorListener;)Ljava/lang/String; + public fun stopCollection (Ljava/lang/String;)V +} + +public abstract interface class io/sentry/android/core/SentryFrameMetricsCollector$FrameMetricsCollectorListener { + public abstract fun onFrameMetricCollected (Landroid/view/FrameMetrics;F)V +} + +public abstract interface class io/sentry/android/core/SentryFrameMetricsCollector$WindowFrameMetricsManager { + public fun addOnFrameMetricsAvailableListener (Landroid/view/Window;Landroid/view/Window$OnFrameMetricsAvailableListener;Landroid/os/Handler;)V + public fun removeOnFrameMetricsAvailableListener (Landroid/view/Window;Landroid/view/Window$OnFrameMetricsAvailableListener;)V +} + public final class io/sentry/android/core/SentryInitProvider : android/content/ContentProvider { public fun ()V public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V 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 0ecbbb9067d..5e441d19cdd 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 @@ -154,8 +154,10 @@ static void init( options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker)); options.setTransportGate(new AndroidTransportGate(context, options.getLogger())); + SentryFrameMetricsCollector frameMetricsCollector = + new SentryFrameMetricsCollector(context, options, buildInfoProvider); options.setTransactionProfiler( - new AndroidTransactionProfiler(context, options, buildInfoProvider)); + new AndroidTransactionProfiler(context, options, buildInfoProvider, frameMetricsCollector)); options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index 6b3dc74baa7..7f3ee8152a2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -11,6 +11,7 @@ import android.os.Debug; import android.os.Process; import android.os.SystemClock; +import android.view.FrameMetrics; import io.sentry.HubAdapter; import io.sentry.IHub; import io.sentry.ITransaction; @@ -21,14 +22,18 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.exception.SentryEnvelopeException; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; import io.sentry.util.Objects; import java.io.File; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -60,23 +65,41 @@ final class AndroidTransactionProfiler implements ITransactionProfiler { private long profileStartCpuMillis = 0; private boolean isInitialized = false; private int transactionsCounter = 0; + private @Nullable String frameMetricsCollectorId; + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; private final @NotNull Map transactionMap = new HashMap<>(); + private final @NotNull ArrayDeque screenFrameRateMeasurements = + new ArrayDeque<>(); + private final @NotNull ArrayDeque + adverseFrameRenderTimestampMeasurements = new ArrayDeque<>(); + private final @NotNull ArrayDeque + adverseFrozenFrameTimestampMeasurements = new ArrayDeque<>(); + private final @NotNull Map measurementsMap = new HashMap<>(); public AndroidTransactionProfiler( final @NotNull Context context, final @NotNull SentryAndroidOptions sentryAndroidOptions, - final @NotNull BuildInfoProvider buildInfoProvider) { - this(context, sentryAndroidOptions, buildInfoProvider, HubAdapter.getInstance()); + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { + this( + context, + sentryAndroidOptions, + buildInfoProvider, + frameMetricsCollector, + HubAdapter.getInstance()); } public AndroidTransactionProfiler( final @NotNull Context context, final @NotNull SentryAndroidOptions sentryAndroidOptions, final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector, final @NotNull IHub hub) { this.context = Objects.requireNonNull(context, "The application context is required"); this.options = Objects.requireNonNull(sentryAndroidOptions, "SentryAndroidOptions is required"); this.hub = Objects.requireNonNull(hub, "Hub is required"); + this.frameMetricsCollector = + Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required."); this.packageInfo = ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); @@ -144,21 +167,7 @@ public synchronized void onTransactionStart(@NotNull ITransaction transaction) { transactionsCounter--; return; } - - // We stop profiling after a timeout to avoid huge profiles to be sent - scheduledFinish = - options - .getExecutorService() - .schedule(() -> onTransactionFinish(transaction, true), PROFILING_TIMEOUT_MILLIS); - - transactionStartNanos = SystemClock.elapsedRealtimeNanos(); - profileStartCpuMillis = Process.getElapsedCpuTime(); - - ProfilingTransactionData transactionData = - new ProfilingTransactionData(transaction, transactionStartNanos, profileStartCpuMillis); - transactionMap.put(transaction.getEventId().toString(), transactionData); - - Debug.startMethodTracingSampling(traceFile.getPath(), BUFFER_SIZE_BYTES, intervalUs); + onFirstTransactionStarted(transaction, traceFile); } else { ProfilingTransactionData transactionData = new ProfilingTransactionData( @@ -175,6 +184,64 @@ public synchronized void onTransactionStart(@NotNull ITransaction transaction) { transactionsCounter); } + @SuppressLint("NewApi") + private void onFirstTransactionStarted( + @NotNull ITransaction transaction, @NotNull File traceFile) { + + measurementsMap.clear(); + screenFrameRateMeasurements.clear(); + adverseFrameRenderTimestampMeasurements.clear(); + adverseFrozenFrameTimestampMeasurements.clear(); + + frameMetricsCollectorId = + frameMetricsCollector.startCollection( + new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { + final long nanosInSecond = TimeUnit.SECONDS.toNanos(1); + final long frozenFrameThresholdNanos = TimeUnit.MILLISECONDS.toNanos(700); + float lastRefreshRate = 0; + + @Override + public void onFrameMetricCollected( + @NotNull FrameMetrics frameMetrics, float refreshRate) { + long frameTimestampRelativeNanos = + SystemClock.elapsedRealtimeNanos() - transactionStartNanos; + long durationNanos = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION); + // Most frames take just a few nanoseconds longer than the optimal calculated + // duration. + // Therefore we subtract one, because otherwise almost all frames would be slow. + boolean isSlow = durationNanos > nanosInSecond / (refreshRate - 1); + float newRefreshRate = (int) (refreshRate * 100) / 100F; + if (durationNanos > frozenFrameThresholdNanos) { + adverseFrozenFrameTimestampMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); + } else if (isSlow) { + adverseFrameRenderTimestampMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); + } + if (newRefreshRate != lastRefreshRate) { + lastRefreshRate = newRefreshRate; + screenFrameRateMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, newRefreshRate)); + } + } + }); + + // We stop profiling after a timeout to avoid huge profiles to be sent + scheduledFinish = + options + .getExecutorService() + .schedule(() -> onTransactionFinish(transaction, true), PROFILING_TIMEOUT_MILLIS); + + transactionStartNanos = SystemClock.elapsedRealtimeNanos(); + profileStartCpuMillis = Process.getElapsedCpuTime(); + + ProfilingTransactionData transactionData = + new ProfilingTransactionData(transaction, transactionStartNanos, profileStartCpuMillis); + transactionMap.put(transaction.getEventId().toString(), transactionData); + + Debug.startMethodTracingSampling(traceFile.getPath(), BUFFER_SIZE_BYTES, intervalUs); + } + @Override public synchronized void onTransactionFinish(@NotNull ITransaction transaction) { onTransactionFinish(transaction, false); @@ -226,8 +293,14 @@ private synchronized void onTransactionFinish( } return; } + onLastTransactionFinished(transaction, isTimeout); + } + @SuppressLint("NewApi") + private void onLastTransactionFinished(ITransaction transaction, boolean isTimeout) { Debug.stopMethodTracing(); + frameMetricsCollector.stopCollection(frameMetricsCollectorId); + long transactionEndNanos = SystemClock.elapsedRealtimeNanos(); long transactionEndCpuMillis = Process.getElapsedCpuTime(); long transactionDurationNanos = transactionEndNanos - transactionStartNanos; @@ -270,6 +343,18 @@ private synchronized void onTransactionFinish( profileStartCpuMillis); } + measurementsMap.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, adverseFrameRenderTimestampMeasurements)); + measurementsMap.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, adverseFrozenFrameTimestampMeasurements)); + measurementsMap.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + // cpu max frequencies are read with a lambda because reading files is involved, so it will be // done in the background when the trace file is read ProfilingTraceData profilingTraceData = @@ -292,7 +377,8 @@ private synchronized void onTransactionFinish( options.getEnvironment(), isTimeout ? ProfilingTraceData.TRUNCATION_REASON_TIMEOUT - : ProfilingTraceData.TRUNCATION_REASON_NORMAL); + : ProfilingTraceData.TRUNCATION_REASON_NORMAL, + measurementsMap); SentryEnvelope envelope; try { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetricsCollector.java new file mode 100644 index 00000000000..736bbc47688 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetricsCollector.java @@ -0,0 +1,206 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.FrameMetrics; +import android.view.Window; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.util.Objects; +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryFrameMetricsCollector implements Application.ActivityLifecycleCallbacks { + private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull Set trackedWindows = new HashSet<>(); + private @Nullable Handler handler; + private @Nullable WeakReference currentWindow; + private final @NotNull HashMap listenerMap = + new HashMap<>(); + private boolean isAvailable = false; + private final WindowFrameMetricsManager windowFrameMetricsManager; + + private @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener; + + @SuppressWarnings("deprecation") + @SuppressLint("NewApi") + public SentryFrameMetricsCollector( + @NotNull Context context, + @NotNull SentryOptions options, + @NotNull BuildInfoProvider buildInfoProvider) { + this(context, options, buildInfoProvider, new WindowFrameMetricsManager() {}); + } + + @SuppressWarnings("deprecation") + @SuppressLint("NewApi") + public SentryFrameMetricsCollector( + @NotNull Context context, + @NotNull SentryOptions options, + @NotNull BuildInfoProvider buildInfoProvider, + @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { + Objects.requireNonNull(context, "The context is required"); + Objects.requireNonNull(options, "SentryOptions is required"); + this.buildInfoProvider = + Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + this.windowFrameMetricsManager = + Objects.requireNonNull(windowFrameMetricsManager, "WindowFrameMetricsManager is required"); + + // registerActivityLifecycleCallbacks is only available if Context is an AppContext + if (!(context instanceof Application)) { + return; + } + // FrameMetrics api is only available since sdk version N + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + return; + } + isAvailable = true; + + HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.SentryFrameMetricsCollector"); + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> + options.getLogger().log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + + // We have to register the lifecycle callback, even if no profile is started, otherwise when we + // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. + ((Application) context).registerActivityLifecycleCallbacks(this); + + frameMetricsAvailableListener = + (window, frameMetrics, dropCountSinceLastInvocation) -> { + float refreshRate = + buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.R + ? window.getContext().getDisplay().getRefreshRate() + : window.getWindowManager().getDefaultDisplay().getRefreshRate(); + for (FrameMetricsCollectorListener l : listenerMap.values()) { + l.onFrameMetricCollected(frameMetrics, refreshRate); + } + }; + } + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(@NonNull Activity activity) { + setCurrentWindow(activity.getWindow()); + } + + @Override + public void onActivityResumed(@NonNull Activity activity) {} + + @Override + public void onActivityPaused(@NonNull Activity activity) {} + + @Override + public void onActivityStopped(@NonNull Activity activity) { + cleanCurrentWindow(activity.getWindow()); + } + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} + + @Override + public void onActivityDestroyed(@NonNull Activity activity) {} + + public @Nullable String startCollection(FrameMetricsCollectorListener listener) { + if (!isAvailable) { + return null; + } + final String uid = UUID.randomUUID().toString(); + listenerMap.put(uid, listener); + trackCurrentWindow(); + return uid; + } + + public void stopCollection(@Nullable String listenerId) { + if (!isAvailable) { + return; + } + if (listenerId != null) { + listenerMap.remove(listenerId); + } + Window window = currentWindow != null ? currentWindow.get() : null; + if (window != null && listenerMap.isEmpty()) { + cleanCurrentWindow(window); + } + } + + @SuppressLint("NewApi") + private void cleanCurrentWindow(@NonNull Window window) { + if (currentWindow != null && currentWindow.get() == window) { + currentWindow = null; + } + if (trackedWindows.contains(window)) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { + windowFrameMetricsManager.removeOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener); + } + trackedWindows.remove(window); + } + } + + private void setCurrentWindow(@NotNull Window window) { + if (currentWindow != null && currentWindow.get() == window) { + return; + } + currentWindow = new WeakReference<>(window); + trackCurrentWindow(); + } + + @SuppressLint("NewApi") + private void trackCurrentWindow() { + Window window = currentWindow != null ? currentWindow.get() : null; + if (window == null || !isAvailable) { + return; + } + + if (!trackedWindows.contains(window) && !listenerMap.isEmpty()) { + + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N && handler != null) { + trackedWindows.add(window); + windowFrameMetricsManager.addOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener, handler); + } + } + } + + @ApiStatus.Internal + public interface FrameMetricsCollectorListener { + void onFrameMetricCollected(@NotNull FrameMetrics frameMetrics, float refreshRate); + } + + @ApiStatus.Internal + public interface WindowFrameMetricsManager { + @RequiresApi(api = Build.VERSION_CODES.N) + default void addOnFrameMetricsAvailableListener( + @NotNull Window window, + @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener, + @Nullable Handler handler) { + window.addOnFrameMetricsAvailableListener(frameMetricsAvailableListener, handler); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + default void removeOnFrameMetricsAvailableListener( + @NotNull Window window, + @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener) { + window.removeOnFrameMetricsAvailableListener(frameMetricsAvailableListener); + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index bbdc8dc5257..309e06035ae 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -12,6 +12,7 @@ import io.sentry.SentryLevel import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.assertEnvelopeItem +import io.sentry.profilemeasurements.ProfileMeasurement import io.sentry.test.getCtor import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -37,7 +38,7 @@ class AndroidTransactionProfilerTest { private lateinit var context: Context private val className = "io.sentry.android.core.AndroidTransactionProfiler" - private val ctorTypes = arrayOf(Context::class.java, SentryAndroidOptions::class.java, BuildInfoProvider::class.java) + private val ctorTypes = arrayOf(Context::class.java, SentryAndroidOptions::class.java, BuildInfoProvider::class.java, SentryFrameMetricsCollector::class.java) private val fixture = Fixture() private class Fixture { @@ -54,6 +55,7 @@ class AndroidTransactionProfilerTest { } val hub: IHub = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() lateinit var transaction1: SentryTracer lateinit var transaction2: SentryTracer @@ -64,7 +66,7 @@ class AndroidTransactionProfilerTest { transaction1 = SentryTracer(TransactionContext("", ""), hub) transaction2 = SentryTracer(TransactionContext("", ""), hub) transaction3 = SentryTracer(TransactionContext("", ""), hub) - return AndroidTransactionProfiler(context, options, buildInfoProvider, hub) + return AndroidTransactionProfiler(context, options, buildInfoProvider, frameMetricsCollector, hub) } } @@ -90,10 +92,13 @@ class AndroidTransactionProfilerTest { ctor.newInstance(arrayOf(null, mock(), mock())) } assertFailsWith { - ctor.newInstance(arrayOf(mock(), null, mock())) + ctor.newInstance(arrayOf(mock(), null, mock(), mock())) } assertFailsWith { - ctor.newInstance(arrayOf(mock(), mock(), null)) + ctor.newInstance(arrayOf(mock(), mock(), null, mock())) + } + assertFailsWith { + ctor.newInstance(arrayOf(mock(), mock(), mock(), null)) } } @@ -328,4 +333,53 @@ class AndroidTransactionProfilerTest { } ) } + + @Test + fun `profiler starts collecting frame metrics when the first transaction starts`() { + val profiler = fixture.getSut(context) + profiler.onTransactionStart(fixture.transaction1) + verify(fixture.frameMetricsCollector, times(1)).startCollection(any()) + profiler.onTransactionStart(fixture.transaction2) + verify(fixture.frameMetricsCollector, times(1)).startCollection(any()) + } + + @Test + fun `profiler stops collecting frame metrics when the last transaction finishes`() { + val profiler = fixture.getSut(context) + val frameMetricsCollectorId = "id" + whenever(fixture.frameMetricsCollector.startCollection(any())).thenReturn(frameMetricsCollectorId) + profiler.onTransactionStart(fixture.transaction1) + profiler.onTransactionStart(fixture.transaction2) + profiler.onTransactionFinish(fixture.transaction1) + verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) + profiler.onTransactionFinish(fixture.transaction2) + verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) + } + + @Test + fun `profiler includes measurements in envelope sent`() { + val profiler = fixture.getSut(context) + profiler.onTransactionStart(fixture.transaction1) + profiler.onTransactionFinish(fixture.transaction1) + verify(fixture.hub).captureEnvelope( + check { + assertEquals(1, it.items.count()) + assertEnvelopeItem(it.items.toList()) { _, item -> + assertEquals(fixture.transaction1.eventId.toString(), item.transactionId) + val expectedMeasurements = setOf( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + ProfileMeasurement.ID_SCREEN_FRAME_RATES + ) + assertEquals(expectedMeasurements, item.measurementsMap.keys) + val slowFrames = item.measurementsMap[ProfileMeasurement.ID_SLOW_FRAME_RENDERS]!! + val frozenFrames = item.measurementsMap[ProfileMeasurement.ID_FROZEN_FRAME_RENDERS]!! + val frameRates = item.measurementsMap[ProfileMeasurement.ID_SCREEN_FRAME_RATES]!! + assertEquals(ProfileMeasurement.UNIT_NANOSECONDS, slowFrames.unit) + assertEquals(ProfileMeasurement.UNIT_NANOSECONDS, frozenFrames.unit) + assertEquals(ProfileMeasurement.UNIT_HZ, frameRates.unit) + } + } + ) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/SentryFrameMetricsCollectorTest.kt new file mode 100644 index 00000000000..61bc416f055 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/SentryFrameMetricsCollectorTest.kt @@ -0,0 +1,233 @@ +package io.sentry.android.core.internal + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.Handler +import android.view.Window +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.SentryOptions +import io.sentry.android.core.BuildInfoProvider +import io.sentry.android.core.SentryFrameMetricsCollector +import io.sentry.test.getCtor +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +class SentryFrameMetricsCollectorTest { + private lateinit var context: Context + + private val className = "io.sentry.android.core.SentryFrameMetricsCollector" + private val ctorTypes = arrayOf(Context::class.java, SentryOptions::class.java, BuildInfoProvider::class.java) + private val fixture = Fixture() + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val buildInfo = mock { + whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + } + val mockLogger = mock() + val options = spy(SentryOptions()).apply { + dsn = mockDsn + isDebug = true + setLogger(mockLogger) + } + + val activity = mock() + val window = mock() + val activity2 = mock() + val window2 = mock() + + var addOnFrameMetricsAvailableListenerCounter = 0 + var removeOnFrameMetricsAvailableListenerCounter = 0 + val windowFrameMetricsManager = object : SentryFrameMetricsCollector.WindowFrameMetricsManager { + override fun addOnFrameMetricsAvailableListener( + window: Window, + frameMetricsAvailableListener: Window.OnFrameMetricsAvailableListener?, + handler: Handler? + ) { + addOnFrameMetricsAvailableListenerCounter++ + } + + override fun removeOnFrameMetricsAvailableListener( + window: Window, + frameMetricsAvailableListener: Window.OnFrameMetricsAvailableListener? + ) { + removeOnFrameMetricsAvailableListenerCounter++ + } + } + + fun getSut(context: Context, buildInfoProvider: BuildInfoProvider = buildInfo): SentryFrameMetricsCollector { + whenever(activity.window).thenReturn(window) + whenever(activity2.window).thenReturn(window2) + addOnFrameMetricsAvailableListenerCounter = 0 + removeOnFrameMetricsAvailableListenerCounter = 0 + return SentryFrameMetricsCollector( + context, + options, + buildInfoProvider, + windowFrameMetricsManager + ) + } + } + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `when null param is provided, invalid argument is thrown`() { + val ctor = className.getCtor(ctorTypes) + + assertFailsWith { + ctor.newInstance(arrayOf(null, mock(), mock())) + } + assertFailsWith { + ctor.newInstance(arrayOf(mock(), null, mock())) + } + assertFailsWith { + ctor.newInstance(arrayOf(mock(), mock(), null)) + } + } + + @Test + fun `collector works only on api 24+`() { + val buildInfo = mock { + whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) + } + val collector = fixture.getSut(context, buildInfo) + val id = collector.startCollection(mock()) + assertNull(id) + } + + @Test + fun `collector works only if context is instance of Application`() { + val collector = fixture.getSut(mock()) + val id = collector.startCollection(mock()) + assertNull(id) + } + + @Test + fun `startCollection returns an id`() { + val collector = fixture.getSut(context) + val id = collector.startCollection(mock()) + assertNotNull(id) + } + + @Test + fun `collector calls addOnFrameMetricsAvailableListener when an activity starts`() { + val collector = fixture.getSut(context) + + collector.startCollection(mock()) + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + collector.onActivityStarted(fixture.activity) + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `collector calls removeOnFrameMetricsAvailableListener when an activity stops`() { + val collector = fixture.getSut(context) + + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + collector.onActivityStopped(fixture.activity) + assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `collector ignores activities if not started`() { + val collector = fixture.getSut(context) + + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + collector.onActivityStarted(fixture.activity) + collector.onActivityStopped(fixture.activity) + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `startCollection calls addOnFrameMetricsAvailableListener if an activity is already started`() { + val collector = fixture.getSut(context) + + collector.onActivityStarted(fixture.activity) + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + collector.startCollection(mock()) + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `stopCollection calls removeOnFrameMetricsAvailableListener even if an activity is still started`() { + val collector = fixture.getSut(context) + val id = collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + collector.stopCollection(id) + assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `OnFrameMetricsAvailableListener is called once per activity`() { + val collector = fixture.getSut(context) + collector.startCollection(mock()) + + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + + collector.onActivityStarted(fixture.activity) + collector.onActivityStarted(fixture.activity) + + collector.onActivityStopped(fixture.activity) + collector.onActivityStopped(fixture.activity) + + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) + assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `stopCollection works only after startCollection`() { + val collector = fixture.getSut(context) + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + collector.stopCollection("testId") + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `collector tracks multiple activities`() { + val collector = fixture.getSut(context) + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + collector.onActivityStarted(fixture.activity2) + assertEquals(2, fixture.addOnFrameMetricsAvailableListenerCounter) + collector.onActivityStopped(fixture.activity) + collector.onActivityStopped(fixture.activity2) + assertEquals(2, fixture.removeOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `collector tracks multiple collections`() { + val collector = fixture.getSut(context) + val id1 = collector.startCollection(mock()) + val id2 = collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) + collector.stopCollection(id1) + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + collector.stopCollection(id2) + assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index 0fd166c7701..2ac15bebc04 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -72,6 +72,10 @@ abstract class BaseUiTest { } SentryAndroid.init(context) { it.dsn = mockDsn + // We don't use test orchestrator, due to problems with Saucelabs. + // So the app data is not deleted between tests. Thus, We don't know when sessions will actually be sent. + // To avoid any interference between tests we can just disable them by default. + it.isEnableAutoSessionTracking = false optionsConfiguration?.invoke(it) } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index f42a336caba..469337749ab 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -12,6 +12,7 @@ import io.sentry.ProfilingTraceData import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.android.core.SentryAndroidOptions +import io.sentry.profilemeasurements.ProfileMeasurement import io.sentry.protocol.SentryTransaction import org.junit.runner.RunWith import java.io.File @@ -48,10 +49,17 @@ class EnvelopeTests : BaseUiTest() { initSentry(true) { options: SentryAndroidOptions -> options.tracesSampleRate = 1.0 options.profilesSampleRate = 1.0 + options.isEnableAutoActivityLifecycleTracing = false } + + IdlingRegistry.getInstance().register(ProfilingSampleActivity.scrollingIdlingResource) + val sampleScenario = launchActivity() + val transaction = Sentry.startTransaction("e2etests", "test1") + swipeList(1) + sampleScenario.moveToState(Lifecycle.State.DESTROYED) + IdlingRegistry.getInstance().unregister(ProfilingSampleActivity.scrollingIdlingResource) relayIdlingResource.increment() relayIdlingResource.increment() - val transaction = Sentry.startTransaction("e2etests", "test1") transaction.finish() relay.assert { @@ -63,6 +71,19 @@ class EnvelopeTests : BaseUiTest() { assertTrue(profilingTraceData.environment.isNotEmpty()) assertTrue(profilingTraceData.cpuArchitecture.isNotEmpty()) assertTrue(profilingTraceData.transactions.isNotEmpty()) + assertTrue(profilingTraceData.measurementsMap.isNotEmpty()) + + // We check the measurements have been collected with expected units + val slowFrames = profilingTraceData.measurementsMap[ProfileMeasurement.ID_SLOW_FRAME_RENDERS]!! + val frozenFrames = profilingTraceData.measurementsMap[ProfileMeasurement.ID_FROZEN_FRAME_RENDERS]!! + val frameRates = profilingTraceData.measurementsMap[ProfileMeasurement.ID_SCREEN_FRAME_RATES]!! + assertEquals(ProfileMeasurement.UNIT_NANOSECONDS, slowFrames.unit) + assertEquals(ProfileMeasurement.UNIT_NANOSECONDS, frozenFrames.unit) + assertEquals(ProfileMeasurement.UNIT_HZ, frameRates.unit) + + // There could be no slow/frozen frames, but we expect at least one frame rate + assertTrue(frameRates.values.isNotEmpty()) + // We should find the transaction id that started the profiling in the list of transactions val transactionData = profilingTraceData.transactions .firstOrNull { t -> t.id == transaction.eventId.toString() } @@ -187,7 +208,7 @@ class EnvelopeTests : BaseUiTest() { } } -// @Test + @Test fun checkTimedOutProfile() { // We increase the IdlingResources timeout to exceed the profiling timeout IdlingPolicies.setIdlingResourceTimeout(1, TimeUnit.MINUTES) @@ -219,6 +240,7 @@ class EnvelopeTests : BaseUiTest() { options.dsn = "https://640fae2f19ac4ba78ad740175f50195f@o1137848.ingest.sentry.io/6191083" options.tracesSampleRate = 1.0 options.profilesSampleRate = 1.0 + options.isEnableAutoActivityLifecycleTracing = false } val transaction = Sentry.startTransaction("e2etests", "testProfile") diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4d5c9ee87fc..4e0eb07b90f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -812,7 +812,7 @@ public final class io/sentry/ProfilingTraceData : io/sentry/JsonSerializable, io public static final field TRUNCATION_REASON_NORMAL Ljava/lang/String; public static final field TRUNCATION_REASON_TIMEOUT Ljava/lang/String; public fun (Ljava/io/File;Lio/sentry/ITransaction;)V - public fun (Ljava/io/File;Ljava/util/List;Lio/sentry/ITransaction;Ljava/lang/String;ILjava/lang/String;Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun (Ljava/io/File;Ljava/util/List;Lio/sentry/ITransaction;Ljava/lang/String;ILjava/lang/String;Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V public fun getAndroidApiLevel ()I public fun getBuildId ()Ljava/lang/String; public fun getCpuArchitecture ()Ljava/lang/String; @@ -826,6 +826,7 @@ public final class io/sentry/ProfilingTraceData : io/sentry/JsonSerializable, io public fun getDevicePhysicalMemoryBytes ()Ljava/lang/String; public fun getDurationNs ()Ljava/lang/String; public fun getEnvironment ()Ljava/lang/String; + public fun getMeasurementsMap ()Ljava/util/Map; public fun getPlatform ()Ljava/lang/String; public fun getProfileId ()Ljava/lang/String; public fun getSampledProfile ()Ljava/lang/String; @@ -859,6 +860,8 @@ public final class io/sentry/ProfilingTraceData : io/sentry/JsonSerializable, io public fun setTraceId (Ljava/lang/String;)V public fun setTransactionId (Ljava/lang/String;)V public fun setTransactionName (Ljava/lang/String;)V + public fun setTransactions (Ljava/util/List;)V + public fun setTruncationReason (Ljava/lang/String;)V public fun setUnknown (Ljava/util/Map;)V public fun setVersionCode (Ljava/lang/String;)V public fun setVersionName (Ljava/lang/String;)V @@ -885,6 +888,7 @@ public final class io/sentry/ProfilingTraceData$JsonKeys { public static final field DEVICE_PHYSICAL_MEMORY_BYTES Ljava/lang/String; public static final field DURATION_NS Ljava/lang/String; public static final field ENVIRONMENT Ljava/lang/String; + public static final field MEASUREMENTS Ljava/lang/String; public static final field PLATFORM Ljava/lang/String; public static final field PROFILE_ID Ljava/lang/String; public static final field SAMPLED_PROFILE Ljava/lang/String; @@ -2214,6 +2218,61 @@ public final class io/sentry/internal/modules/ResourcesModulesLoader : io/sentry public fun (Lio/sentry/ILogger;)V } +public final class io/sentry/profilemeasurements/ProfileMeasurement : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field ID_FROZEN_FRAME_RENDERS Ljava/lang/String; + public static final field ID_SCREEN_FRAME_RATES Ljava/lang/String; + public static final field ID_SLOW_FRAME_RENDERS Ljava/lang/String; + public static final field ID_UNKNOWN Ljava/lang/String; + public static final field UNIT_HZ Ljava/lang/String; + public static final field UNIT_NANOSECONDS Ljava/lang/String; + public static final field UNIT_UNKNOWN Ljava/lang/String; + public fun ()V + public fun (Ljava/lang/String;Ljava/util/Collection;)V + public fun equals (Ljava/lang/Object;)Z + public fun getUnit ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getValues ()Ljava/util/Collection; + public fun hashCode ()I + public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V + public fun setUnit (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setValues (Ljava/util/Collection;)V +} + +public final class io/sentry/profilemeasurements/ProfileMeasurement$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/profilemeasurements/ProfileMeasurement$JsonKeys { + public static final field UNIT Ljava/lang/String; + public static final field VALUES Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/profilemeasurements/ProfileMeasurementValue : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun (Ljava/lang/Long;Ljava/lang/Number;)V + public fun equals (Ljava/lang/Object;)Z + public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I + public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/profilemeasurements/ProfileMeasurementValue$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/profilemeasurements/ProfileMeasurementValue$JsonKeys { + public static final field START_NS Ljava/lang/String; + public static final field VALUE Ljava/lang/String; + public fun ()V +} + public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 52c5d2e2da5..b4091105730 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -1,6 +1,8 @@ package io.sentry; import io.sentry.clientreport.ClientReport; +import io.sentry.profilemeasurements.ProfileMeasurement; +import io.sentry.profilemeasurements.ProfileMeasurementValue; import io.sentry.protocol.App; import io.sentry.protocol.Browser; import io.sentry.protocol.Contexts; @@ -77,6 +79,11 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(Message.class, new Message.Deserializer()); deserializersByClass.put(OperatingSystem.class, new OperatingSystem.Deserializer()); deserializersByClass.put(ProfilingTraceData.class, new ProfilingTraceData.Deserializer()); + deserializersByClass.put( + ProfilingTransactionData.class, new ProfilingTransactionData.Deserializer()); + deserializersByClass.put(ProfileMeasurement.class, new ProfileMeasurement.Deserializer()); + deserializersByClass.put( + ProfileMeasurementValue.class, new ProfileMeasurementValue.Deserializer()); deserializersByClass.put(Request.class, new Request.Deserializer()); deserializersByClass.put(SdkInfo.class, new SdkInfo.Deserializer()); deserializersByClass.put(SdkVersion.class, new SdkVersion.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/ProfilingTraceData.java b/sentry/src/main/java/io/sentry/ProfilingTraceData.java index 764bb005bc9..7d67c502c9c 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTraceData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTraceData.java @@ -1,9 +1,11 @@ package io.sentry; +import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -28,8 +30,8 @@ public final class ProfilingTraceData implements JsonUnknown, JsonSerializable { // Backgrounded reason is not used, yet, but it's one of the possible values @ApiStatus.Internal public static final String TRUNCATION_REASON_BACKGROUNDED = "backgrounded"; - private @NotNull File traceFile; - private @Nullable Callable> deviceCpuFrequenciesReader; + private final @NotNull File traceFile; + private final @NotNull Callable> deviceCpuFrequenciesReader; // Device metadata private int androidApiLevel; @@ -62,6 +64,7 @@ public final class ProfilingTraceData implements JsonUnknown, JsonSerializable { private @NotNull String profileId; private @NotNull String environment; private @NotNull String truncationReason; + private final @NotNull Map measurementsMap; // Stacktrace (file) /** Profile trace encoded with Base64 */ @@ -90,7 +93,8 @@ public ProfilingTraceData(@NotNull File traceFile, @NotNull ITransaction transac null, null, null, - TRUNCATION_REASON_NORMAL); + TRUNCATION_REASON_NORMAL, + new HashMap<>()); } public ProfilingTraceData( @@ -110,7 +114,8 @@ public ProfilingTraceData( @Nullable String versionName, @Nullable String versionCode, @Nullable String environment, - @NotNull String truncationReason) { + @NotNull String truncationReason, + @NotNull Map measurementsMap) { this.traceFile = traceFile; this.cpuArchitecture = cpuArchitecture; this.deviceCpuFrequenciesReader = deviceCpuFrequenciesReader; @@ -147,6 +152,7 @@ public ProfilingTraceData( if (!isTruncationReasonValid()) { this.truncationReason = TRUNCATION_REASON_NORMAL; } + this.measurementsMap = measurementsMap; } private boolean isTruncationReasonValid() { @@ -257,6 +263,10 @@ public boolean isDeviceIsEmulator() { return truncationReason; } + public @NotNull Map getMeasurementsMap() { + return measurementsMap; + } + public void setAndroidApiLevel(int androidApiLevel) { this.androidApiLevel = androidApiLevel; } @@ -297,6 +307,14 @@ public void setDevicePhysicalMemoryBytes(@NotNull String devicePhysicalMemoryByt this.devicePhysicalMemoryBytes = devicePhysicalMemoryBytes; } + public void setTruncationReason(@NotNull String truncationReason) { + this.truncationReason = truncationReason; + } + + public void setTransactions(@NotNull List transactions) { + this.transactions = transactions; + } + public void setBuildId(@NotNull String buildId) { this.buildId = buildId; } @@ -339,9 +357,7 @@ public void setSampledProfile(@Nullable String sampledProfile) { public void readDeviceCpuFrequencies() { try { - if (deviceCpuFrequenciesReader != null) { - this.deviceCpuFrequencies = deviceCpuFrequenciesReader.call(); - } + this.deviceCpuFrequencies = deviceCpuFrequenciesReader.call(); } catch (Throwable ignored) { // should never happen } @@ -374,6 +390,7 @@ public static final class JsonKeys { public static final String ENVIRONMENT = "environment"; public static final String SAMPLED_PROFILE = "sampled_profile"; public static final String TRUNCATION_REASON = "truncation_reason"; + public static final String MEASUREMENTS = "measurements"; } @Override @@ -409,6 +426,7 @@ public void serialize(@NotNull JsonObjectWriter writer, @NotNull ILogger logger) if (sampledProfile != null) { writer.name(JsonKeys.SAMPLED_PROFILE).value(sampledProfile); } + writer.name(JsonKeys.MEASUREMENTS).value(logger, measurementsMap); if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -582,6 +600,13 @@ public static final class Deserializer implements JsonDeserializer measurements = + reader.nextMapOrNull(logger, new ProfileMeasurement.Deserializer()); + if (measurements != null) { + data.measurementsMap.putAll(measurements); + } + break; case JsonKeys.SAMPLED_PROFILE: String sampledProfile = reader.nextStringOrNull(); if (sampledProfile != null) { diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java new file mode 100644 index 00000000000..0dd8ab95f6d --- /dev/null +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java @@ -0,0 +1,150 @@ +package io.sentry.profilemeasurements; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonObjectWriter; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class ProfileMeasurement implements JsonUnknown, JsonSerializable { + + public static final String ID_FROZEN_FRAME_RENDERS = "frozen_frame_renders"; + public static final String ID_SLOW_FRAME_RENDERS = "slow_frame_renders"; + public static final String ID_SCREEN_FRAME_RATES = "screen_frame_rates"; + public static final String ID_UNKNOWN = "unknown"; + + public static final String UNIT_HZ = "hz"; + public static final String UNIT_NANOSECONDS = "nanoseconds"; + public static final String UNIT_UNKNOWN = "unknown"; + + private @Nullable Map unknown; + private @NotNull String unit; // Unit of the value + private @NotNull Collection values; + + public ProfileMeasurement() { + this(UNIT_UNKNOWN, new ArrayList<>()); + } + + public ProfileMeasurement( + @NotNull String unit, @NotNull Collection values) { + this.unit = unit; + this.values = values; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProfileMeasurement that = (ProfileMeasurement) o; + return Objects.equals(unknown, that.unknown) + && unit.equals(that.unit) + && new ArrayList<>(values).equals(new ArrayList<>(that.values)); + } + + @Override + public int hashCode() { + return Objects.hash(unknown, unit, values); + } + + // JsonSerializable + + public static final class JsonKeys { + public static final String UNIT = "unit"; + public static final String VALUES = "values"; + } + + @Override + public void serialize(@NotNull JsonObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.UNIT).value(logger, unit); + writer.name(JsonKeys.VALUES).value(logger, values); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + @Nullable + @Override + public Map getUnknown() { + return unknown; + } + + public @NotNull String getUnit() { + return unit; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public void setUnit(@NotNull String unit) { + this.unit = unit; + } + + public @NotNull Collection getValues() { + return values; + } + + public void setValues(@NotNull Collection values) { + this.values = values; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ProfileMeasurement deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + ProfileMeasurement data = new ProfileMeasurement(); + Map unknown = null; + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.UNIT: + String unit = reader.nextStringOrNull(); + if (unit != null) { + data.unit = unit; + } + break; + case JsonKeys.VALUES: + List values = + reader.nextList(logger, new ProfileMeasurementValue.Deserializer()); + if (values != null) { + data.values = values; + } + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + data.setUnknown(unknown); + reader.endObject(); + return data; + } + } +} diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java new file mode 100644 index 00000000000..f4b863a2f56 --- /dev/null +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java @@ -0,0 +1,120 @@ +package io.sentry.profilemeasurements; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonObjectWriter; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class ProfileMeasurementValue implements JsonUnknown, JsonSerializable { + + private @Nullable Map unknown; + private @NotNull Long relativeStartNs; // timestamp in nanoseconds this frame was started + private @NotNull String value; // frame duration in nanoseconds + + public ProfileMeasurementValue() { + this(0L, 0); + } + + public ProfileMeasurementValue(@NotNull Long relativeStartNs, @NotNull Number value) { + this.relativeStartNs = relativeStartNs; + this.value = value.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProfileMeasurementValue that = (ProfileMeasurementValue) o; + return Objects.equals(unknown, that.unknown) + && relativeStartNs.equals(that.relativeStartNs) + && value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hash(unknown, relativeStartNs, value); + } + + // JsonSerializable + + public static final class JsonKeys { + public static final String VALUE = "value"; + public static final String START_NS = "elapsed_since_start_ns"; + } + + @Override + public void serialize(@NotNull JsonObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.VALUE).value(logger, value); + writer.name(JsonKeys.START_NS).value(logger, relativeStartNs); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + @Nullable + @Override + public Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ProfileMeasurementValue deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + ProfileMeasurementValue data = new ProfileMeasurementValue(); + Map unknown = null; + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.VALUE: + String value = reader.nextStringOrNull(); + if (value != null) { + data.value = value; + } + break; + case JsonKeys.START_NS: + Long startNs = reader.nextLongOrNull(); + if (startNs != null) { + data.relativeStartNs = startNs; + } + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + data.setUnknown(unknown); + reader.endObject(); + return data; + } + } +} diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index 57ef90e4b30..10799d3a3e3 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -1,6 +1,8 @@ package io.sentry import io.sentry.exception.SentryEnvelopeException +import io.sentry.profilemeasurements.ProfileMeasurement +import io.sentry.profilemeasurements.ProfileMeasurementValue import io.sentry.protocol.Device import io.sentry.protocol.Request import io.sentry.protocol.SdkVersion @@ -384,8 +386,7 @@ class JsonSerializerTest { assertTrue( sdkInfo.packages!!.any { - it.name == "maven:io.sentry:sentry-android-core" - it.version == "4.5.6" + it.name == "io.sentry:maven:sentry-android-core" && it.version == "4.5.6" } ) } @@ -425,8 +426,7 @@ class JsonSerializerTest { assertNotNull(sdkVersion.packages) assertTrue( sdkVersion.packages!!.any { - it.name == "abc" - it.version == "4.5.6" + it.name == "abc" && it.version == "4.5.6" } ) } @@ -499,9 +499,14 @@ class JsonSerializerTest { profilingTraceData.deviceOsBuildNumber = "deviceOsBuildNumber" profilingTraceData.deviceOsVersion = "11" profilingTraceData.isDeviceIsEmulator = true + profilingTraceData.cpuArchitecture = "cpuArchitecture" profilingTraceData.deviceCpuFrequencies = listOf(1, 2, 3, 4) profilingTraceData.devicePhysicalMemoryBytes = "2000000" profilingTraceData.buildId = "buildId" + profilingTraceData.transactions = listOf( + ProfilingTransactionData(NoOpTransaction.getInstance(), 1, 2), + ProfilingTransactionData(NoOpTransaction.getInstance(), 2, 3) + ) profilingTraceData.transactionName = "transactionName" profilingTraceData.durationNs = "100" profilingTraceData.versionName = "versionName" @@ -510,12 +515,20 @@ class JsonSerializerTest { profilingTraceData.traceId = "traceId" profilingTraceData.profileId = "profileId" profilingTraceData.environment = "environment" + profilingTraceData.truncationReason = "truncationReason" + profilingTraceData.measurementsMap.putAll( + hashMapOf( + ProfileMeasurement.ID_SCREEN_FRAME_RATES to + ProfileMeasurement( + ProfileMeasurement.UNIT_HZ, + listOf(ProfileMeasurementValue(1, 60.1F)) + ) + ) + ) profilingTraceData.sampledProfile = "sampled profile in base 64" - val stringWriter = StringWriter() - fixture.serializer.serialize(profilingTraceData, stringWriter) - - val reader = StringReader(stringWriter.toString()) + val actual = serializeToString(profilingTraceData) + val reader = StringReader(actual) val objectReader = JsonObjectReader(reader) val element = JsonObjectDeserializer().deserialize(objectReader) as Map<*, *> @@ -527,10 +540,49 @@ class JsonSerializerTest { assertEquals("android", element["device_os_name"] as String) assertEquals("11", element["device_os_version"] as String) assertEquals(true, element["device_is_emulator"] as Boolean) + assertEquals("cpuArchitecture", element["architecture"] as String) assertEquals(listOf(1, 2, 3, 4), element["device_cpu_frequencies"] as List) assertEquals("2000000", element["device_physical_memory_bytes"] as String) assertEquals("android", element["platform"] as String) assertEquals("buildId", element["build_id"] as String) + assertEquals( + listOf( + mapOf( + "trace_id" to "00000000000000000000000000000000", + "relative_cpu_end_ms" to null, + "name" to "", + "relative_start_ns" to 1, + "relative_end_ns" to null, + "id" to "00000000000000000000000000000000", + "relative_cpu_start_ms" to 2 + ), + mapOf( + "trace_id" to "00000000000000000000000000000000", + "relative_cpu_end_ms" to null, + "name" to "", + "relative_start_ns" to 2, + "relative_end_ns" to null, + "id" to "00000000000000000000000000000000", + "relative_cpu_start_ms" to 3 + ) + ), + element["transactions"] + ) + assertEquals( + mapOf( + ProfileMeasurement.ID_SCREEN_FRAME_RATES to + mapOf( + "unit" to ProfileMeasurement.UNIT_HZ, + "values" to listOf( + mapOf( + "value" to "60.1", + "elapsed_since_start_ns" to 1 + ) + ) + ) + ), + element["measurements"] + ) assertEquals("transactionName", element["transaction_name"] as String) assertEquals("100", element["duration_ns"] as String) assertEquals("versionName", element["version_name"] as String) @@ -539,6 +591,7 @@ class JsonSerializerTest { assertEquals("traceId", element["trace_id"] as String) assertEquals("profileId", element["profile_id"] as String) assertEquals("environment", element["environment"] as String) + assertEquals("truncationReason", element["truncation_reason"] as String) assertEquals("sampled profile in base 64", element["sampled_profile"] as String) } @@ -574,6 +627,20 @@ class JsonSerializerTest { "relative_end_ns":21 } ], + "measurements":{ + "screen_frame_rates": { + "unit":"hz", + "values":[ + {"value":"60.1","elapsed_since_start_ns":"1"} + ] + }, + "frozen_frame_renders": { + "unit":"nanoseconds", + "values":[ + {"value":"100","elapsed_since_start_ns":"2"} + ] + } + }, "transaction_name":"transactionName", "duration_ns":"100", "version_name":"versionName", @@ -582,6 +649,7 @@ class JsonSerializerTest { "trace_id":"traceId", "profile_id":"profileId", "environment":"environment", + "truncation_reason":"truncationReason", "sampled_profile":"sampled profile in base 64" }""" val profilingTraceData = fixture.serializer.deserialize(StringReader(json), ProfilingTraceData::class.java) @@ -616,6 +684,17 @@ class JsonSerializerTest { } ) assertEquals(expectedTransactions, profilingTraceData.transactions) + val expectedMeasurements = mapOf( + ProfileMeasurement.ID_SCREEN_FRAME_RATES to ProfileMeasurement( + ProfileMeasurement.UNIT_HZ, + listOf(ProfileMeasurementValue(1, 60.1)) + ), + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS to ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, + listOf(ProfileMeasurementValue(2, 100)) + ) + ) + assertEquals(expectedMeasurements, profilingTraceData.measurementsMap) assertEquals("transactionName", profilingTraceData.transactionName) assertEquals("100", profilingTraceData.durationNs) assertEquals("versionName", profilingTraceData.versionName) @@ -624,9 +703,51 @@ class JsonSerializerTest { assertEquals("traceId", profilingTraceData.traceId) assertEquals("profileId", profilingTraceData.profileId) assertEquals("environment", profilingTraceData.environment) + assertEquals("truncationReason", profilingTraceData.truncationReason) assertEquals("sampled profile in base 64", profilingTraceData.sampledProfile) } + @Test + fun `serializes profileMeasurement`() { + val measurementValues = listOf(ProfileMeasurementValue(1, 2), ProfileMeasurementValue(3, 4)) + val profileMeasurement = ProfileMeasurement(ProfileMeasurement.UNIT_NANOSECONDS, measurementValues) + val actual = serializeToString(profileMeasurement) + val expected = "{\"unit\":\"nanoseconds\",\"values\":[{\"value\":\"2\",\"elapsed_since_start_ns\":1},{\"value\":\"4\",\"elapsed_since_start_ns\":3}]}" + assertEquals(expected, actual) + } + + @Test + fun `deserializes profileMeasurement`() { + val json = """{ + "unit":"hz", + "values":[ + {"value":"60.1","elapsed_since_start_ns":"1"},{"value":"100","elapsed_since_start_ns":"2"} + ] + }""" + val profileMeasurement = fixture.serializer.deserialize(StringReader(json), ProfileMeasurement::class.java) + val expected = ProfileMeasurement( + ProfileMeasurement.UNIT_HZ, + listOf(ProfileMeasurementValue(1, 60.1), ProfileMeasurementValue(2, 100)) + ) + assertEquals(expected, profileMeasurement) + } + + @Test + fun `serializes profileMeasurementValue`() { + val profileMeasurementValue = ProfileMeasurementValue(1, 2) + val actual = serializeToString(profileMeasurementValue) + val expected = "{\"value\":\"2\",\"elapsed_since_start_ns\":1}" + assertEquals(expected, actual) + } + + @Test + fun `deserializes profileMeasurementValue`() { + val json = """{"value":"60.1","elapsed_since_start_ns":"1"}""" + val profileMeasurementValue = fixture.serializer.deserialize(StringReader(json), ProfileMeasurementValue::class.java) + val expected = ProfileMeasurementValue(1, 60.1) + assertEquals(expected, profileMeasurementValue) + } + @Test fun `serializes transaction`() { val trace = TransactionContext("transaction-name", "http") From 1585c22ea792a7af4c582e579a0d049083ddddbf Mon Sep 17 00:00:00 2001 From: stefanosiano Date: Mon, 7 Nov 2022 17:38:59 +0100 Subject: [PATCH 2/2] added ProfileMeasurements to AndroidTransactionProfiler and ProfilingTraceData added frameMetrics (slow and frozen frames) and screen refresh rate added a SentryFrameMetricsCollector to collect frameMetrics changed a benchmark device on Saucelabs as it's not available anymore --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 381ae518c60..f3bb776c953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Provide hook for Jetpack Compose navigation instrumentation ([#2320](https://github.com/getsentry/sentry-java/pull/2320)) - Populate `event.modules` with dependencies metadata ([#2324](https://github.com/getsentry/sentry-java/pull/2324)) - Support Spring 6 and Spring Boot 3 ([#2289](https://github.com/getsentry/sentry-java/pull/2289)) +- added FrameMetrics to Android profiling data ([#2342](https://github.com/getsentry/sentry-java/pull/2342)) ### Dependencies