diff --git a/.sauce/sentry-uitest-android-benchmark-lite.yml b/.sauce/sentry-uitest-android-benchmark-lite.yml index 9ba72dec00..12dcb4cec2 100644 --- a/.sauce/sentry-uitest-android-benchmark-lite.yml +++ b/.sauce/sentry-uitest-android-benchmark-lite.yml @@ -20,7 +20,7 @@ suites: - name: "Android 11 (api 30)" devices: - - id: OnePlus_Nord_N200_5G_real_us # OnePlus Nord N200 5G - api 30 (11) + - id: Google_Pixel_3a_real # Google Pixel 3a - api 30 (11) artifacts: download: diff --git a/.sauce/sentry-uitest-android-benchmark.yml b/.sauce/sentry-uitest-android-benchmark.yml index d7d376b8e4..f136a967f9 100644 --- a/.sauce/sentry-uitest-android-benchmark.yml +++ b/.sauce/sentry-uitest-android-benchmark.yml @@ -28,11 +28,11 @@ suites: devices: - id: OnePlus_9_Pro_real_us # OnePlus 9 Pro - api 30 (11) - high end - id: Google_Pixel_4_real_us # Google Pixel 4 - api 30 (11) - mid end - - id: OnePlus_Nord_N200_5G_real_us # OnePlus Nord N200 5G - api 30 (11) - low end + - id: Google_Pixel_3a_real # Google Pixel 3a - api 30 (11) - low end - name: "Android 10 (api 29)" devices: - - id: OnePlus_7T_real_us # OnePlus 7T - api 29 (10) + - id: Google_Pixel_4_XL_real_us1 # Google Pixel 4 XL - 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/CHANGELOG.md b/CHANGELOG.md index 5588cdda14..3ee6a1d584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Fixes + +- Remove profiler main thread io ([#2348](https://github.com/getsentry/sentry-java/pull/2348)) + +### Features + +- Add FrameMetrics to Android profiling data ([#2342](https://github.com/getsentry/sentry-java/pull/2342)) + ## 6.7.1 ### Fixes @@ -11,6 +21,8 @@ ### Features +- Don't set device name on Android if `sendDefaultPii` is disabled ([#2354](https://github.com/getsentry/sentry-java/pull/2354)) +- Fix corrupted UUID on Motorola devices ([#2363](https://github.com/getsentry/sentry-java/pull/2363)) - Update Spring Boot Jakarta to Spring Boot 3.0.0-RC2 ([#2347](https://github.com/getsentry/sentry-java/pull/2347)) ## 6.7.0 diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 6e8718b81c..20aee6be05 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -139,10 +139,8 @@ object Config { } object TestLibs { - // todo These rc versions are needed to run ui tests on Android 13. - // They will be replaced by stable version when 1.5.0/3.5.0 will be out. - private val androidxTestVersion = "1.5.0-rc01" - private val espressoVersion = "3.5.0-rc01" + private val androidxTestVersion = "1.5.0" + private val espressoVersion = "3.5.0" val androidJUnitRunner = "androidx.test.runner.AndroidJUnitRunner" val kotlinTestJunit = "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" 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 0ecbbb9067..8f3543825b 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 @@ -13,6 +13,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.modules.AssetsModulesLoader; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; import io.sentry.util.Objects; @@ -154,8 +155,10 @@ static void init( options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker)); options.setTransportGate(new AndroidTransportGate(context, options.getLogger())); + final 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 6b3dc74baa..ced0d976e9 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; @@ -20,15 +21,20 @@ import io.sentry.SentryEnvelope; import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.CpuInfoUtils; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; 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 +66,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 slowFrameRenderMeasurements = + new ArrayDeque<>(); + private final @NotNull ArrayDeque frozenFrameRenderMeasurements = + 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); @@ -115,9 +139,13 @@ private void init() { traceFilesDir = new File(tracesFilesDirPath); } - @SuppressLint("NewApi") @Override - public synchronized void onTransactionStart(@NotNull ITransaction transaction) { + public synchronized void onTransactionStart(final @NotNull ITransaction transaction) { + options.getExecutorService().submit(() -> onTransactionStartSafe(transaction)); + } + + @SuppressLint("NewApi") + private void onTransactionStartSafe(final @NotNull ITransaction transaction) { // Debug.startMethodTracingSampling() is only available since Lollipop if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return; @@ -127,38 +155,14 @@ public synchronized void onTransactionStart(@NotNull ITransaction transaction) { // traceFilesDir is null or intervalUs is 0 only if there was a problem in the init, but // we already logged that - if (traceFilesDir == null || intervalUs == 0 || !traceFilesDir.exists()) { + if (traceFilesDir == null || intervalUs == 0 || !traceFilesDir.canWrite()) { return; } transactionsCounter++; // When the first transaction is starting, we can start profiling if (transactionsCounter == 1) { - - traceFile = new File(traceFilesDir, UUID.randomUUID() + ".trace"); - - if (traceFile.exists()) { - options - .getLogger() - .log(SentryLevel.DEBUG, "Trace file already exists: %s", traceFile.getPath()); - 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); } else { ProfilingTransactionData transactionData = new ProfilingTransactionData( @@ -175,14 +179,73 @@ public synchronized void onTransactionStart(@NotNull ITransaction transaction) { transactionsCounter); } + @SuppressLint("NewApi") + private void onFirstTransactionStarted(final @NotNull ITransaction transaction) { + // We create a file with a uuid name, so no need to check if it already exists + traceFile = new File(traceFilesDir, UUID.randomUUID() + ".trace"); + + measurementsMap.clear(); + screenFrameRateMeasurements.clear(); + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.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) { + frozenFrameRenderMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); + } else if (isSlow) { + slowFrameRenderMeasurements.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); + public synchronized void onTransactionFinish(final @NotNull ITransaction transaction) { + options.getExecutorService().submit(() -> onTransactionFinish(transaction, false)); } @SuppressLint("NewApi") - private synchronized void onTransactionFinish( - @NotNull ITransaction transaction, boolean isTimeout) { + private void onTransactionFinish( + final @NotNull ITransaction transaction, final boolean isTimeout) { // onTransactionStart() is only available since Lollipop // and SystemClock.elapsedRealtimeNanos() since Jelly Bean @@ -226,8 +289,14 @@ private synchronized void onTransactionFinish( } return; } + onLastTransactionFinished(transaction, isTimeout); + } + @SuppressLint("NewApi") + private void onLastTransactionFinished(final ITransaction transaction, final boolean isTimeout) { Debug.stopMethodTracing(); + frameMetricsCollector.stopCollection(frameMetricsCollectorId); + long transactionEndNanos = SystemClock.elapsedRealtimeNanos(); long transactionEndCpuMillis = Process.getElapsedCpuTime(); long transactionDurationNanos = transactionEndNanos - transactionStartNanos; @@ -270,6 +339,23 @@ private synchronized void onTransactionFinish( profileStartCpuMillis); } + if (!slowFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement(ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + } + // 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 +378,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/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java new file mode 100644 index 0000000000..76ad621771 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -0,0 +1,215 @@ +package io.sentry.android.core.internal.util; + +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.RequiresApi; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.BuildInfoProvider; +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 final @NotNull SentryOptions options; + 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( + final @NotNull Context context, + final @NotNull SentryOptions options, + final @NotNull BuildInfoProvider buildInfoProvider) { + this(context, options, buildInfoProvider, new WindowFrameMetricsManager() {}); + } + + @SuppressWarnings("deprecation") + @SuppressLint("NewApi") + public SentryFrameMetricsCollector( + final @NotNull Context context, + final @NotNull SentryOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { + Objects.requireNonNull(context, "The context is required"); + this.options = 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.internal.util.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); + } + }; + } + + // addOnFrameMetricsAvailableListener internally calls Activity.getWindow().getDecorView(), + // which cannot be called before setContentView. That's why we call it in onActivityStarted() + @Override + public void onActivityCreated(@NotNull Activity activity, @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(@NotNull Activity activity) { + setCurrentWindow(activity.getWindow()); + } + + @Override + public void onActivityResumed(@NotNull Activity activity) {} + + @Override + public void onActivityPaused(@NotNull Activity activity) {} + + @Override + public void onActivityStopped(@NotNull Activity activity) { + clearCurrentWindow(activity.getWindow()); + } + + @Override + public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bundle outState) {} + + @Override + public void onActivityDestroyed(@NotNull Activity activity) {} + + public @Nullable String startCollection(final @NotNull FrameMetricsCollectorListener listener) { + if (!isAvailable) { + return null; + } + final String uid = UUID.randomUUID().toString(); + listenerMap.put(uid, listener); + trackCurrentWindow(); + return uid; + } + + public void stopCollection(final @Nullable String listenerId) { + if (!isAvailable) { + return; + } + if (listenerId != null) { + listenerMap.remove(listenerId); + } + Window window = currentWindow != null ? currentWindow.get() : null; + if (window != null && listenerMap.isEmpty()) { + clearCurrentWindow(window); + } + } + + @SuppressLint("NewApi") + private void clearCurrentWindow(final @NotNull Window window) { + if (currentWindow != null && currentWindow.get() == window) { + currentWindow = null; + } + if (trackedWindows.contains(window)) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { + try { + windowFrameMetricsManager.removeOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener); + } catch (Exception e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e); + } + } + trackedWindows.remove(window); + } + } + + private void setCurrentWindow(final @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(final @NotNull FrameMetrics frameMetrics, final float refreshRate); + } + + @ApiStatus.Internal + public interface WindowFrameMetricsManager { + @RequiresApi(api = Build.VERSION_CODES.N) + default void addOnFrameMetricsAvailableListener( + final @NotNull Window window, + final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener, + final @Nullable Handler handler) { + window.addOnFrameMetricsAvailableListener(frameMetricsAvailableListener, handler); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + default void removeOnFrameMetricsAvailableListener( + final @NotNull Window window, + final @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 bbdc8dc525..57dcb5448c 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 @@ -11,11 +11,11 @@ import io.sentry.ProfilingTraceData import io.sentry.SentryLevel import io.sentry.SentryTracer import io.sentry.TransactionContext +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.assertEnvelopeItem import io.sentry.test.getCtor import org.junit.runner.RunWith import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -24,6 +24,8 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.io.File +import java.util.concurrent.Future +import java.util.concurrent.FutureTask import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -37,7 +39,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 { @@ -46,14 +48,29 @@ class AndroidTransactionProfilerTest { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) } val mockLogger = mock() + var lastScheduledRunnable: Runnable? = null + val mockExecutorService = object : ISentryExecutorService { + override fun submit(runnable: Runnable): Future<*> { + runnable.run() + return FutureTask {} + } + override fun schedule(runnable: Runnable, delayMillis: Long): Future<*> { + lastScheduledRunnable = runnable + return FutureTask {} + } + override fun close(timeoutMillis: Long) {} + } + val options = spy(SentryAndroidOptions()).apply { dsn = mockDsn profilesSampleRate = 1.0 isDebug = true setLogger(mockLogger) + executorService = mockExecutorService } val hub: IHub = mock() + val frameMetricsCollector: SentryFrameMetricsCollector = mock() lateinit var transaction1: SentryTracer lateinit var transaction2: SentryTracer @@ -64,7 +81,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 +107,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)) } } @@ -241,6 +261,15 @@ class AndroidTransactionProfilerTest { assertNotNull(traceData) } + @Test + fun `profiler uses background threads`() { + val profiler = fixture.getSut(context) + fixture.options.executorService = mock() + profiler.onTransactionStart(fixture.transaction1) + profiler.onTransactionFinish(fixture.transaction1) + verify(fixture.hub, never()).captureEnvelope(any()) + } + @Test fun `onTransactionFinish works only if previously started`() { val profiler = fixture.getSut(context) @@ -252,16 +281,11 @@ class AndroidTransactionProfilerTest { fun `timedOutData has timeout truncation reason`() { val profiler = fixture.getSut(context) - val executorService = mock() - val captor = argumentCaptor() - whenever(executorService.schedule(captor.capture(), any())).thenReturn(null) - whenever(fixture.options.executorService).thenReturn(executorService) - // Start and finish first transaction profiling profiler.onTransactionStart(fixture.transaction1) // Set timed out data by calling the timeout scheduled job - captor.firstValue.run() + fixture.lastScheduledRunnable?.run() // First transaction finishes: timed out data is returned profiler.onTransactionFinish(fixture.transaction1) @@ -328,4 +352,26 @@ 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) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt new file mode 100644 index 0000000000..3fa3e77184 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -0,0 +1,232 @@ +package io.sentry.android.core.internal.util + +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.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.internal.util.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-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/BaseBenchmarkTest.kt b/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/BaseBenchmarkTest.kt index 4417d0f72d..d124e41f63 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/BaseBenchmarkTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/BaseBenchmarkTest.kt @@ -24,6 +24,7 @@ abstract class BaseBenchmarkTest { runner.runOnMainSync { choreographer = Choreographer.getInstance() } + // We need the refresh rate, but we can get it only from the activity, so we start and destroy one val benchmarkScenario = launchActivity() benchmarkScenario.moveToState(Lifecycle.State.DESTROYED) diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt b/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt index 9c607be133..90fcca6d89 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/src/androidTest/java/io/sentry/uitest/android/benchmark/SentryBenchmarkTest.kt @@ -89,7 +89,7 @@ class SentryBenchmarkTest : BaseBenchmarkTest() { comparisonResult.printResults() // Currently we just want to assert the cpu overhead - assertTrue(comparisonResult.cpuTimeIncreasePercentage in 0F..5F) + assertTrue(comparisonResult.cpuTimeIncreasePercentage in 0F..5.5F) } /** 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 0fd166c770..2ac15bebc0 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 f42a336cab..e41336fe48 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,13 +12,14 @@ 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 import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -49,12 +50,29 @@ class EnvelopeTests : BaseUiTest() { options.tracesSampleRate = 1.0 options.profilesSampleRate = 1.0 } + relayIdlingResource.increment() - relayIdlingResource.increment() + IdlingRegistry.getInstance().register(ProfilingSampleActivity.scrollingIdlingResource) val transaction = Sentry.startTransaction("e2etests", "test1") + val sampleScenario = launchActivity() + swipeList(1) + sampleScenario.moveToState(Lifecycle.State.DESTROYED) + IdlingRegistry.getInstance().unregister(ProfilingSampleActivity.scrollingIdlingResource) + relayIdlingResource.increment() + relayIdlingResource.increment() transaction.finish() relay.assert { + assertEnvelope { + val transactionItem: SentryTransaction = it.assertItem() + it.assertNoOtherItems() + assertEquals("ProfilingSampleActivity", transactionItem.transaction) + } + assertEnvelope { + val transactionItem: SentryTransaction = it.assertItem() + it.assertNoOtherItems() + assertEquals("e2etests", transactionItem.transaction) + } assertEnvelope { val profilingTraceData: ProfilingTraceData = it.assertItem() it.assertNoOtherItems() @@ -63,16 +81,28 @@ 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]!! + // Slow and frozen frames can be null (in case there were none) + if (slowFrames != null) { + assertEquals(ProfileMeasurement.UNIT_NANOSECONDS, slowFrames.unit) + } + if (frozenFrames != null) { + assertEquals(ProfileMeasurement.UNIT_NANOSECONDS, frozenFrames.unit) + } + // There could be no slow/frozen frames, but we expect at least one frame rate + assertEquals(ProfileMeasurement.UNIT_HZ, frameRates.unit) + 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() } assertNotNull(transactionData) } - assertEnvelope { - val transactionItem: SentryTransaction = it.assertItem() - it.assertNoOtherItems() - assertEquals("e2etests", transactionItem.transaction) - } assertNoOtherEnvelopes() assertNoOtherRequests() } @@ -108,6 +138,12 @@ class EnvelopeTests : BaseUiTest() { assertEquals(transaction2.eventId.toString(), transactionItem.eventId.toString()) } // The profile is sent only in the last transaction envelope + assertEnvelope { + val transactionItem: SentryTransaction = it.assertItem() + it.assertNoOtherItems() + assertEquals(transaction3.eventId.toString(), transactionItem.eventId.toString()) + } + // The profile is sent only in the last transaction envelope assertEnvelope { val profilingTraceData: ProfilingTraceData = it.assertItem() it.assertNoOtherItems() @@ -141,12 +177,6 @@ class EnvelopeTests : BaseUiTest() { // The first and last transactions should be aligned to the start/stop of profile assertEquals(endTimes.last() - startTimes.first(), profilingTraceData.durationNs.toLong()) } - // The profile is sent only in the last transaction envelope - assertEnvelope { - val transactionItem: SentryTransaction = it.assertItem() - it.assertNoOtherItems() - assertEquals(transaction3.eventId.toString(), transactionItem.eventId.toString()) - } assertNoOtherEnvelopes() assertNoOtherRequests() } @@ -172,22 +202,26 @@ class EnvelopeTests : BaseUiTest() { } }.start() transaction.finish() - finished = true + // The profiler is stopped in background on the executor service, so we can stop deleting the trace file + // only after the profiler is stopped. This means we have to stop the deletion in the executorService + Sentry.getCurrentHub().options.executorService.submit { + finished = true + } relay.assert { - // The profile failed to be sent. Trying to read the envelope from the data transmitted throws an exception - assertFails { assertEnvelope {} } assertEnvelope { val transactionItem: SentryTransaction = it.assertItem() it.assertNoOtherItems() assertEquals("e2etests", transactionItem.transaction) } + // The profile failed to be sent. Trying to read the envelope from the data transmitted throws an exception + assertFailsWith { assertEnvelope {} } assertNoOtherEnvelopes() assertNoOtherRequests() } } -// @Test + @Test fun checkTimedOutProfile() { // We increase the IdlingResources timeout to exceed the profiling timeout IdlingPolicies.setIdlingResourceTimeout(1, TimeUnit.MINUTES) 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 95ed3b0bd5..60def396b9 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 @@ -59,14 +59,20 @@ class ProfilingActivity : AppCompatActivity() { executors.submit { runMathOperations() } } executors.submit { swipeList() } - binding.profilingStart.postDelayed({ finishTransactionAndPrintResults(t) }, (seconds * 1000).toLong()) + + Thread { + Thread.sleep((seconds * 1000).toLong()) + finishTransactionAndPrintResults(t) + binding.root.post { + binding.profilingProgressBar.visibility = View.GONE + } + }.start() } setContentView(binding.root) } private fun finishTransactionAndPrintResults(t: ITransaction) { t.finish() - binding.profilingProgressBar.visibility = View.GONE profileFinished = true val profilesDirPath = Sentry.getCurrentHub().options.profilingTracesDirPath if (profilesDirPath == null) { @@ -82,25 +88,31 @@ class ProfilingActivity : AppCompatActivity() { Thread.sleep((timeout - duration).coerceAtLeast(0)) } - // Get the last trace file, which is the current profile - val origProfileFile = File(profilesDirPath).listFiles()?.maxByOrNull { f -> f.lastModified() } - // Create a new profile file and copy the content of the original file into it - val profile = File(cacheDir, UUID.randomUUID().toString()) - origProfileFile?.copyTo(profile) - - val profileLength = profile.length() - val traceData = ProfilingTraceData(profile, t) - // Create envelope item from copied profile - val item = - SentryEnvelopeItem.fromProfilingTrace(traceData, Long.MAX_VALUE, Sentry.getCurrentHub().options.serializer) - val itemData = item.data - - // Compress the envelope item using Gzip - val bos = ByteArrayOutputStream() - GZIPOutputStream(bos).bufferedWriter().use { it.write(String(itemData)) } - - binding.profilingResult.text = - getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) + try { + // Get the last trace file, which is the current profile + val origProfileFile = File(profilesDirPath).listFiles()?.maxByOrNull { f -> f.lastModified() } + // Create a new profile file and copy the content of the original file into it + val profile = File(cacheDir, UUID.randomUUID().toString()) + origProfileFile?.copyTo(profile) + + val profileLength = profile.length() + val traceData = ProfilingTraceData(profile, t) + // Create envelope item from copied profile + val item = + SentryEnvelopeItem.fromProfilingTrace(traceData, Long.MAX_VALUE, Sentry.getCurrentHub().options.serializer) + val itemData = item.data + + // Compress the envelope item using Gzip + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).bufferedWriter().use { it.write(String(itemData)) } + + binding.root.post { + binding.profilingResult.text = + getString(R.string.profiling_result, profileLength, itemData.size, bos.toByteArray().size) + } + } catch (e: Exception) { + e.printStackTrace() + } } private fun swipeList() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 857fa457af..be010cdc39 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 52c5d2e2da..b409110573 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 764bb005bc..c26fc32719 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 */ @@ -71,7 +74,8 @@ private ProfilingTraceData() { this(new File("dummy"), NoOpTransaction.getInstance()); } - public ProfilingTraceData(@NotNull File traceFile, @NotNull ITransaction transaction) { + public ProfilingTraceData( + final @NotNull File traceFile, final @NotNull ITransaction transaction) { this( traceFile, new ArrayList<>(), @@ -90,27 +94,29 @@ public ProfilingTraceData(@NotNull File traceFile, @NotNull ITransaction transac null, null, null, - TRUNCATION_REASON_NORMAL); + TRUNCATION_REASON_NORMAL, + new HashMap<>()); } public ProfilingTraceData( - @NotNull File traceFile, - @NotNull List transactions, - @NotNull ITransaction transaction, - @NotNull String durationNanos, - int sdkInt, - @NotNull String cpuArchitecture, - @NotNull Callable> deviceCpuFrequenciesReader, - @Nullable String deviceManufacturer, - @Nullable String deviceModel, - @Nullable String deviceOsVersion, - @Nullable Boolean deviceIsEmulator, - @Nullable String devicePhysicalMemoryBytes, - @Nullable String buildId, - @Nullable String versionName, - @Nullable String versionCode, - @Nullable String environment, - @NotNull String truncationReason) { + final @NotNull File traceFile, + final @NotNull List transactions, + final @NotNull ITransaction transaction, + final @NotNull String durationNanos, + final int sdkInt, + final @NotNull String cpuArchitecture, + final @NotNull Callable> deviceCpuFrequenciesReader, + final @Nullable String deviceManufacturer, + final @Nullable String deviceModel, + final @Nullable String deviceOsVersion, + final @Nullable Boolean deviceIsEmulator, + final @Nullable String devicePhysicalMemoryBytes, + final @Nullable String buildId, + final @Nullable String versionName, + final @Nullable String versionCode, + final @Nullable String environment, + final @NotNull String truncationReason, + final @NotNull Map measurementsMap) { this.traceFile = traceFile; this.cpuArchitecture = cpuArchitecture; this.deviceCpuFrequenciesReader = deviceCpuFrequenciesReader; @@ -147,6 +153,7 @@ public ProfilingTraceData( if (!isTruncationReasonValid()) { this.truncationReason = TRUNCATION_REASON_NORMAL; } + this.measurementsMap = measurementsMap; } private boolean isTruncationReasonValid() { @@ -257,91 +264,101 @@ public boolean isDeviceIsEmulator() { return truncationReason; } - public void setAndroidApiLevel(int androidApiLevel) { + public @NotNull Map getMeasurementsMap() { + return measurementsMap; + } + + public void setAndroidApiLevel(final int androidApiLevel) { this.androidApiLevel = androidApiLevel; } - public void setCpuArchitecture(@NotNull String cpuArchitecture) { + public void setCpuArchitecture(final @NotNull String cpuArchitecture) { this.cpuArchitecture = cpuArchitecture; } - public void setDeviceLocale(@NotNull String deviceLocale) { + public void setDeviceLocale(final @NotNull String deviceLocale) { this.deviceLocale = deviceLocale; } - public void setDeviceManufacturer(@NotNull String deviceManufacturer) { + public void setDeviceManufacturer(final @NotNull String deviceManufacturer) { this.deviceManufacturer = deviceManufacturer; } - public void setDeviceModel(@NotNull String deviceModel) { + public void setDeviceModel(final @NotNull String deviceModel) { this.deviceModel = deviceModel; } - public void setDeviceOsBuildNumber(@NotNull String deviceOsBuildNumber) { + public void setDeviceOsBuildNumber(final @NotNull String deviceOsBuildNumber) { this.deviceOsBuildNumber = deviceOsBuildNumber; } - public void setDeviceOsVersion(@NotNull String deviceOsVersion) { + public void setDeviceOsVersion(final @NotNull String deviceOsVersion) { this.deviceOsVersion = deviceOsVersion; } - public void setDeviceIsEmulator(boolean deviceIsEmulator) { + public void setDeviceIsEmulator(final boolean deviceIsEmulator) { this.deviceIsEmulator = deviceIsEmulator; } - public void setDeviceCpuFrequencies(@NotNull List deviceCpuFrequencies) { + public void setDeviceCpuFrequencies(final @NotNull List deviceCpuFrequencies) { this.deviceCpuFrequencies = deviceCpuFrequencies; } - public void setDevicePhysicalMemoryBytes(@NotNull String devicePhysicalMemoryBytes) { + public void setDevicePhysicalMemoryBytes(final @NotNull String devicePhysicalMemoryBytes) { this.devicePhysicalMemoryBytes = devicePhysicalMemoryBytes; } - public void setBuildId(@NotNull String buildId) { + public void setTruncationReason(final @NotNull String truncationReason) { + this.truncationReason = truncationReason; + } + + public void setTransactions(final @NotNull List transactions) { + this.transactions = transactions; + } + + public void setBuildId(final @NotNull String buildId) { this.buildId = buildId; } - public void setTransactionName(@NotNull String transactionName) { + public void setTransactionName(final @NotNull String transactionName) { this.transactionName = transactionName; } - public void setDurationNs(@NotNull String durationNs) { + public void setDurationNs(final @NotNull String durationNs) { this.durationNs = durationNs; } - public void setVersionName(@NotNull String versionName) { + public void setVersionName(final @NotNull String versionName) { this.versionName = versionName; } - public void setVersionCode(@NotNull String versionCode) { + public void setVersionCode(final @NotNull String versionCode) { this.versionCode = versionCode; } - public void setTransactionId(@NotNull String transactionId) { + public void setTransactionId(final @NotNull String transactionId) { this.transactionId = transactionId; } - public void setTraceId(@NotNull String traceId) { + public void setTraceId(final @NotNull String traceId) { this.traceId = traceId; } - public void setProfileId(@NotNull String profileId) { + public void setProfileId(final @NotNull String profileId) { this.profileId = profileId; } - public void setEnvironment(@NotNull String environment) { + public void setEnvironment(final @NotNull String environment) { this.environment = environment; } - public void setSampledProfile(@Nullable String sampledProfile) { + public void setSampledProfile(final @Nullable String sampledProfile) { this.sampledProfile = sampledProfile; } public void readDeviceCpuFrequencies() { try { - if (deviceCpuFrequenciesReader != null) { - this.deviceCpuFrequencies = deviceCpuFrequenciesReader.call(); - } + this.deviceCpuFrequencies = deviceCpuFrequenciesReader.call(); } catch (Throwable ignored) { // should never happen } @@ -374,10 +391,11 @@ 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 - public void serialize(@NotNull JsonObjectWriter writer, @NotNull ILogger logger) + public void serialize(final @NotNull JsonObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); writer.name(JsonKeys.ANDROID_API_LEVEL).value(logger, androidApiLevel); @@ -409,6 +427,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); @@ -426,7 +445,7 @@ public Map getUnknown() { } @Override - public void setUnknown(@Nullable Map unknown) { + public void setUnknown(final @Nullable Map unknown) { this.unknown = unknown; } @@ -435,7 +454,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; @@ -582,6 +601,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 0000000000..0e80cbb4a3 --- /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 = "nanosecond"; + 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( + final @NotNull String unit, final @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(final @NotNull JsonObjectWriter writer, final @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(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public void setUnit(final @NotNull String unit) { + this.unit = unit; + } + + public @NotNull Collection getValues() { + return values; + } + + public void setValues(final @NotNull Collection values) { + this.values = values; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ProfileMeasurement deserialize( + final @NotNull JsonObjectReader reader, final @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 0000000000..22d48b4a38 --- /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(final @NotNull Long relativeStartNs, final @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(final @NotNull JsonObjectWriter writer, final @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(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ProfileMeasurementValue deserialize( + final @NotNull JsonObjectReader reader, final @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 57ef90e4b3..53a4af094e 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":"nanosecond", + "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\":\"nanosecond\",\"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")