From ed98d118cc49af2250fcfcac128ea709e5a93b0d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jan 2025 09:04:06 +0100 Subject: [PATCH 01/24] Various fixes to instrumentations running on the main thread (#4051) * Get rid of redundant requireNonNull * Do not instrument Window.Callback multiple times * Do not instrument FileIO if tracing is disabled * Do not traverse children if a touch event is not within view groups bounds * Add test for SentryFileOutputStream * Fix test * Fix test * Changelog * pr id * Fix api dump --- CHANGELOG.md | 8 +++ .../core/UserInteractionIntegration.java | 5 ++ .../AndroidViewGestureTargetLocator.java | 26 +++------- .../core/internal/gestures/ViewUtils.java | 36 +++++++++++-- .../core/UserInteractionIntegrationTest.kt | 24 +++++++++ .../SentryGestureListenerClickTest.kt | 22 ++++++-- .../gestures/ComposeGestureTargetLocator.java | 2 +- sentry/api/sentry.api | 1 + .../file/SentryFileInputStream.java | 27 +++++++--- .../file/SentryFileOutputStream.java | 45 +++++++++++++--- .../gestures/GestureTargetLocator.java | 3 +- .../file/SentryFileInputStreamTest.kt | 17 +++++-- .../file/SentryFileOutputStreamTest.kt | 51 ++++++++++++++++--- 13 files changed, 215 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3857217a987..208e9bf4c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) + ## 7.20.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index a0ad3591669..87e60fa6bfd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -50,6 +50,11 @@ private void startTracking(final @NotNull Activity activity) { delegate = new NoOpWindowCallback(); } + if (delegate instanceof SentryWindowCallback) { + // already instrumented + return; + } + final SentryGestureListener gestureListener = new SentryGestureListener(activity, hub, options); window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index 945ebeef649..f271c3da9e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -18,7 +18,6 @@ public final class AndroidViewGestureTargetLocator implements GestureTargetLocat private static final String ORIGIN = "old_view_system"; private final boolean isAndroidXAvailable; - private final int[] coordinates = new int[2]; public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; @@ -26,18 +25,16 @@ public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { if (!(root instanceof View)) { return null; } final View view = (View) root; - if (touchWithinBounds(view, x, y)) { - if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { - return createUiElement(view); - } else if (targetType == UiElement.Type.SCROLLABLE - && isViewScrollable(view, isAndroidXAvailable)) { - return createUiElement(view); - } + if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { + return createUiElement(view); + } else if (targetType == UiElement.Type.SCROLLABLE + && isViewScrollable(view, isAndroidXAvailable)) { + return createUiElement(view); } return null; } @@ -52,17 +49,6 @@ private UiElement createUiElement(final @NotNull View targetView) { } } - private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); - } - private static boolean isViewTappable(final @NotNull View view) { return view.isClickable() && view.getVisibility() == View.VISIBLE; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 6e7dab2ef5a..8aa8e89d694 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -7,7 +7,6 @@ import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import io.sentry.util.Objects; import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,6 +16,32 @@ @ApiStatus.Internal public final class ViewUtils { + private static final int[] coordinates = new int[2]; + + /** + * Verifies if the given touch coordinates are within the bounds of the given view. + * + * @param view the view to check if the touch coordinates are within its bounds + * @param x - the x coordinate of a {@link MotionEvent} + * @param y - the y coordinate of {@link MotionEvent} + * @return true if the touch coordinates are within the bounds of the view, false otherwise + */ + private static boolean touchWithinBounds( + final @Nullable View view, final float x, final float y) { + if (view == null) { + return false; + } + + view.getLocationOnScreen(coordinates); + int vx = coordinates[0]; + int vy = coordinates[1]; + + int w = view.getWidth(); + int h = view.getHeight(); + + return !(x < vx || x > vx + w || y < vy || y > vy + h); + } + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. @@ -40,7 +65,12 @@ public final class ViewUtils { @Nullable UiElement target = null; while (queue.size() > 0) { - final View view = Objects.requireNonNull(queue.poll(), "view is required"); + final View view = queue.poll(); + + if (!touchWithinBounds(view, x, y)) { + // if the touch is not hitting the view, skip traversal of its children + continue; + } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; @@ -54,7 +84,7 @@ public final class ViewUtils { if (newTarget != null) { if (targetType == UiElement.Type.CLICKABLE) { target = newTarget; - } else { + } else if (targetType == UiElement.Type.SCROLLABLE) { return newTarget; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index d43dfe14197..f126e6c9229 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -140,6 +140,7 @@ class UserInteractionIntegrationTest { ) ) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(fixture.window).callback = null @@ -160,6 +161,7 @@ class UserInteractionIntegrationTest { ) ) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(fixture.window).callback = delegate @@ -170,8 +172,30 @@ class UserInteractionIntegrationTest { val callback = mock() val sut = fixture.getSut(callback) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(callback).stopTracking() } + + @Test + fun `does not instrument if the callback is already ours`() { + val delegate = mock() + val context = mock() + val resources = Fixture.mockResources() + whenever(context.resources).thenReturn(resources) + val existingCallback = SentryWindowCallback( + delegate, + context, + mock(), + mock() + ) + val sut = fixture.getSut(existingCallback) + + sut.register(fixture.hub, fixture.options) + sut.onActivityResumed(fixture.activity) + + val argumentCaptor = argumentCaptor() + verify(fixture.window, never()).callback = argumentCaptor.capture() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index 1e6652276a7..c864e3d4b57 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -185,7 +185,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -214,7 +214,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -223,7 +223,7 @@ class SentryGestureListenerClickTest { val event = mock() val sut = fixture.getSut(event, attachViewsToRoot = false) - fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + fixture.window.mockDecorView(event = event, touchWithinBounds = true) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(fixture.target) } @@ -244,7 +244,7 @@ class SentryGestureListenerClickTest { val event = mock() val sut = fixture.getSut(event, attachViewsToRoot = false) - fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + fixture.window.mockDecorView(event = event, touchWithinBounds = true) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(fixture.target) } @@ -253,4 +253,18 @@ class SentryGestureListenerClickTest { verify(fixture.scope).propagationContext = any() } + + @Test + fun `if touch is not within view group bounds does not traverse its children`() { + val event = mock() + val sut = fixture.getSut(event, attachViewsToRoot = false) + fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(fixture.target) + } + + sut.onSingleTapUp(event) + + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + } } diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java index aaf085f4841..99cc5414419 100644 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -39,7 +39,7 @@ public ComposeGestureTargetLocator(final @NotNull ILogger logger) { @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { // lazy init composeHelper as it's using some reflection under the hood if (composeHelper == null) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c38d11e945f..b7cb1adfc7e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3645,6 +3645,7 @@ public final class io/sentry/instrumentation/file/SentryFileOutputStream : java/ public final class io/sentry/instrumentation/file/SentryFileOutputStream$Factory { public fun ()V public static fun create (Ljava/io/FileOutputStream;Ljava/io/File;)Ljava/io/FileOutputStream; + public static fun create (Ljava/io/FileOutputStream;Ljava/io/File;Lio/sentry/IHub;)Ljava/io/FileOutputStream; public static fun create (Ljava/io/FileOutputStream;Ljava/io/File;Z)Ljava/io/FileOutputStream; public static fun create (Ljava/io/FileOutputStream;Ljava/io/FileDescriptor;)Ljava/io/FileOutputStream; public static fun create (Ljava/io/FileOutputStream;Ljava/lang/String;)Ljava/io/FileOutputStream; diff --git a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java index 04bb87ae7c2..e1b46276bcf 100644 --- a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java +++ b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java @@ -3,6 +3,7 @@ import io.sentry.HubAdapter; import io.sentry.IHub; import io.sentry.ISpan; +import io.sentry.SentryOptions; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; @@ -127,26 +128,40 @@ public static final class Factory { public static FileInputStream create( final @NotNull FileInputStream delegate, final @Nullable String name) throws FileNotFoundException { - return new SentryFileInputStream( - init(name != null ? new File(name) : null, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(name != null ? new File(name) : null, delegate, hub)) + : delegate; } public static FileInputStream create( final @NotNull FileInputStream delegate, final @Nullable File file) throws FileNotFoundException { - return new SentryFileInputStream(init(file, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(file, delegate, hub)) + : delegate; } public static FileInputStream create( final @NotNull FileInputStream delegate, final @NotNull FileDescriptor descriptor) { - return new SentryFileInputStream( - init(descriptor, delegate, HubAdapter.getInstance()), descriptor); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(descriptor, delegate, hub), descriptor) + : delegate; } static FileInputStream create( final @NotNull FileInputStream delegate, final @Nullable File file, final @NotNull IHub hub) throws FileNotFoundException { - return new SentryFileInputStream(init(file, delegate, hub)); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(file, delegate, hub)) + : delegate; + } + + private static boolean isTracingEnabled(final @NotNull IHub hub) { + final @NotNull SentryOptions options = hub.getOptions(); + return options.isTracingEnabled(); } } } diff --git a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java index 9424710d71d..850c2216467 100644 --- a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java +++ b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java @@ -3,6 +3,7 @@ import io.sentry.HubAdapter; import io.sentry.IHub; import io.sentry.ISpan; +import io.sentry.SentryOptions; import java.io.File; import java.io.FileDescriptor; import java.io.FileNotFoundException; @@ -131,32 +132,62 @@ public static final class Factory { public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable String name) throws FileNotFoundException { - return new SentryFileOutputStream( - init(name != null ? new File(name) : null, false, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream( + init(name != null ? new File(name) : null, false, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable String name, final boolean append) throws FileNotFoundException { - return new SentryFileOutputStream( - init(name != null ? new File(name) : null, append, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream( + init(name != null ? new File(name) : null, append, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable File file) throws FileNotFoundException { - return new SentryFileOutputStream(init(file, false, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(file, false, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable File file, final boolean append) throws FileNotFoundException { - return new SentryFileOutputStream(init(file, append, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(file, append, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @NotNull FileDescriptor fdObj) { - return new SentryFileOutputStream(init(fdObj, delegate, HubAdapter.getInstance()), fdObj); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(fdObj, delegate, hub), fdObj) + : delegate; + } + + public static FileOutputStream create( + final @NotNull FileOutputStream delegate, + final @Nullable File file, + final @NotNull IHub hub) + throws FileNotFoundException { + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(file, false, delegate, hub)) + : delegate; + } + + private static boolean isTracingEnabled(final @NotNull IHub hub) { + final @NotNull SentryOptions options = hub.getOptions(); + return options.isTracingEnabled(); } } } diff --git a/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java index 79109f70ff6..3fbfa1ab980 100644 --- a/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java +++ b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java @@ -1,11 +1,10 @@ package io.sentry.internal.gestures; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public interface GestureTargetLocator { @Nullable UiElement locate( - final @NotNull Object root, final float x, final float y, final UiElement.Type targetType); + final @Nullable Object root, final float x, final float y, final UiElement.Type targetType); } diff --git a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt index 5e27eb451d3..5bc2290eb0a 100644 --- a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt +++ b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt @@ -60,8 +60,10 @@ class SentryFileInputStreamTest { internal fun getSut( tmpFile: File? = null, - delegate: FileInputStream - ): SentryFileInputStream { + delegate: FileInputStream, + tracesSampleRate: Double? = 1.0 + ): FileInputStream { + options.tracesSampleRate = tracesSampleRate whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) whenever(hub.span).thenReturn(sentryTracer) @@ -69,7 +71,7 @@ class SentryFileInputStreamTest { delegate, tmpFile, hub - ) as SentryFileInputStream + ) } } @@ -242,6 +244,15 @@ class SentryFileInputStreamTest { assertEquals(false, fileIOSpan.data[SpanDataConvention.BLOCKED_MAIN_THREAD_KEY]) assertNull(fileIOSpan.data[SpanDataConvention.CALL_STACK_KEY]) } + + @Test + fun `when tracing is disabled does not instrument the stream`() { + val file = tmpFile + val delegate = ThrowingFileInputStream(file) + val stream = fixture.getSut(file, delegate = delegate, tracesSampleRate = null) + + assertTrue { stream is ThrowingFileInputStream } + } } class ThrowingFileInputStream(file: File) : FileInputStream(file) { diff --git a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt index f6a09830c26..95533f0f8b7 100644 --- a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt +++ b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt @@ -14,6 +14,8 @@ import org.junit.rules.TemporaryFolder import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.io.File +import java.io.FileOutputStream +import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread import kotlin.test.Test @@ -25,6 +27,7 @@ import kotlin.test.assertTrue class SentryFileOutputStreamTest { class Fixture { val hub = mock() + val options = SentryOptions() lateinit var sentryTracer: SentryTracer internal fun getSut( @@ -32,18 +35,33 @@ class SentryFileOutputStreamTest { activeTransaction: Boolean = true, append: Boolean = false ): SentryFileOutputStream { - whenever(hub.options).thenReturn( - SentryOptions().apply { - mainThreadChecker = MainThreadChecker.getInstance() - addInAppInclude("org.junit") - } - ) + options.run { + mainThreadChecker = MainThreadChecker.getInstance() + addInAppInclude("org.junit") + } + whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) if (activeTransaction) { whenever(hub.span).thenReturn(sentryTracer) } return SentryFileOutputStream(tmpFile, append, hub) } + + internal fun getSut( + tmpFile: File? = null, + delegate: FileOutputStream, + tracesSampleRate: Double? = 1.0 + ): FileOutputStream { + options.tracesSampleRate = tracesSampleRate + whenever(hub.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + whenever(hub.span).thenReturn(sentryTracer) + return SentryFileOutputStream.Factory.create( + delegate, + tmpFile, + hub + ) + } } @get:Rule @@ -157,4 +175,25 @@ class SentryFileOutputStreamTest { assertEquals(false, fileIOSpan.data[SpanDataConvention.BLOCKED_MAIN_THREAD_KEY]) assertNull(fileIOSpan.data[SpanDataConvention.CALL_STACK_KEY]) } + + @Test + fun `when tracing is disabled does not instrument the stream`() { + val file = tmpFile + val delegate = ThrowingFileOutputStream(file) + val stream = fixture.getSut(file, delegate = delegate, tracesSampleRate = null) + + assertTrue { stream is ThrowingFileOutputStream } + } +} + +class ThrowingFileOutputStream(file: File) : FileOutputStream(file) { + val throwable = IOException("Oops!") + + override fun write(b: Int) { + throw throwable + } + + override fun close() { + throw throwable + } } From e5095039e5cfb9c68ff89ab6fa1bcf8932731b36 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jan 2025 22:38:05 +0100 Subject: [PATCH 02/24] Fix BroadcastReceivers (#4052) * Drop TempSesnorBreadcrumbIntegration * Drop PhoneStateBreadcrumbsIntegration * Reduce number of system events we're listening to and use RECEIVER_NOT_EXPORTED * Format code * Changelog * Update CHANGELOG.md Co-authored-by: Stefano * Update CHANGELOG.md Co-authored-by: Stefano --------- Co-authored-by: Sentry Github Bot Co-authored-by: Stefano --- CHANGELOG.md | 18 +++ .../api/sentry-android-core.api | 15 +- .../core/AndroidOptionsInitializer.java | 2 - .../io/sentry/android/core/ContextUtils.java | 3 +- .../PhoneStateBreadcrumbsIntegration.java | 136 ----------------- .../SystemEventsBreadcrumbsIntegration.java | 49 +----- .../TempSensorBreadcrumbsIntegration.java | 143 ------------------ .../sentry/android/core/ContextUtilsTest.kt | 2 +- .../PhoneStateBreadcrumbsIntegrationTest.kt | 128 ---------------- .../sentry/android/core/SentryAndroidTest.kt | 4 +- .../TempSensorBreadcrumbsIntegrationTest.kt | 133 ---------------- .../src/main/AndroidManifest.xml | 2 - 12 files changed, 27 insertions(+), 608 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 208e9bf4c4d..e64a52a5d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ - Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) - Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + ## 7.20.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 02725ba5df6..44c34038ddb 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,12 +245,6 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/PhoneStateBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -379,14 +373,7 @@ public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : i public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - -public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : android/hardware/SensorEventListener, io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V - public fun onSensorChanged (Landroid/hardware/SensorEvent;)V + public static fun getDefaultActions ()Ljava/util/List; public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)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 d5dfce77b28..32938a39843 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 @@ -301,8 +301,6 @@ static void installDefaultIntegrations( options.addIntegration(new SystemEventsBreadcrumbsIntegration(context)); options.addIntegration( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); - options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); - options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 89fe856631b..c0a018b5b35 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -2,7 +2,6 @@ import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; import static android.content.Context.ACTIVITY_SERVICE; -import static android.content.Context.RECEIVER_EXPORTED; import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED; import android.annotation.SuppressLint; @@ -349,7 +348,7 @@ public static boolean isForegroundImportance() { // If this receiver is listening for broadcasts sent from the system or from other apps, even // other apps that you own—use the RECEIVER_EXPORTED flag. If instead this receiver is // listening only for broadcasts sent by your app, use the RECEIVER_NOT_EXPORTED flag. - return context.registerReceiver(receiver, filter, RECEIVER_EXPORTED); + return context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); } else { return context.registerReceiver(receiver, filter); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java deleted file mode 100644 index 249904fd162..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.sentry.android.core; - -import static android.Manifest.permission.READ_PHONE_STATE; -import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; - -import android.content.Context; -import android.telephony.TelephonyManager; -import io.sentry.Breadcrumb; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.Permissions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class PhoneStateBreadcrumbsIntegration implements Integration, Closeable { - - private final @NotNull Context context; - private @Nullable SentryAndroidOptions options; - @TestOnly @Nullable PhoneStateChangeListener listener; - private @Nullable TelephonyManager telephonyManager; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); - - public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - } - - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs() - && Permissions.hasPermission(context, READ_PHONE_STATE)) { - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startTelephonyListener(hub, options); - } - } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start PhoneStateBreadcrumbsIntegration on executor thread.", - e); - } - } - } - - @SuppressWarnings("deprecation") - private void startTelephonyListener( - final @NotNull IHub hub, final @NotNull SentryOptions options) { - telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - if (telephonyManager != null) { - try { - listener = new PhoneStateChangeListener(hub); - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE); - - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("PhoneStateBreadcrumbs"); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.INFO, e, "TelephonyManager is not available or ready to use."); - } - } else { - options.getLogger().log(SentryLevel.INFO, "TelephonyManager is not available"); - } - } - - @SuppressWarnings("deprecation") - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (telephonyManager != null && listener != null) { - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_NONE); - listener = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration removed."); - } - } - } - - @SuppressWarnings("deprecation") - static final class PhoneStateChangeListener extends android.telephony.PhoneStateListener { - - private final @NotNull IHub hub; - - PhoneStateChangeListener(final @NotNull IHub hub) { - this.hub = hub; - } - - @SuppressWarnings("deprecation") - @Override - public void onCallStateChanged(int state, String incomingNumber) { - // incomingNumber is never used and it's always empty if you don't have permission: - // android.permission.READ_CALL_LOG - if (state == TelephonyManager.CALL_STATE_RINGING) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "CALL_STATE_RINGING"); - breadcrumb.setMessage("Device ringing"); - breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); - } - } - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index ea838975cde..cfa61454bc1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -1,31 +1,17 @@ package io.sentry.android.core; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DELETED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DISABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_ENABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; -import static android.content.Intent.ACTION_APP_ERROR; import static android.content.Intent.ACTION_BATTERY_CHANGED; -import static android.content.Intent.ACTION_BATTERY_LOW; -import static android.content.Intent.ACTION_BATTERY_OKAY; -import static android.content.Intent.ACTION_BOOT_COMPLETED; -import static android.content.Intent.ACTION_BUG_REPORT; import static android.content.Intent.ACTION_CAMERA_BUTTON; import static android.content.Intent.ACTION_CONFIGURATION_CHANGED; import static android.content.Intent.ACTION_DATE_CHANGED; import static android.content.Intent.ACTION_DEVICE_STORAGE_LOW; import static android.content.Intent.ACTION_DEVICE_STORAGE_OK; import static android.content.Intent.ACTION_DOCK_EVENT; +import static android.content.Intent.ACTION_DREAMING_STARTED; +import static android.content.Intent.ACTION_DREAMING_STOPPED; import static android.content.Intent.ACTION_INPUT_METHOD_CHANGED; import static android.content.Intent.ACTION_LOCALE_CHANGED; -import static android.content.Intent.ACTION_MEDIA_BAD_REMOVAL; -import static android.content.Intent.ACTION_MEDIA_MOUNTED; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTABLE; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTED; -import static android.content.Intent.ACTION_POWER_CONNECTED; -import static android.content.Intent.ACTION_POWER_DISCONNECTED; -import static android.content.Intent.ACTION_REBOOT; import static android.content.Intent.ACTION_SCREEN_OFF; import static android.content.Intent.ACTION_SCREEN_ON; import static android.content.Intent.ACTION_SHUTDOWN; @@ -142,52 +128,27 @@ private void startSystemEventsReceiver( } @SuppressWarnings("deprecation") - private static @NotNull List getDefaultActions() { + public static @NotNull List getDefaultActions() { final List actions = new ArrayList<>(); - actions.add(ACTION_APPWIDGET_DELETED); - actions.add(ACTION_APPWIDGET_DISABLED); - actions.add(ACTION_APPWIDGET_ENABLED); - actions.add("android.appwidget.action.APPWIDGET_HOST_RESTORED"); - actions.add("android.appwidget.action.APPWIDGET_RESTORED"); - actions.add(ACTION_APPWIDGET_UPDATE); - actions.add("android.appwidget.action.APPWIDGET_UPDATE_OPTIONS"); - actions.add(ACTION_POWER_CONNECTED); - actions.add(ACTION_POWER_DISCONNECTED); actions.add(ACTION_SHUTDOWN); actions.add(ACTION_AIRPLANE_MODE_CHANGED); - actions.add(ACTION_BATTERY_LOW); - actions.add(ACTION_BATTERY_OKAY); actions.add(ACTION_BATTERY_CHANGED); - actions.add(ACTION_BOOT_COMPLETED); actions.add(ACTION_CAMERA_BUTTON); actions.add(ACTION_CONFIGURATION_CHANGED); - actions.add("android.intent.action.CONTENT_CHANGED"); actions.add(ACTION_DATE_CHANGED); actions.add(ACTION_DEVICE_STORAGE_LOW); actions.add(ACTION_DEVICE_STORAGE_OK); actions.add(ACTION_DOCK_EVENT); - actions.add("android.intent.action.DREAMING_STARTED"); - actions.add("android.intent.action.DREAMING_STOPPED"); + actions.add(ACTION_DREAMING_STARTED); + actions.add(ACTION_DREAMING_STOPPED); actions.add(ACTION_INPUT_METHOD_CHANGED); actions.add(ACTION_LOCALE_CHANGED); - actions.add(ACTION_REBOOT); actions.add(ACTION_SCREEN_OFF); actions.add(ACTION_SCREEN_ON); actions.add(ACTION_TIMEZONE_CHANGED); actions.add(ACTION_TIME_CHANGED); actions.add("android.os.action.DEVICE_IDLE_MODE_CHANGED"); actions.add("android.os.action.POWER_SAVE_MODE_CHANGED"); - // The user pressed the "Report" button in the crash/ANR dialog. - actions.add(ACTION_APP_ERROR); - // Show activity for reporting a bug. - actions.add(ACTION_BUG_REPORT); - - // consider if somebody mounted or ejected a sdcard - actions.add(ACTION_MEDIA_BAD_REMOVAL); - actions.add(ACTION_MEDIA_MOUNTED); - actions.add(ACTION_MEDIA_UNMOUNTABLE); - actions.add(ACTION_MEDIA_UNMOUNTED); - return actions; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index b94a06b9768..8b137891791 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -1,144 +1 @@ -package io.sentry.android.core; -import static android.content.Context.SENSOR_SERVICE; -import static io.sentry.TypeCheckHint.ANDROID_SENSOR_EVENT; -import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; - -import android.content.Context; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import io.sentry.Breadcrumb; -import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class TempSensorBreadcrumbsIntegration - implements Integration, Closeable, SensorEventListener { - - private final @NotNull Context context; - private @Nullable IHub hub; - private @Nullable SentryAndroidOptions options; - - @TestOnly @Nullable SensorManager sensorManager; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); - - public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - } - - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventsBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs()) { - - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startSensorListener(options); - } - } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start TempSensorBreadcrumbsIntegration on executor thread.", - e); - } - } - } - - private void startSensorListener(final @NotNull SentryOptions options) { - try { - sensorManager = (SensorManager) context.getSystemService(SENSOR_SERVICE); - if (sensorManager != null) { - final Sensor defaultSensor = - sensorManager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE); - if (defaultSensor != null) { - sensorManager.registerListener(this, defaultSensor, SensorManager.SENSOR_DELAY_NORMAL); - - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("TempSensorBreadcrumbs"); - } else { - options.getLogger().log(SentryLevel.INFO, "TYPE_AMBIENT_TEMPERATURE is not available."); - } - } else { - options.getLogger().log(SentryLevel.INFO, "SENSOR_SERVICE is not available."); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, e, "Failed to init. the SENSOR_SERVICE."); - } - } - - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (sensorManager != null) { - sensorManager.unregisterListener(this); - sensorManager = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration removed."); - } - } - } - - @Override - public void onSensorChanged(final @NotNull SensorEvent event) { - final float[] values = event.values; - // return if data is not available or zero'ed - if (values == null || values.length == 0 || values[0] == 0f) { - return; - } - - if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "TYPE_AMBIENT_TEMPERATURE"); - breadcrumb.setData("accuracy", event.accuracy); - breadcrumb.setData("timestamp", event.timestamp); - breadcrumb.setLevel(SentryLevel.INFO); - breadcrumb.setData("degree", event.values[0]); // Celsius - - final Hint hint = new Hint(); - hint.set(ANDROID_SENSOR_EVENT, event); - - hub.addBreadcrumb(breadcrumb, hint); - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) {} -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index 588a32a6569..756be16a3db 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -193,7 +193,7 @@ class ContextUtilsTest { val context = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.TIRAMISU) ContextUtils.registerReceiver(context, buildInfo, receiver, filter) - verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_EXPORTED)) + verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_NOT_EXPORTED)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt deleted file mode 100644 index 2b6ca801dae..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.sentry.android.core - -import android.content.Context -import android.telephony.PhoneStateListener -import android.telephony.TelephonyManager -import io.sentry.Breadcrumb -import io.sentry.IHub -import io.sentry.ISentryExecutorService -import io.sentry.SentryLevel -import io.sentry.test.DeferredExecutorService -import io.sentry.test.ImmediateExecutorService -import org.mockito.kotlin.any -import org.mockito.kotlin.check -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class PhoneStateBreadcrumbsIntegrationTest { - - private class Fixture { - val context = mock() - val manager = mock() - val options = SentryAndroidOptions() - - fun getSut(executorService: ISentryExecutorService = ImmediateExecutorService()): PhoneStateBreadcrumbsIntegration { - options.executorService = executorService - whenever(context.getSystemService(eq(Context.TELEPHONY_SERVICE))).thenReturn(manager) - - return PhoneStateBreadcrumbsIntegration(context) - } - } - - private val fixture = Fixture() - - @Test - fun `When system events breadcrumb is enabled, it registers callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_CALL_STATE)) - assertNotNull(sut.listener) - } - - @Test - fun `Phone state callback is registered in the executorService`() { - val sut = fixture.getSut(mock()) - val hub = mock() - sut.register(hub, fixture.options) - - assertNull(sut.listener) - } - - @Test - fun `When system events breadcrumb is disabled, it doesn't register callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register( - hub, - fixture.options.apply { - isEnableSystemEventBreadcrumbs = false - } - ) - verify(fixture.manager, never()).listen(any(), any()) - assertNull(sut.listener) - } - - @Test - fun `When ActivityBreadcrumbsIntegration is closed, it should unregister the callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.close() - verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_NONE)) - assertNull(sut.listener) - } - - @Test - fun `when hub is closed right after start, integration is not registered`() { - val deferredExecutorService = DeferredExecutorService() - val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(mock(), fixture.options) - assertNull(sut.listener) - sut.close() - deferredExecutorService.runAll() - assertNull(sut.listener) - } - - @Test - fun `When on call state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_RINGING, null) - - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When on idle state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_IDLE, null) - verify(hub, never()).addBreadcrumb(any()) - } - - @Test - fun `When on offhook state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK, null) - verify(hub, never()).addBreadcrumb(any()) - } -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index ec2b3db4ce3..a4608582f78 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -461,7 +461,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(21, options.integrations.size) + assertEquals(19, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -479,8 +479,6 @@ class SentryAndroidTest { it is AppComponentsBreadcrumbsIntegration || it is SystemEventsBreadcrumbsIntegration || it is NetworkBreadcrumbsIntegration || - it is TempSensorBreadcrumbsIntegration || - it is PhoneStateBreadcrumbsIntegration || it is SpotlightIntegration || it is ReplayIntegration } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt deleted file mode 100644 index d443b1e3458..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package io.sentry.android.core - -import android.content.Context -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import io.sentry.Breadcrumb -import io.sentry.Hint -import io.sentry.IHub -import io.sentry.ISentryExecutorService -import io.sentry.SentryLevel -import io.sentry.TypeCheckHint -import io.sentry.test.DeferredExecutorService -import io.sentry.test.ImmediateExecutorService -import io.sentry.test.getDeclaredCtor -import io.sentry.test.injectForField -import org.mockito.kotlin.any -import org.mockito.kotlin.check -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class TempSensorBreadcrumbsIntegrationTest { - private class Fixture { - val context = mock() - val manager = mock() - val sensor = mock() - val options = SentryAndroidOptions() - - fun getSut(executorService: ISentryExecutorService = ImmediateExecutorService()): TempSensorBreadcrumbsIntegration { - options.executorService = executorService - whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(manager) - whenever(manager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE)).thenReturn(sensor) - return TempSensorBreadcrumbsIntegration(context) - } - } - - private val fixture = Fixture() - - @Test - fun `When system events breadcrumb is enabled, it registers callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - verify(fixture.manager).registerListener(any(), any(), eq(SensorManager.SENSOR_DELAY_NORMAL)) - assertNotNull(sut.sensorManager) - } - - @Test - fun `temp sensor listener is registered in the executorService`() { - val sut = fixture.getSut(executorService = mock()) - val hub = mock() - sut.register(hub, fixture.options) - - assertNull(sut.sensorManager) - } - - @Test - fun `When system events breadcrumb is disabled, it should not register a callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register( - hub, - fixture.options.apply { - isEnableSystemEventBreadcrumbs = false - } - ) - verify(fixture.manager, never()).registerListener(any(), any(), any()) - assertNull(sut.sensorManager) - } - - @Test - fun `When TempSensorBreadcrumbsIntegration is closed, it should unregister the callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.close() - verify(fixture.manager).unregisterListener(any()) - assertNull(sut.sensorManager) - } - - @Test - fun `when hub is closed right after start, integration is not registered`() { - val deferredExecutorService = DeferredExecutorService() - val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(mock(), fixture.options) - assertNull(sut.sensorManager) - sut.close() - deferredExecutorService.runAll() - assertNull(sut.sensorManager) - } - - @Test - fun `When onSensorChanged received, add a breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - val sensorCtor = "android.hardware.SensorEvent".getDeclaredCtor(emptyArray()) - val sensorEvent: SensorEvent = sensorCtor.newInstance() as SensorEvent - sensorEvent.injectForField("values", FloatArray(2) { 1F }) - sut.onSensorChanged(sensorEvent) - - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.INFO, it.level) - }, - check { - assertEquals(sensorEvent, it.get(TypeCheckHint.ANDROID_SENSOR_EVENT)) - } - ) - } - - @Test - fun `When onSensorChanged received and null values, do not add a breadcrumb`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - val event = mock() - assertNull(event.values) - sut.onSensorChanged(event) - - verify(hub, never()).addBreadcrumb(any()) - } -} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index d8ae6c709d9..5860c3863eb 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -6,8 +6,6 @@ - - From 0b511c952156b6180449bb9582652dedb91b0a22 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jan 2025 14:07:10 +0100 Subject: [PATCH 03/24] Only provide {{auto}} ip-address if sendDefaultPii is enabled --- .../android/core/AnrV2EventProcessor.java | 2 +- .../core/DefaultAndroidEventProcessor.java | 2 +- .../android/core/AnrV2EventProcessorTest.kt | 18 +++++++++++++++--- .../core/DefaultAndroidEventProcessorTest.kt | 19 ++++++++++++++++--- .../java/io/sentry/MainEventProcessor.java | 2 +- .../java/io/sentry/MainEventProcessorTest.kt | 10 ++++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index e914029c30c..b35b53f150f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -575,7 +575,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index a2833d2b346..f8c91ec994c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -156,7 +156,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 80ae9467114..21e18a594bb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -85,7 +85,6 @@ class AnrV2EventProcessorTest { lateinit var context: Context val options = SentryAndroidOptions().apply { setLogger(NoOpLogger.getInstance()) - isSendDefaultPii = true } fun getSut( @@ -93,10 +92,13 @@ class AnrV2EventProcessorTest { currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, - replayErrorSampleRate: Double? = null + replayErrorSampleRate: Double? = null, + isSendDefaultPii: Boolean = true ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" + options.isSendDefaultPii = isSendDefaultPii + whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -278,6 +280,7 @@ class AnrV2EventProcessorTest { // user assertEquals("bot", processed.user!!.username) assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) // trace assertEquals("ui.load", processed.contexts.trace!!.operation) // tags @@ -304,6 +307,13 @@ class AnrV2EventProcessorTest { assertEquals("Google Chrome", processed.contexts.browser!!.name) } + @Test + fun `when backfillable event is enrichable, does not backfill user ip`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + assertNull(processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -617,6 +627,7 @@ class AnrV2EventProcessorTest { hint: Hint, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, + isSendDefaultPii: Boolean = true, configureEvent: SentryEvent.() -> Unit = {} ): SentryEvent { val original = SentryEvent().apply(configureEvent) @@ -624,7 +635,8 @@ class AnrV2EventProcessorTest { val processor = fixture.getSut( tmpDir, populateScopeCache = populateScopeCache, - populateOptionsCache = populateOptionsCache + populateOptionsCache = populateOptionsCache, + isSendDefaultPii = isSendDefaultPii ) return processor.process(original, hint)!! } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 80954f67a5f..0dfcf663bff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -66,7 +66,8 @@ class DefaultAndroidEventProcessorTest { lateinit var sentryTracer: SentryTracer - fun getSut(context: Context): DefaultAndroidEventProcessor { + fun getSut(context: Context, isSendDefaultPii: Boolean = false): DefaultAndroidEventProcessor { + options.isSendDefaultPii = isSendDefaultPii whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("", ""), hub) return DefaultAndroidEventProcessor(context, buildInfo, options) @@ -284,8 +285,20 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `when event user data does not have ip address set, sets {{auto}} as the ip address`() { - val sut = fixture.getSut(context) + fun `when event user data does not have ip address set, sets no ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { + user = User() + } + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + + @Test + fun `when event user data does not have ip address set, sets {{auto}} if sendDefaultPii is true`() { + val sut = fixture.getSut(context, isSendDefaultPii = true) val event = SentryEvent().apply { user = User() } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 45be9212bac..d14264da570 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -245,7 +245,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { user = new User(); event.setUser(user); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 8881b6d386a..7933906821c 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -306,6 +306,16 @@ class MainEventProcessorTest { } } + @Test + fun `when event does not have ip address set, do not enrich ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(sendDefaultPii = true) From ef02e3a23927d68319600dd98930bbe65d0c2ee8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jan 2025 14:14:48 +0100 Subject: [PATCH 04/24] Update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3857217a987..b1e4b2a93c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Behavioural Changes + +- The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) + - This change gives you control over IP address collection directly on the client + ## 7.20.0 ### Features From 63819218108dbc29fa7a4f4f2deed9ab5923f213 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 20 Jan 2025 14:48:36 +0100 Subject: [PATCH 05/24] Reduce the number of IPC calls (#4058) * Remove binder call for external storage * Remove binder call for memory in profiler * Cache static values to avoid binder calls * Comment * Changelog * Formatting * Fix tests * Minor fixes * change protected method in final class to private --------- Co-authored-by: Markus Hintersteiner Co-authored-by: stefanosiano --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 12 ++ .../core/AndroidOptionsInitializer.java | 4 +- .../core/AndroidTransactionProfiler.java | 32 +--- .../android/core/AnrV2EventProcessor.java | 26 +-- .../io/sentry/android/core/ContextUtils.java | 174 +++++++++++++----- .../core/DefaultAndroidEventProcessor.java | 2 +- .../sentry/android/core/DeviceInfoUtil.java | 20 +- .../android/core/InternalSentrySdk.java | 2 +- .../android/core/ManifestMetadataReader.java | 11 +- .../core/util/AndroidLazyEvaluator.java | 68 +++++++ .../core/ActivityLifecycleIntegrationTest.kt | 1 + .../core/AndroidOptionsInitializerTest.kt | 1 + .../android/core/AnrV2EventProcessorTest.kt | 1 + .../sentry/android/core/ContextUtilsTest.kt | 13 +- .../core/ManifestMetadataReaderTest.kt | 6 + .../android/core/SentryInitProviderTest.kt | 1 + .../android/core/SentryLogcatAdapterTest.kt | 1 + 18 files changed, 254 insertions(+), 122 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e64a52a5d10..c354ae38a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) - Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) - Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) ### Behavioural Changes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 44c34038ddb..99ca27dc733 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -193,6 +193,7 @@ public final class io/sentry/android/core/DeviceInfoUtil { public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo; + public fun getTotalMemory ()Ljava/lang/Long; public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; public static fun resetInstance ()V } @@ -501,3 +502,14 @@ public class io/sentry/android/core/performance/WindowContentChangedCallback : i public fun onContentChanged ()V } +public final class io/sentry/android/core/util/AndroidLazyEvaluator { + public fun (Lio/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator;)V + public fun getValue (Landroid/content/Context;)Ljava/lang/Object; + public fun resetValue ()V + public fun setValue (Ljava/lang/Object;)V +} + +public abstract interface class io/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator { + public abstract fun evaluate (Landroid/content/Context;)Ljava/lang/Object; +} + 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 32938a39843..80d81671ee5 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 @@ -320,8 +320,8 @@ private static void readDefaultOptionValues( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final @Nullable PackageInfo packageInfo = + ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { // Sets App's release if not set by Manifest if (options.getRelease() == null) { 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 41e57a886a4..f4aa3cf0987 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 @@ -1,10 +1,8 @@ package io.sentry.android.core; -import static android.content.Context.ACTIVITY_SERVICE; import static java.util.concurrent.TimeUnit.SECONDS; import android.annotation.SuppressLint; -import android.app.ActivityManager; import android.content.Context; import android.os.Build; import android.os.Process; @@ -265,9 +263,12 @@ public synchronized void bindTransaction(final @NotNull ITransaction transaction transactionsCounter = 0; String totalMem = "0"; - ActivityManager.MemoryInfo memInfo = getMemInfo(); - if (memInfo != null) { - totalMem = Long.toString(memInfo.totalMem); + final @Nullable Long memory = + (options instanceof SentryAndroidOptions) + ? DeviceInfoUtil.getInstance(context, (SentryAndroidOptions) options).getTotalMemory() + : null; + if (memory != null) { + totalMem = Long.toString(memory); } String[] abis = Build.SUPPORTED_ABIS; @@ -333,27 +334,6 @@ public void close() { } } - /** - * Get MemoryInfo object representing the memory state of the application. - * - * @return MemoryInfo object representing the memory state of the application - */ - private @Nullable ActivityManager.MemoryInfo getMemInfo() { - try { - ActivityManager actManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); - ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); - if (actManager != null) { - actManager.getMemoryInfo(memInfo); - return memInfo; - } - logger.log(SentryLevel.INFO, "Error getting MemoryInfo."); - return null; - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting MemoryInfo.", e); - return null; - } - } - @TestOnly int getTransactionsCounter() { return transactionsCounter; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index e914029c30c..36f855fea52 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -374,14 +374,13 @@ private void setApp(final @NotNull SentryBaseEvent event, final @NotNull Object if (app == null) { app = new App(); } - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); // TODO: not entirely correct, because we define background ANRs as not the ones of // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR happened // but it's our best effort for now. We could serialize AppState in theory. app.setInForeground(!isBackgroundAnr(hint)); - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { app.setAppIdentifier(packageInfo.packageName); } @@ -592,8 +591,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { try { final ContextUtils.SideLoadedInfo sideLoadedInfo = - ContextUtils.retrieveSideLoadedInfo(context, options.getLogger(), buildInfoProvider); - + DeviceInfoUtil.getInstance(context, options).getSideLoadedInfo(); if (sideLoadedInfo != null) { final @NotNull Map tags = sideLoadedInfo.asTags(); for (Map.Entry entry : tags.entrySet()) { @@ -662,7 +660,8 @@ private void setDevice(final @NotNull SentryBaseEvent event) { private void mergeOS(final @NotNull SentryBaseEvent event) { final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - final OperatingSystem androidOS = getOperatingSystem(); + final OperatingSystem androidOS = + DeviceInfoUtil.getInstance(context, options).getOperatingSystem(); // make Android OS the main OS using the 'os' key event.getContexts().setOperatingSystem(androidOS); @@ -678,20 +677,5 @@ private void mergeOS(final @NotNull SentryBaseEvent event) { event.getContexts().put(osNameKey, currentOS); } } - - private @NotNull OperatingSystem getOperatingSystem() { - OperatingSystem os = new OperatingSystem(); - os.setName("Android"); - os.setVersion(Build.VERSION.RELEASE); - os.setBuild(Build.DISPLAY); - - try { - os.setKernelVersion(ContextUtils.getKernelVersion(options.getLogger())); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting OperatingSystem.", e); - } - - return os; - } // endregion } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index c0a018b5b35..086c2a101c7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -19,7 +19,9 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.util.AndroidLazyEvaluator; import io.sentry.protocol.App; +import io.sentry.util.LazyEvaluator; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -29,6 +31,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class ContextUtils { @@ -62,6 +65,106 @@ public boolean isSideLoaded() { private ContextUtils() {} + // to avoid doing a bunch of Binder calls we use LazyEvaluator to cache the values that are static + // during the app process running + + private static final @NotNull AndroidLazyEvaluator deviceName = + new AndroidLazyEvaluator<>( + (context) -> Settings.Global.getString(context.getContentResolver(), "device_name")); + + private static final @NotNull LazyEvaluator isForegroundImportance = + new LazyEvaluator<>( + () -> { + try { + final ActivityManager.RunningAppProcessInfo appProcessInfo = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(appProcessInfo); + return appProcessInfo.importance == IMPORTANCE_FOREGROUND; + } catch (Throwable ignored) { + // should never happen + } + return false; + }); + + /** + * Since this packageInfo uses flags 0 we can assume it's static and cache it as the package name + * or version code cannot change during runtime, only after app update (which will spin up a new + * process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticPackageInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.PackageInfoFlags.of(0)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticPackageInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator applicationName = + new AndroidLazyEvaluator<>( + context -> { + try { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + final int stringId = applicationInfo.labelRes; + if (stringId == 0) { + if (applicationInfo.nonLocalizedLabel != null) { + return applicationInfo.nonLocalizedLabel.toString(); + } + return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); + } else { + return context.getString(stringId); + } + } catch (Throwable e) { + return null; + } + }); + + /** + * Since this applicationInfo uses the same flag (METADATA) we can assume it's static and cache it + * as the manifest metadata cannot change during runtime, only after app update (which will spin + * up a new process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticAppInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo( + context.getPackageName(), + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticAppInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + } catch (Throwable e) { + return null; + } + }); + /** * Return the Application's PackageInfo if possible, or null. * @@ -69,10 +172,12 @@ private ContextUtils() {} */ @Nullable static PackageInfo getPackageInfo( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider) { - return getPackageInfo(context, 0, logger, buildInfoProvider); + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { + return staticPackageInfo33.getValue(context); + } else { + return staticPackageInfo.getValue(context); + } } /** @@ -109,22 +214,14 @@ static PackageInfo getPackageInfo( * @return the Application's ApplicationInfo if possible, or throws */ @SuppressLint("NewApi") - @NotNull + @Nullable @SuppressWarnings("deprecation") static ApplicationInfo getApplicationInfo( - final @NotNull Context context, - final long flag, - final @NotNull BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { - return context - .getPackageManager() - .getApplicationInfo( - context.getPackageName(), PackageManager.ApplicationInfoFlags.of(flag)); + return staticAppInfo33.getValue(context); } else { - return context - .getPackageManager() - .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + return staticAppInfo.getValue(context); } } @@ -168,15 +265,7 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { */ @ApiStatus.Internal public static boolean isForegroundImportance() { - try { - final ActivityManager.RunningAppProcessInfo appProcessInfo = - new ActivityManager.RunningAppProcessInfo(); - ActivityManager.getMyMemoryState(appProcessInfo); - return appProcessInfo.importance == IMPORTANCE_FOREGROUND; - } catch (Throwable ignored) { - // should never happen - } - return false; + return isForegroundImportance.getValue(); } /** @@ -212,7 +301,7 @@ public static boolean isForegroundImportance() { final @NotNull BuildInfoProvider buildInfoProvider) { String packageName = null; try { - final PackageInfo packageInfo = getPackageInfo(context, logger, buildInfoProvider); + final PackageInfo packageInfo = getPackageInfo(context, buildInfoProvider); final PackageManager packageManager = context.getPackageManager(); if (packageInfo != null && packageManager != null) { @@ -238,24 +327,8 @@ public static boolean isForegroundImportance() { * * @return Application name */ - static @Nullable String getApplicationName( - final @NotNull Context context, final @NotNull ILogger logger) { - try { - final ApplicationInfo applicationInfo = context.getApplicationInfo(); - final int stringId = applicationInfo.labelRes; - if (stringId == 0) { - if (applicationInfo.nonLocalizedLabel != null) { - return applicationInfo.nonLocalizedLabel.toString(); - } - return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); - } else { - return context.getString(stringId); - } - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting application name.", e); - } - - return null; + static @Nullable String getApplicationName(final @NotNull Context context) { + return applicationName.getValue(context); } /** @@ -289,7 +362,7 @@ public static boolean isForegroundImportance() { } static @Nullable String getDeviceName(final @NotNull Context context) { - return Settings.Global.getString(context.getContentResolver(), "device_name"); + return deviceName.getValue(context); } @SuppressWarnings("deprecation") @@ -397,4 +470,15 @@ public static Context getApplicationContext(final @NotNull Context context) { } return context; } + + @TestOnly + static void resetInstance() { + deviceName.resetValue(); + isForegroundImportance.resetValue(); + staticPackageInfo33.resetValue(); + staticPackageInfo.resetValue(); + applicationName.resetValue(); + staticAppInfo33.resetValue(); + staticAppInfo.resetValue(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index a2833d2b346..8cc00ed2188 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -250,7 +250,7 @@ private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String } private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); final @NotNull TimeSpan appStartTimeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); if (appStartTimeSpan.hasStarted()) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index e2dfee2705a..aba2d1ed511 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -9,7 +9,6 @@ import android.content.IntentFilter; import android.os.BatteryManager; import android.os.Build; -import android.os.Environment; import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; @@ -156,8 +155,13 @@ public OperatingSystem getOperatingSystem() { return os; } + @Nullable + public Long getTotalMemory() { + return totalMem; + } + @NotNull - protected OperatingSystem retrieveOperatingSystemInformation() { + private OperatingSystem retrieveOperatingSystemInformation() { final OperatingSystem os = new OperatingSystem(); os.setName("Android"); @@ -384,15 +388,14 @@ private Long getUnusedInternalStorage(final @NotNull StatFs stat) { @Nullable private StatFs getExternalStorageStat(final @Nullable File internalStorage) { - if (!isExternalStorageMounted()) { + try { File path = getExternalStorageDep(internalStorage); if (path != null) { // && path.canRead()) { canRead() will read return false return new StatFs(path.getPath()); } + } catch (Throwable e) { options.getLogger().log(SentryLevel.INFO, "Not possible to read external files directory"); - return null; } - options.getLogger().log(SentryLevel.INFO, "External storage is not mounted or emulated."); return null; } @@ -444,13 +447,6 @@ private Long getTotalExternalStorage(final @NotNull StatFs stat) { } } - private boolean isExternalStorageMounted() { - final String storageState = Environment.getExternalStorageState(); - return (Environment.MEDIA_MOUNTED.equals(storageState) - || Environment.MEDIA_MOUNTED_READ_ONLY.equals(storageState)) - && !Environment.isExternalStorageEmulated(); - } - /** * Get the unused amount of external storage, in bytes, or null if no external storage is mounted. * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index a3a15d7326c..2125be1671a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -108,7 +108,7 @@ public static Map serializeScope( if (app == null) { app = new App(); } - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); final @NotNull TimeSpan appStartTimeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 2d2df5700af..6e8a64530fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -2,7 +2,6 @@ import android.content.Context; import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; import android.os.Bundle; import io.sentry.ILogger; import io.sentry.SentryIntegrationPackageStorage; @@ -538,18 +537,14 @@ static boolean isAutoInit(final @NotNull Context context, final @NotNull ILogger * * @param context the application context * @return the Bundle attached to the PackageManager - * @throws PackageManager.NameNotFoundException if the package name is non-existent */ private static @Nullable Bundle getMetadata( final @NotNull Context context, final @NotNull ILogger logger, - final @Nullable BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @Nullable BuildInfoProvider buildInfoProvider) { final ApplicationInfo app = ContextUtils.getApplicationInfo( - context, - PackageManager.GET_META_DATA, - buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); - return app.metaData; + context, buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); + return app != null ? app.metaData : null; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java new file mode 100644 index 00000000000..beb9ff8e8ed --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java @@ -0,0 +1,68 @@ +package io.sentry.android.core.util; + +import android.content.Context; +import io.sentry.util.LazyEvaluator; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. Same as {@link LazyEvaluator} but accepts Context as an + * argument for {@link AndroidLazyEvaluator#getValue}. + */ +@ApiStatus.Internal +public final class AndroidLazyEvaluator { + + private volatile @Nullable T value = null; + private final @NotNull AndroidEvaluator evaluator; + + /** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. + * + * @param evaluator The function to evaluate. + */ + public AndroidLazyEvaluator(final @NotNull AndroidEvaluator evaluator) { + this.evaluator = evaluator; + } + + /** + * Executes the evaluator function and caches its result, so that it's called only once, unless + * resetValue is called. + * + * @return The result of the evaluator function. + */ + public @Nullable T getValue(final @NotNull Context context) { + if (value == null) { + synchronized (this) { + if (value == null) { + value = evaluator.evaluate(context); + } + } + } + + return value; + } + + public void setValue(final @Nullable T value) { + synchronized (this) { + this.value = value; + } + } + + /** + * Resets the internal value and forces the evaluator function to be called the next time + * getValue() is called. + */ + public void resetValue() { + synchronized (this) { + this.value = null; + } + } + + public interface AndroidEvaluator { + @Nullable + T evaluate(@NotNull Context context); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index be000b7517c..b212ed2feab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -132,6 +132,7 @@ class ActivityLifecycleIntegrationTest { @BeforeTest fun `reset instance`() { AppStartMetrics.getInstance().clear() + ContextUtils.resetInstance() context = ApplicationProvider.getApplicationContext() val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index ed2fa3338a5..f234f7a6402 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -164,6 +164,7 @@ class AndroidOptionsInitializerTest { @BeforeTest fun `set up`() { + ContextUtils.resetInstance() val appContext = ApplicationProvider.getApplicationContext() fixture = Fixture(appContext, appContext.cacheDir) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 80ae9467114..f04830182d5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -170,6 +170,7 @@ class AnrV2EventProcessorTest { @BeforeTest fun `set up`() { + DeviceInfoUtil.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index 756be16a3db..dc224a5bee6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -42,6 +42,7 @@ class ContextUtilsTest { @BeforeTest fun `set up`() { + ContextUtils.resetInstance() context = ApplicationProvider.getApplicationContext() logger = NoOpLogger.getInstance() ShadowBuild.reset() @@ -51,20 +52,20 @@ class ContextUtilsTest { @Test fun `Given a valid context, returns a valid PackageInfo`() { - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(context, mock()) assertNotNull(packageInfo) } @Test fun `Given an invalid context, do not throw Error`() { // as Context is not fully mocked, it'll throw NPE but catch it and return null - val packageInfo = ContextUtils.getPackageInfo(mock(), mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(mock(), mock()) assertNull(packageInfo) } @Test fun `Given a valid PackageInfo, returns a valid versionCode`() { - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(context, mock()) val versionCode = ContextUtils.getVersionCode(packageInfo!!, mock()) assertNotNull(versionCode) @@ -73,7 +74,7 @@ class ContextUtilsTest { @Test fun `Given a valid PackageInfo, returns a valid versionName`() { // VersionName is null during tests, so we mock it the second time - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock())!! + val packageInfo = ContextUtils.getPackageInfo(context, mock())!! val versionName = ContextUtils.getVersionName(packageInfo) assertNull(versionName) val mockedPackageInfo = spy(packageInfo) { it.versionName = "" } @@ -83,13 +84,13 @@ class ContextUtilsTest { @Test fun `when context is valid, getApplicationName returns application name`() { - val appName = ContextUtils.getApplicationName(context, logger) + val appName = ContextUtils.getApplicationName(context) assertEquals("io.sentry.android.core.test", appName) } @Test fun `when context is invalid, getApplicationName returns null`() { - val appName = ContextUtils.getApplicationName(mock(), logger) + val appName = ContextUtils.getApplicationName(mock()) assertNull(appName) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d60f47bd2cc..e1e5b3b9aa6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -37,6 +38,11 @@ class ManifestMetadataReaderTest { private val fixture = Fixture() + @BeforeTest + fun `set up`() { + ContextUtils.resetInstance() + } + @Test fun `isAutoInit won't throw exception and is enabled by default`() { fixture.options.setDebug(true) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index 5b546523d01..927e8792376 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -20,6 +20,7 @@ class SentryInitProviderTest { @BeforeTest fun `set up`() { Sentry.close() + ContextUtils.resetInstance() } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1939e7ed801..f6d6d229852 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -42,6 +42,7 @@ class SentryLogcatAdapterTest { fun `set up`() { Sentry.close() AppStartMetrics.getInstance().clear() + ContextUtils.resetInstance() breadcrumbs.clear() fixture.initSut { From 0126da6565926904aa2a5589bba740de0ad6bb9b Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 20 Jan 2025 14:39:13 +0000 Subject: [PATCH 06/24] release: 7.20.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e4b2a93c5..7e427f6efe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.20.1 ### Behavioural Changes diff --git a/gradle.properties b/gradle.properties index 65fe48ea942..2f9c5d420bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.20.0 +versionName=7.20.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 3cdb90527de31c2d72b377ed0e34fefe80b80bc3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jan 2025 16:47:09 +0100 Subject: [PATCH 07/24] Only send {{auto}} ip-adress if sendDefaultPii is enabled (7.x.x) (#4071) * Only provide {{auto}} ip-address if sendDefaultPii is enabled * Update changelog --- CHANGELOG.md | 3 +++ .../android/core/AnrV2EventProcessor.java | 2 +- .../core/DefaultAndroidEventProcessor.java | 2 +- .../android/core/AnrV2EventProcessorTest.kt | 18 +++++++++++++++--- .../core/DefaultAndroidEventProcessorTest.kt | 19 ++++++++++++++++--- .../java/io/sentry/MainEventProcessor.java | 2 +- .../java/io/sentry/MainEventProcessorTest.kt | 10 ++++++++++ 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c354ae38a95..9b93818aa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ SentryAndroid.init(context) { options -> If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). +- The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) + - This change gives you control over IP address collection directly on the client + ## 7.20.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 36f855fea52..990facd6244 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -574,7 +574,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 8cc00ed2188..8edb9737e25 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -156,7 +156,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index f04830182d5..6065e81e086 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -85,7 +85,6 @@ class AnrV2EventProcessorTest { lateinit var context: Context val options = SentryAndroidOptions().apply { setLogger(NoOpLogger.getInstance()) - isSendDefaultPii = true } fun getSut( @@ -93,10 +92,13 @@ class AnrV2EventProcessorTest { currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, - replayErrorSampleRate: Double? = null + replayErrorSampleRate: Double? = null, + isSendDefaultPii: Boolean = true ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" + options.isSendDefaultPii = isSendDefaultPii + whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -279,6 +281,7 @@ class AnrV2EventProcessorTest { // user assertEquals("bot", processed.user!!.username) assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) // trace assertEquals("ui.load", processed.contexts.trace!!.operation) // tags @@ -305,6 +308,13 @@ class AnrV2EventProcessorTest { assertEquals("Google Chrome", processed.contexts.browser!!.name) } + @Test + fun `when backfillable event is enrichable, does not backfill user ip`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + assertNull(processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -618,6 +628,7 @@ class AnrV2EventProcessorTest { hint: Hint, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, + isSendDefaultPii: Boolean = true, configureEvent: SentryEvent.() -> Unit = {} ): SentryEvent { val original = SentryEvent().apply(configureEvent) @@ -625,7 +636,8 @@ class AnrV2EventProcessorTest { val processor = fixture.getSut( tmpDir, populateScopeCache = populateScopeCache, - populateOptionsCache = populateOptionsCache + populateOptionsCache = populateOptionsCache, + isSendDefaultPii = isSendDefaultPii ) return processor.process(original, hint)!! } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 80954f67a5f..0dfcf663bff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -66,7 +66,8 @@ class DefaultAndroidEventProcessorTest { lateinit var sentryTracer: SentryTracer - fun getSut(context: Context): DefaultAndroidEventProcessor { + fun getSut(context: Context, isSendDefaultPii: Boolean = false): DefaultAndroidEventProcessor { + options.isSendDefaultPii = isSendDefaultPii whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("", ""), hub) return DefaultAndroidEventProcessor(context, buildInfo, options) @@ -284,8 +285,20 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `when event user data does not have ip address set, sets {{auto}} as the ip address`() { - val sut = fixture.getSut(context) + fun `when event user data does not have ip address set, sets no ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { + user = User() + } + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + + @Test + fun `when event user data does not have ip address set, sets {{auto}} if sendDefaultPii is true`() { + val sut = fixture.getSut(context, isSendDefaultPii = true) val event = SentryEvent().apply { user = User() } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 45be9212bac..d14264da570 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -245,7 +245,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { user = new User(); event.setUser(user); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 8881b6d386a..7933906821c 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -306,6 +306,16 @@ class MainEventProcessorTest { } } + @Test + fun `when event does not have ip address set, do not enrich ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(sendDefaultPii = true) From b1c5c1b81e764d6815b57d66f50bf2842c62c103 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 20 Jan 2025 15:53:21 +0000 Subject: [PATCH 08/24] release: 7.21.0-beta.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f93c2422c3..171ba0baba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.21.0-beta.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 2f9c5d420bf..4b18e3040bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.20.1 +versionName=7.21.0-beta.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 5a70546c2fae7dd8c373c65f8dfaf88b8f88489d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 27 Jan 2025 20:43:55 +0100 Subject: [PATCH 09/24] Prep changelog for 7.21.0 release --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 171ba0baba1..adbf2580573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + ## 7.21.0-beta.1 ### Fixes From 56c8730c65915b528a1daa2fa1ec5203d12560e6 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 27 Jan 2025 19:45:45 +0000 Subject: [PATCH 10/24] release: 7.21.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adbf2580573..99fd12bed90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.21.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index 4b18e3040bf..e423d73f7f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.21.0-beta.1 +versionName=7.21.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 47a39032827023dd2baa10cd76f1325a9ac2bbae Mon Sep 17 00:00:00 2001 From: Richard Z Date: Thu, 6 Feb 2025 18:00:48 -0500 Subject: [PATCH 11/24] Modifier.sentryTag uses Modifier.Node (#4029) * Modifier.sentryTag uses Modifier.Node * Update Changelog * Add UI test for SentryModifier * Make sentrymodifier a robolectric test * Remove redundant dep --------- Co-authored-by: Markus Hintersteiner Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 7 +++ buildSrc/src/main/java/Config.kt | 1 + sentry-compose/build.gradle.kts | 5 ++ .../io/sentry/compose/SentryModifier.kt | 41 +++++++++++-- .../compose/SentryModifierComposeTest.kt | 59 +++++++++++++++++++ 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fd12bed90..dc74fdae405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) + - This allows Composables that use this modifier to be skippable + ## 7.21.0 ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index bf5dba03be5..8f7f495e27f 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -201,6 +201,7 @@ object Config { val javaFaker = "com.github.javafaker:javafaker:1.0.2" val msgpack = "org.msgpack:msgpack-core:0.9.8" val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" + val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$composeVersion" } object QualityPlugins { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 114c08a22ff..a31027a5a3a 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -60,6 +60,11 @@ kotlin { implementation(Config.TestLibs.mockitoKotlin) implementation(Config.TestLibs.mockitoInline) implementation(Config.Libs.composeNavigation) + implementation(Config.TestLibs.robolectric) + implementation(Config.TestLibs.androidxRunner) + implementation(Config.TestLibs.androidxJunit) + implementation(Config.TestLibs.androidxTestRules) + implementation(Config.TestLibs.composeUiTestJunit4) } } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index f1f43c9c8bb..39ac3216610 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -1,8 +1,13 @@ package io.sentry.compose import androidx.compose.ui.Modifier +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.SemanticsPropertyReceiver public object SentryModifier { @@ -19,11 +24,35 @@ public object SentryModifier { ) @JvmStatic - public fun Modifier.sentryTag(tag: String): Modifier { - return semantics( - properties = { - this[SentryTag] = tag + public fun Modifier.sentryTag(tag: String): Modifier = + this then SentryTagModifierNodeElement(tag) + + private data class SentryTagModifierNodeElement(val tag: String) : + ModifierNodeElement(), SemanticsModifier { + + override val semanticsConfiguration: SemanticsConfiguration = + SemanticsConfiguration().also { + it[SentryTag] = tag } - ) + + override fun create(): SentryTagModifierNode = SentryTagModifierNode(tag) + + override fun update(node: SentryTagModifierNode) { + node.tag = tag + } + + override fun InspectorInfo.inspectableProperties() { + name = "sentryTag" + properties["tag"] = tag + } + } + + private class SentryTagModifierNode(var tag: String) : + Modifier.Node(), + SemanticsModifierNode { + + override fun SemanticsPropertyReceiver.applySemantics() { + this[SentryTag] = tag + } } } diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt new file mode 100644 index 00000000000..38aa2585d3f --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt @@ -0,0 +1,59 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.compose.SentryModifier.sentryTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryModifierComposeTest { + + companion object { + private const val TAG_VALUE = "ExampleTagValue" + } + + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager).addActivityIfNotPresent( + ComponentName( + appContext.packageName, + ComponentActivity::class.java.name + ) + ) + } + } + + @get:Rule(order = 2) + val rule = createComposeRule() + + @Test + fun sentryModifierAppliesTag() { + rule.setContent { + Box(modifier = Modifier.sentryTag(TAG_VALUE)) + } + rule.onNode( + SemanticsMatcher(TAG_VALUE) { + it.config.find { (key, _) -> key.name == SentryModifier.TAG }?.value == TAG_VALUE + } + ).assertExists() + } +} From 1eac2fccd717cd6d80f14863b1afd1ba9acc5548 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Feb 2025 15:23:22 +0100 Subject: [PATCH 12/24] Cherry-pick: Session Replay: Fix various crashes and issues (#4135) (#4145) * Cherry-pick session replay fixes * Fix test --- CHANGELOG.md | 6 +- .../sentry/android/core/LifecycleWatcher.java | 8 +- .../android/core/LifecycleWatcherTest.kt | 6 +- .../io/sentry/android/replay/ReplayCache.kt | 5 +- .../android/replay/ReplayIntegration.kt | 72 +++++-- .../sentry/android/replay/ReplayLifecycle.kt | 58 ++++++ .../replay/capture/BaseCaptureStrategy.kt | 7 +- .../io/sentry/android/replay/util/Views.kt | 12 +- .../replay/video/SimpleVideoEncoder.kt | 5 +- .../sentry/android/replay/ReplayCacheTest.kt | 15 ++ .../android/replay/ReplayIntegrationTest.kt | 178 +++++++++++++++++- .../ReplayIntegrationWithRecorderTest.kt | 6 +- .../android/replay/ReplayLifecycleTest.kt | 120 ++++++++++++ 13 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dc74fdae405..0c08da8967c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ ### Fixes +- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135)) + - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file + - Fix `IllegalStateException` when registering `onDrawListener` + - Fix SIGABRT native crashes on Motorola devices when encoding a video - (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) - - This allows Composables that use this modifier to be skippable + - This allows Composables that use this modifier to be skippable ## 7.21.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 23072265eb0..8f7353f4e3d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -10,7 +10,6 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,7 +18,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); - private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; @@ -80,7 +78,6 @@ private void startSession() { final @Nullable Session currentSession = scope.getSession(); if (currentSession != null && currentSession.getStarted() != null) { lastUpdatedSession.set(currentSession.getStarted().getTime()); - isFreshSession.set(true); } } }); @@ -92,11 +89,8 @@ private void startSession() { hub.startSession(); } hub.getOptions().getReplayController().start(); - } else if (!isFreshSession.get()) { - // only resume if it's not a fresh session, which has been started in SentryAndroid.init - hub.getOptions().getReplayController().resume(); } - isFreshSession.set(false); + hub.getOptions().getReplayController().resume(); this.lastUpdatedSession.set(currentTimeMillis); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 1bc88961da4..4f4f46e63fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -254,7 +254,7 @@ class LifecycleWatcherTest { } @Test - fun `if the hub has already a fresh session running, doesn't resume replay`() { + fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { val watcher = fixture.getSUT( enableAppLifecycleBreadcrumbs = false, session = Session( @@ -276,7 +276,7 @@ class LifecycleWatcherTest { ) watcher.onStart(fixture.ownerMock) - verify(fixture.replayController, never()).resume() + verify(fixture.replayController).resume() } @Test @@ -293,7 +293,7 @@ class LifecycleWatcherTest { verify(fixture.replayController).pause() watcher.onStart(fixture.ownerMock) - verify(fixture.replayController).resume() + verify(fixture.replayController, times(2)).resume() watcher.onStop(fixture.ownerMock) verify(fixture.replayController, timeout(10000)).stop() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 88638e7e168..11d3b84897e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -53,7 +53,7 @@ public class ReplayCache( internal val frames = mutableListOf() private val ongoingSegment = LinkedHashMap() - private val ongoingSegmentFile: File? by lazy { + internal val ongoingSegmentFile: File? by lazy { if (replayCacheDir == null) { return@lazy null } @@ -273,6 +273,9 @@ public class ReplayCache( if (isClosed.get()) { return } + if (ongoingSegmentFile?.exists() != true) { + ongoingSegmentFile?.createNewFile() + } if (ongoingSegment.isEmpty()) { ongoingSegmentFile?.useLines { lines -> lines.associateTo(ongoingSegment) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c0b77abc2a5..655b3ca354b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -21,6 +21,11 @@ import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayState.CLOSED +import io.sentry.android.replay.ReplayState.PAUSED +import io.sentry.android.replay.ReplayState.RESUMED +import io.sentry.android.replay.ReplayState.STARTED +import io.sentry.android.replay.ReplayState.STOPPED import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment @@ -100,15 +105,15 @@ public class ReplayIntegration( Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) - private val isRecording = AtomicBoolean(false) + internal val isManualPause = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null + private val lifecycle = ReplayLifecycle() override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -151,15 +156,15 @@ public class ReplayIntegration( finalizePreviousReplay() } - override fun isRecording() = isRecording.get() + override fun isRecording() = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED + @Synchronized override fun start() { - // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { return } - if (isRecording.getAndSet(true)) { + if (!lifecycle.isAllowed(STARTED)) { options.logger.log( DEBUG, "Session replay is already being recorded, not starting a new one" @@ -183,19 +188,35 @@ public class ReplayIntegration( captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) registerRootViewListeners() + lifecycle.currentState = STARTED } override fun resume() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(false) + resumeInternal() + } + + @Synchronized + private fun resumeInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(RESUMED)) { + return + } + + if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED || + hub?.rateLimiter?.isActiveForCategory(All) == true || + hub?.rateLimiter?.isActiveForCategory(Replay) == true + ) { return } captureStrategy?.resume() recorder?.resume() + lifecycle.currentState = RESUMED } + @Synchronized override fun captureReplay(isTerminating: Boolean?) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -220,16 +241,24 @@ public class ReplayIntegration( override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter override fun pause() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(true) + pauseInternal() + } + + @Synchronized + private fun pauseInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { return } recorder?.pause() captureStrategy?.pause() + lifecycle.currentState = PAUSED } + @Synchronized override fun stop() { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(STOPPED)) { return } @@ -237,8 +266,8 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() captureStrategy?.stop() - isRecording.set(false) captureStrategy = null + lifecycle.currentState = STOPPED } override fun onScreenshotRecorded(bitmap: Bitmap) { @@ -257,8 +286,9 @@ public class ReplayIntegration( } } + @Synchronized override fun close() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(CLOSED)) { return } @@ -275,10 +305,11 @@ public class ReplayIntegration( recorder = null rootViewsSpy.close() replayExecutor.gracefullyShutdown(options) + lifecycle.currentState = CLOSED } override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -289,6 +320,10 @@ public class ReplayIntegration( captureStrategy?.onConfigurationChanged(recorderConfig) recorder?.start(recorderConfig) + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.pause() + } } override fun onConnectionStatusChanged(status: ConnectionStatus) { @@ -298,10 +333,10 @@ public class ReplayIntegration( } if (status == DISCONNECTED) { - pause() + pauseInternal() } else { // being positive for other states, even if it's NO_PERMISSION - resume() + resumeInternal() } } @@ -312,15 +347,18 @@ public class ReplayIntegration( } if (rateLimiter.isActiveForCategory(All) || rateLimiter.isActiveForCategory(Replay)) { - pause() + pauseInternal() } else { - resume() + resumeInternal() } } override fun onLowMemory() = Unit override fun onTouchEvent(event: MotionEvent) { + if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) { + return + } captureStrategy?.onTouchEvent(event) } @@ -336,7 +374,7 @@ public class ReplayIntegration( hub?.rateLimiter?.isActiveForCategory(Replay) == true ) ) { - pause() + pauseInternal() } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt new file mode 100644 index 00000000000..fba95fcb415 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt @@ -0,0 +1,58 @@ +package io.sentry.android.replay + +internal enum class ReplayState { + /** + * Initial state of a Replay session. This is the state when ReplayIntegration is constructed + * but has not been started yet. + */ + INITIAL, + + /** + * Started state for a Replay session. This state is reached after the start() method is called + * and the recording is initialized successfully. + */ + STARTED, + + /** + * Resumed state for a Replay session. This state is reached after resume() is called on an + * already started recording. + */ + RESUMED, + + /** + * Paused state for a Replay session. This state is reached after pause() is called on a + * resumed recording. + */ + PAUSED, + + /** + * Stopped state for a Replay session. This state is reached after stop() is called. + * The recording can be started again from this state. + */ + STOPPED, + + /** + * Closed state for a Replay session. This is the terminal state reached after close() is called. + * No further state transitions are possible after this. + */ + CLOSED; +} + +/** + * Class to manage state transitions for ReplayIntegration + */ +internal class ReplayLifecycle { + @field:Volatile + internal var currentState = ReplayState.INITIAL + + fun isAllowed(newState: ReplayState): Boolean = when (currentState) { + ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.STARTED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.RESUMED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.PAUSED -> newState == ReplayState.RESUMED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.STOPPED -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.CLOSED -> false + } + + fun isTouchRecordingAllowed(): Boolean = currentState == ReplayState.STARTED || currentState == ReplayState.RESUMED +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fbc80565b1b..9caf92fa20f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -5,6 +5,7 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub +import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER @@ -183,7 +184,11 @@ internal abstract class BaseCaptureStrategy( task() } } else { - task() + try { + task() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $TAG.runInBackground", e) + } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index f3e667dc320..b51f2f98475 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -184,12 +184,20 @@ internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListen if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.addOnDrawListener(listener) + try { + viewTreeObserver.addOnDrawListener(listener) + } catch (e: IllegalStateException) { + // viewTreeObserver is already dead + } } internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.removeOnDrawListener(listener) + try { + viewTreeObserver.removeOnDrawListener(listener) + } catch (e: IllegalStateException) { + // viewTreeObserver is already dead + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 211decc098d..0a535a439c7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -157,7 +157,10 @@ internal class SimpleVideoEncoder( fun encode(image: Bitmap) { // it seems that Xiaomi devices have problems with hardware canvas, so we have to use // lockCanvas instead https://stackoverflow.com/a/73520742 - val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) { + val canvas = if ( + Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || + Build.MANUFACTURER.contains("motorola", ignoreCase = true) + ) { surface?.lockCanvas(null) } else { surface?.lockHardwareCanvas() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index b2c8836d40f..a3e17d4f732 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -285,6 +285,21 @@ class ReplayCacheTest { assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) } + @Test + fun `when file does not exist upon persisting creates it`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId + ) + + replayCache.ongoingSegmentFile?.delete() + + replayCache.persistSegmentValues("key", "value") + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key=value", segmentValues[0]) + } + @Test fun `stores segment key value pairs`() { val replayId = SentryId() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 4183a780b8e..353b11d8f66 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -277,6 +277,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.pause() replay.resume() verify(captureStrategy).resume() @@ -646,6 +647,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) replay.onConnectionStatusChanged(CONNECTED) verify(recorder).resume() @@ -677,16 +679,190 @@ class ReplayIntegrationTest { context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }, - isRateLimited = false + isRateLimited = true ) replay.register(fixture.hub, fixture.options) replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) replay.onRateLimitChanged(fixture.rateLimiter) verify(recorder).resume() } + @Test + fun `closed replay cannot be started`() { + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + replay.start() + replay.close() + + replay.start() + + assertFalse(replay.isRecording) + } + + @Test + fun `if recording is paused in configChanges re-pauses it again`() { + var configChanged = false + val recorderConfig = mock() + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + recorderConfigProvider = { configChanged = it; recorderConfig } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onConfigurationChanged(mock()) + + verify(recorder).stop() + verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) + verify(recorder, times(2)).start(eq(recorderConfig)) + verify(recorder, times(2)).pause() + assertTrue(configChanged) + } + + @Test + fun `onTouchEvent does nothing when not started or resumed`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + } + + @Test + fun `when paused manually onConnectionStatusChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) + replay.pause() + replay.onConnectionStatusChanged(CONNECTED) + + verify(recorder, never()).resume() + } + + @Test + fun `when paused manually onRateLimitChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + replay.pause() + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) + replay.onRateLimitChanged(fixture.rateLimiter) + + verify(recorder, never()).resume() + } + + @Test + fun `when rate limit is active manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when no connection manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isOffline = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when already paused does not pause again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.pause() + + verify(recorder).pause() + } + + @Test + fun `when already resumed does not resume again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + replay.resume() + + verify(recorder).resume() + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy { return SessionCaptureStrategy( options, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index ae817a17596..e2491d3796a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -127,12 +127,12 @@ class ReplayIntegrationWithRecorderTest { replay.start() assertEquals(STARTED, recorder.state) - replay.resume() - assertEquals(RESUMED, recorder.state) - replay.pause() assertEquals(PAUSED, recorder.state) + replay.resume() + assertEquals(RESUMED, recorder.state) + replay.stop() assertEquals(STOPPED, recorder.state) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt new file mode 100644 index 00000000000..c9892374526 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt @@ -0,0 +1,120 @@ +package io.sentry.android.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReplayLifecycleTest { + @Test + fun `verify initial state`() { + val lifecycle = ReplayLifecycle() + assertEquals(ReplayState.INITIAL, lifecycle.currentState) + } + + @Test + fun `test transitions from INITIAL state`() { + val lifecycle = ReplayLifecycle() + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + } + + @Test + fun `test transitions from STARTED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STARTED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from RESUMED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.RESUMED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from PAUSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.PAUSED + + assertTrue(lifecycle.isAllowed(ReplayState.RESUMED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from STOPPED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STOPPED + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from CLOSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.CLOSED + + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + assertFalse(lifecycle.isAllowed(ReplayState.CLOSED)) + } + + @Test + fun `test touch recording is allowed only in STARTED and RESUMED states`() { + val lifecycle = ReplayLifecycle() + + // Initial state doesn't allow touch recording + assertFalse(lifecycle.isTouchRecordingAllowed()) + + // STARTED state allows touch recording + lifecycle.currentState = ReplayState.STARTED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // RESUMED state allows touch recording + lifecycle.currentState = ReplayState.RESUMED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // Other states don't allow touch recording + val otherStates = listOf( + ReplayState.INITIAL, + ReplayState.PAUSED, + ReplayState.STOPPED, + ReplayState.CLOSED + ) + + otherStates.forEach { state -> + lifecycle.currentState = state + assertFalse(lifecycle.isTouchRecordingAllowed()) + } + } +} From d714dc82d38b5663d43ec5ee72aa590303b0f29f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 11 Feb 2025 15:56:22 +0100 Subject: [PATCH 13/24] feat(android-ndk): add api for getting debug images by addresses (#4159) * feat(android-ndk): add api for getting debug images by addresses (#4089) --------- Co-authored-by: Sentry Github Bot Co-authored-by: Markus Hintersteiner * Update Changelog * Format code * Fix switch sync/data classes to match 7.x.x --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 4 + .../api/sentry-android-core.api | 1 + .../android/core/IDebugImagesLoader.java | 4 + .../android/core/NoOpDebugImagesLoader.java | 6 + .../android/core/SentryAndroidOptionsTest.kt | 2 + sentry-android-ndk/api/sentry-android-ndk.api | 1 + .../sentry/android/ndk/DebugImagesLoader.java | 91 +++++++++++++- .../android/ndk/DebugImagesLoaderTest.kt | 111 +++++++++++++++++- 8 files changed, 215 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c08da8967c..f00519002ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) - This allows Composables that use this modifier to be skippable +### Features + +- (Internal) Add API to filter native debug images based on stacktrace addresses ([#4159](https://github.com/getsentry/sentry-java/pull/4159)) + ## 7.21.0 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 99ca27dc733..ca1f067552a 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -208,6 +208,7 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i public abstract interface class io/sentry/android/core/IDebugImagesLoader { public abstract fun clearDebugImages ()V public abstract fun loadDebugImages ()Ljava/util/List; + public abstract fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; } public final class io/sentry/android/core/InternalSentrySdk { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java index 902f7efc2b5..7b98147aab8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; @@ -11,5 +12,8 @@ public interface IDebugImagesLoader { @Nullable List loadDebugImages(); + @Nullable + Set loadDebugImagesForAddresses(Set addresses); + void clearDebugImages(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java index 70451972a76..193b7342193 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.Nullable; final class NoOpDebugImagesLoader implements IDebugImagesLoader { @@ -19,6 +20,11 @@ public static NoOpDebugImagesLoader getInstance() { return null; } + @Override + public @Nullable Set loadDebugImagesForAddresses(Set addresses) { + return null; + } + @Override public void clearDebugImages() {} } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index aa266d5c7a2..7bcc35b6b56 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -182,6 +182,8 @@ class SentryAndroidOptionsTest { private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null + override fun loadDebugImagesForAddresses(addresses: Set?): Set? = null + override fun clearDebugImages() {} } } diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index e8f838ce8b4..cbd2e308bd6 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -10,6 +10,7 @@ public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/c public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/ndk/NativeModuleListLoader;)V public fun clearDebugImages ()V public fun loadDebugImages ()Ljava/util/List; + public fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; } public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObserverAdapter { diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java index cb38db498a6..a8574157775 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java @@ -7,7 +7,9 @@ import io.sentry.protocol.DebugImage; import io.sentry.util.Objects; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; @@ -22,7 +24,7 @@ public final class DebugImagesLoader implements IDebugImagesLoader { private final @NotNull NativeModuleListLoader moduleListLoader; - private static @Nullable List debugImages; + private static volatile @Nullable List debugImages; /** we need to lock it because it could be called from different threads */ private static final @NotNull Object debugImagesLock = new Object(); @@ -60,7 +62,92 @@ public DebugImagesLoader( return debugImages; } - /** Clears the caching of debug images on sentry-native and here. */ + /** + * Loads debug images for the given set of addresses. + * + * @param addresses Set of memory addresses to find debug images for + * @return Set of matching debug images, or null if debug images couldn't be loaded + */ + public @Nullable Set loadDebugImagesForAddresses( + final @NotNull Set addresses) { + synchronized (debugImagesLock) { + final @Nullable List allDebugImages = loadDebugImages(); + if (allDebugImages == null) { + return null; + } + if (addresses.isEmpty()) { + return null; + } + + final Set referencedImages = filterImagesByAddresses(allDebugImages, addresses); + if (referencedImages.isEmpty()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No debug images found for any of the %d addresses.", + addresses.size()); + return null; + } + + return referencedImages; + } + } + + /** + * Finds all debug image containing the given addresses. Assumes that the images are sorted by + * address, which should always be true on Linux/Android and Windows platforms + * + * @return All matching debug images or null if none are found + */ + private @NotNull Set filterImagesByAddresses( + final @NotNull List images, final @NotNull Set addresses) { + final Set result = new HashSet<>(); + + for (int i = 0; i < images.size(); i++) { + final @NotNull DebugImage image = images.get(i); + final @Nullable DebugImage nextDebugImage = + (i + 1) < images.size() ? images.get(i + 1) : null; + final @Nullable String nextDebugImageAddress = + nextDebugImage != null ? nextDebugImage.getImageAddr() : null; + + for (final @NotNull String rawAddress : addresses) { + try { + final long address = Long.parseLong(rawAddress.replace("0x", ""), 16); + + final @Nullable String imageAddress = image.getImageAddr(); + if (imageAddress != null) { + try { + final long imageStart = Long.parseLong(imageAddress.replace("0x", ""), 16); + final long imageEnd; + + final @Nullable Long imageSize = image.getImageSize(); + if (imageSize != null) { + imageEnd = imageStart + imageSize; + } else if (nextDebugImageAddress != null) { + imageEnd = Long.parseLong(nextDebugImageAddress.replace("0x", ""), 16); + } else { + imageEnd = Long.MAX_VALUE; + } + if (address >= imageStart && address < imageEnd) { + result.add(image); + // once image is added we can skip the remaining addresses and go straight to the + // next + // image + break; + } + } catch (NumberFormatException e) { + // ignored, invalid debug image address + } + } + } catch (NumberFormatException e) { + // ignored, invalid address supplied + } + } + } + return result; + } + @Override public void clearDebugImages() { synchronized (debugImagesLock) { diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt index db584c814f6..4d14caa695d 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt @@ -5,8 +5,8 @@ import io.sentry.protocol.DebugImage import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.lang.RuntimeException import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -17,11 +17,13 @@ class DebugImagesLoaderTest { val options = SentryAndroidOptions() fun getSut(): DebugImagesLoader { - return DebugImagesLoader(options, nativeLoader) + val loader = DebugImagesLoader(options, nativeLoader) + loader.clearDebugImages() + return loader } } - private val fixture = Fixture() + private var fixture = Fixture() @Test fun `get images returns image list`() { @@ -78,4 +80,107 @@ class DebugImagesLoaderTest { assertNull(sut.cachedDebugImages) } + + @Test + fun `find images by address`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + val image3 = DebugImage().apply { + imageAddr = "0x3000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2, image3)) + + val result = sut.loadDebugImagesForAddresses( + setOf("0x1500", "0x2500") + ) + + assertNotNull(result) + assertEquals(2, result.size) + assertTrue(result.any { it.imageAddr == image1.imageAddr }) + assertTrue(result.any { it.imageAddr == image2.imageAddr }) + } + + @Test + fun `find images with invalid addresses are not added to the result`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2)) + + val hexAddresses = setOf("0xINVALID", "0x1500") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertEquals(1, result!!.size) + } + + @Test + fun `find images by address returns null if result is empty`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2)) + + val hexAddresses = setOf("0x100", "0x10500") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertNull(result) + } + + @Test + fun `invalid image addresses are ignored for loadDebugImagesForAddresses`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0xNotANumber" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = null + } + + val image3 = DebugImage().apply { + imageAddr = "0x5000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2, image3)) + + val hexAddresses = setOf("0x100", "0x2000", "0x2000", "0x5000") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertNotNull(result) + assertEquals(2, result.size) + } } From 8be05874241033eb0a32fac7c8f0de2a91836d03 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 11 Feb 2025 16:06:56 +0000 Subject: [PATCH 14/24] release: 7.22.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00519002ba..9bfc5eeeb74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index e423d73f7f9..944f3ae3a02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.21.0 +versionName=7.22.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From f10c73cc2425b8be78b7689a795e44bd32d90f2e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 10 Mar 2025 14:00:37 +0100 Subject: [PATCH 15/24] Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running (#4216) * Ensure app start type is set, even when ActivityLifecycleIntegration is not activated * Update Changelog * Add proper tests * Add code comments * Unify handling * Move all app start handling to AppStartMetrics * Make tests happy * Fix flaky RateLimiter test (#4100) * changed RateLimiterTest `close cancels the timer` to use reflection * Update sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java Co-authored-by: Stefano * Address PR feedback * Fix post-merge conflict * Format code * Address PR feedback * Address PR feedback * Update sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java --------- Co-authored-by: Stefano Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 6 + .../api/sentry-android-core.api | 6 +- .../core/ActivityLifecycleIntegration.java | 26 +-- .../io/sentry/android/core/SentryAndroid.java | 2 +- .../core/SentryPerformanceProvider.java | 48 +--- .../core/performance/AppStartMetrics.java | 164 +++++++------ .../core/ActivityLifecycleIntegrationTest.kt | 138 +---------- .../PerformanceAndroidEventProcessorTest.kt | 3 +- .../core/SentryPerformanceProviderTest.kt | 27 +-- .../core/performance/AppStartMetricsTest.kt | 215 ++++++++++++++---- .../io/sentry/transport/RateLimiterTest.kt | 21 +- 11 files changed, 311 insertions(+), 345 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfc5eeeb74..983bbc29685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) + ## 7.22.0 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ca1f067552a..8096a5058e3 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -447,15 +447,15 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z - public fun isColdStartValid ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityStarted (Landroid/app/Activity;)V public fun onAppStartSpansSent ()V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V - public fun registerApplicationForegroundCheck (Landroid/app/Application;)V - public fun restartAppStart (J)V + public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 0912051dd7d..5ddf706d16d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -397,7 +397,6 @@ public synchronized void onActivityCreated( if (!isAllActivityCallbacksAvailable) { onActivityPreCreated(activity, savedInstanceState); } - setColdStart(savedInstanceState); if (hub != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); hub.configureScope(scope -> scope.setScreen(activityClassName)); @@ -554,15 +553,13 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) { // if the activity is opened again and not in memory, transactions will be created normally. activitiesWithOngoingTransactions.remove(activity); - if (activitiesWithOngoingTransactions.isEmpty()) { + if (activitiesWithOngoingTransactions.isEmpty() && !activity.isChangingConfigurations()) { clear(); } } private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - lastPausedUptimeMillis = 0; activityLifecycleMap.clear(); } @@ -705,27 +702,6 @@ WeakHashMap getTtfdSpanMap() { return ttfdSpanMap; } - private void setColdStart(final @Nullable Bundle savedInstanceState) { - if (!firstActivityCreated) { - final @NotNull TimeSpan appStartSpan = AppStartMetrics.getInstance().getAppStartTimeSpan(); - // If the app start span already started and stopped, it means the app restarted without - // killing the process, so we are in a warm start - // If the app has an invalid cold start, it means it was started in the background, like - // via BroadcastReceiver, so we consider it a warm start - if ((appStartSpan.hasStarted() && appStartSpan.hasStopped()) - || (!AppStartMetrics.getInstance().isColdStartValid())) { - AppStartMetrics.getInstance().restartAppStart(lastPausedUptimeMillis); - AppStartMetrics.getInstance().setAppStartType(AppStartMetrics.AppStartType.WARM); - } else { - AppStartMetrics.getInstance() - .setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); - } - } - } - private @NotNull String getTtidDesc(final @NotNull String activityName) { return activityName + " initial display"; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index adeb451332a..9fee04f2515 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -152,7 +152,7 @@ public static synchronized void init( } } if (context.getApplicationContext() instanceof Application) { - appStartMetrics.registerApplicationForegroundCheck( + appStartMetrics.registerLifecycleCallbacks( (Application) context.getApplicationContext()); } final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 0aa946c2553..ade1826c2c1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -3,17 +3,13 @@ import static io.sentry.Sentry.APP_START_PROFILING_CONFIG_FILE_NAME; import android.annotation.SuppressLint; -import android.app.Activity; import android.app.Application; import android.content.Context; import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Build; -import android.os.Handler; -import android.os.Looper; import android.os.Process; import android.os.SystemClock; -import androidx.annotation.NonNull; import io.sentry.ILogger; import io.sentry.ITransactionProfiler; import io.sentry.JsonSerializer; @@ -22,9 +18,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.TracesSamplingDecision; -import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; -import io.sentry.android.core.performance.ActivityLifecycleCallbacksAdapter; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import java.io.BufferedReader; @@ -33,7 +27,6 @@ import java.io.FileNotFoundException; import java.io.InputStreamReader; import java.io.Reader; -import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -185,8 +178,9 @@ private void onAppLaunched( // performance v2: Uses Process.getStartUptimeMillis() // requires API level 24+ - if (buildInfoProvider.getSdkInfoVersion() < android.os.Build.VERSION_CODES.N) { - return; + if (buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); } if (context instanceof Application) { @@ -196,40 +190,6 @@ private void onAppLaunched( return; } - final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); - appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); - appStartMetrics.registerApplicationForegroundCheck(app); - - final AtomicBoolean firstDrawDone = new AtomicBoolean(false); - - activityCallback = - new ActivityLifecycleCallbacksAdapter() { - @Override - public void onActivityStarted(@NonNull Activity activity) { - if (firstDrawDone.get()) { - return; - } - if (activity.getWindow() != null) { - FirstDrawDoneListener.registerForNextDraw( - activity, () -> onAppStartDone(), buildInfoProvider); - } else { - new Handler(Looper.getMainLooper()).post(() -> onAppStartDone()); - } - } - }; - - app.registerActivityLifecycleCallbacks(activityCallback); - } - - synchronized void onAppStartDone() { - final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - appStartMetrics.getSdkInitTimeSpan().stop(); - appStartMetrics.getAppStartTimeSpan().stop(); - - if (app != null) { - if (activityCallback != null) { - app.unregisterActivityLifecycleCallbacks(activityCallback); - } - } + appStartMetrics.registerLifecycleCallbacks(app); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 2e249d6dccf..20a85584866 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -11,17 +11,20 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.sentry.ITransactionProfiler; -import io.sentry.SentryDate; -import io.sentry.SentryNanotimeDate; +import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -30,6 +33,9 @@ * An in-memory representation for app-metrics during app start. As the SDK can't be initialized * that early, we can't use transactions or spans directly. Thus simple TimeSpans are used and later * transformed into SDK specific txn/span data structures. + * + *

This class is also responsible for - determining the app start type (cold, warm) - determining + * if the app was launched in foreground */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { @@ -45,7 +51,7 @@ public enum AppStartType { private static volatile @Nullable AppStartMetrics instance; private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground = false; + private boolean appLaunchedInForeground; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -54,10 +60,10 @@ public enum AppStartType { private final @NotNull List activityLifecycles; private @Nullable ITransactionProfiler appStartProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; - private @Nullable SentryDate onCreateTime = null; - private boolean appLaunchTooLong = false; private boolean isCallbackRegistered = false; private boolean shouldSendStartMeasurements = true; + private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); + private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); public static @NotNull AppStartMetrics getInstance() { @@ -116,10 +122,6 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } - public boolean isColdStartValid() { - return appLaunchedInForeground && !appLaunchTooLong; - } - @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; @@ -153,17 +155,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements; - } - - public void restartAppStart(final long uptimeMillis) { - shouldSendStartMeasurements = true; - appLaunchTooLong = false; - appLaunchedInForeground = true; - appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(uptimeMillis); - CLASS_LOADED_UPTIME_MS = appStartSpan.getStartUptimeMs(); + return shouldSendStartMeasurements && appLaunchedInForeground; } public long getClassLoadedUptimeMs() { @@ -176,20 +168,27 @@ public long getClassLoadedUptimeMs() { */ public @NotNull TimeSpan getAppStartTimeSpanWithFallback( final @NotNull SentryAndroidOptions options) { - // If the app launch took too long or it was launched in the background we return an empty span - if (!isColdStartValid()) { - return new TimeSpan(); - } - if (options.isEnablePerformanceV2()) { - // Only started when sdk version is >= N - final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); - if (appStartSpan.hasStarted()) { - return appStartSpan; + // If the app start type was never determined or app wasn't launched in foreground, + // the app start is considered invalid + if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted() + && appStartSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + final @NotNull TimeSpan sdkInitTimeSpan = getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasStarted() + && sdkInitTimeSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return sdkInitTimeSpan; } } - // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return new TimeSpan(); } @TestOnly @@ -205,11 +204,11 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; - appLaunchTooLong = false; appLaunchedInForeground = false; - onCreateTime = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; + firstDrawDone.set(false); + activeActivitiesCounter.set(0); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -247,7 +246,23 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.registerApplicationForegroundCheck(application); + instance.registerLifecycleCallbacks(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); } } @@ -256,7 +271,7 @@ public static void onApplicationCreate(final @NotNull Application application) { * * @param application The application object to register the callback to */ - public void registerApplicationForegroundCheck(final @NotNull Application application) { + public void registerLifecycleCallbacks(final @NotNull Application application) { if (isCallbackRegistered) { return; } @@ -267,15 +282,15 @@ public void registerApplicationForegroundCheck(final @NotNull Application applic // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); } - private void checkCreateTimeOnMain(final @NotNull Application application) { + private void checkCreateTimeOnMain() { new Handler(Looper.getMainLooper()) .post( () -> { // if no activity has ever been created, app was launched in background - if (onCreateTime == null) { + if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground = false; // we stop the app start profiler, as it's useless and likely to timeout @@ -284,43 +299,54 @@ private void checkCreateTimeOnMain(final @NotNull Application application) { appStartProfiler = null; } } - application.unregisterActivityLifecycleCallbacks(instance); }); } @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - // An activity already called onCreate() - if (!appLaunchedInForeground || onCreateTime != null) { + final long nowUptimeMs = SystemClock.uptimeMillis(); + + // the first activity determines the app start type + if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { + // If the app (process) was launched more than 1 minute ago, it's likely wrong + final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); + if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + appStartType = AppStartType.WARM; + + shouldSendStartMeasurements = true; + appStartSpan.reset(); + appStartSpan.start(); + appStartSpan.setStartedAt(nowUptimeMs); + CLASS_LOADED_UPTIME_MS = nowUptimeMs; + } else { + appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + } + } + appLaunchedInForeground = true; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { return; } - onCreateTime = new SentryNanotimeDate(); - - final long spanStartMillis = appStartSpan.getStartTimestampMs(); - final long spanEndMillis = - appStartSpan.hasStopped() - ? appStartSpan.getProjectedStopTimestampMs() - : System.currentTimeMillis(); - final long durationMillis = spanEndMillis - spanStartMillis; - // If the app was launched more than 1 minute ago, it's likely wrong - if (durationMillis > TimeUnit.MINUTES.toMillis(1)) { - appLaunchTooLong = true; + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(), new BuildInfoProvider(NoOpLogger.getInstance())); + } else { + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn()); } } - /** - * Called by instrumentation - * - * @param application The application object where onCreate was called on - * @noinspection unused - */ - public static void onApplicationPostCreate(final @NotNull Application application) { - final long now = SystemClock.uptimeMillis(); - - final @NotNull AppStartMetrics instance = getInstance(); - if (instance.applicationOnCreate.hasNotStopped()) { - instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); - instance.applicationOnCreate.setStoppedAt(now); + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + // if the app is moving into background + // as the next Activity is considered like a new app start + if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appLaunchedInForeground = false; + shouldSendStartMeasurements = true; + firstDrawDone.set(false); } } @@ -354,4 +380,12 @@ public static void onContentProviderPostCreate(final @NotNull ContentProvider co measurement.setStoppedAt(now); } } + + synchronized void onFirstFrameDrawn() { + if (!firstDrawDone.getAndSet(true)) { + final @NotNull AppStartMetrics appStartMetrics = getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index b212ed2feab..c14d6c82c4a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -54,7 +54,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import java.util.Date import java.util.concurrent.Future -import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -94,7 +93,10 @@ class ActivityLifecycleIntegrationTest { whenever(hub.options).thenReturn(options) - AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.start() + // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() @@ -594,45 +596,6 @@ class ActivityLifecycleIntegrationTest { verify(ttfdReporter, never()).registerFullyDrawnListener(any()) } - @Test - fun `App start is Cold when savedInstanceState is null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `App start is Warm when savedInstanceState is not null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `Do not overwrite App start type after set`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - @Test fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() @@ -883,86 +846,6 @@ class ActivityLifecycleIntegrationTest { ) } - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start warm span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, fixture.bundle) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start cold span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.cold") - assertEquals(span.description, "Cold Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started more than 1 minute ago, start app with Warm start`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started in background, start app with Warm start`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - @Test fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() @@ -1467,7 +1350,6 @@ class ActivityLifecycleIntegrationTest { assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) sut.onActivityCreated(activity, null) - assertNotNull(sut.appStartSpan) sut.onActivityPostCreated(activity, null) assertTrue(activityLifecycleSpan.onCreate.hasStopped()) @@ -1556,15 +1438,6 @@ class ActivityLifecycleIntegrationTest { // lastPausedUptimeMillis is set to current SystemClock.uptimeMillis() val lastUptimeMillis = sut.getProperty("lastPausedUptimeMillis") assertNotEquals(0, lastUptimeMillis) - - sut.onActivityCreated(activity, null) - // AppStartMetrics app start time is set to Activity preCreated timestamp - assertEquals(lastUptimeMillis, appStartMetrics.appStartTimeSpan.startUptimeMs) - // AppStart type is considered warm - assertEquals(AppStartType.WARM, appStartMetrics.appStartType) - - // Activity appStart span timestamp is the same of AppStartMetrics.appStart timestamp - assertEquals(sut.appStartSpan!!.startDate.nanoTimestamp(), appStartMetrics.getAppStartTimeSpanWithFallback(fixture.options).startTimestamp!!.nanoTimestamp()) } private fun SentryTracer.isFinishing() = getProperty("finishStatus").getProperty("isFinishing") @@ -1578,6 +1451,9 @@ class ActivityLifecycleIntegrationTest { private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) { // set by SentryPerformanceProvider so forcing it here + AppStartMetrics.getInstance().appStartType = AppStartType.COLD + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 35e0f5257bf..c0d18e5db75 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -75,7 +75,7 @@ class PerformanceAndroidEventProcessorTest { null, null ).also { - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + AppStartMetrics.getInstance().onActivityCreated(mock(), if (coldStart) null else mock()) } @BeforeTest @@ -225,6 +225,7 @@ class PerformanceAndroidEventProcessorTest { fun `adds app start metrics to app start txn`() { // given some app start metrics val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.isAppLaunchedInForeground = true appStartMetrics.appStartType = AppStartType.COLD appStartMetrics.appStartTimeSpan.setStartedAt(123) appStartMetrics.appStartTimeSpan.setStoppedAt(456) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 9f868d701b4..5e816f6ca50 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.content.pm.ProviderInfo import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,7 +17,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -48,6 +48,7 @@ class SentryPerformanceProviderTest { val providerInfo = ProviderInfo() val logger = mock() lateinit var configFile: File + var activityLifecycleCallbacks: MutableList = mutableListOf() fun getSut(sdkVersion: Int = Build.VERSION_CODES.S, authority: String = AUTHORITY, handleFile: ((config: File) -> Unit)? = null): SentryPerformanceProvider { val buildInfoProvider: BuildInfoProvider = mock() @@ -56,7 +57,14 @@ class SentryPerformanceProviderTest { whenever(mockContext.applicationContext).thenReturn(mockContext) configFile = File(sentryCache, Sentry.APP_START_PROFILING_CONFIG_FILE_NAME) handleFile?.invoke(configFile) - + whenever(mockContext.registerActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.add(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } + whenever(mockContext.unregisterActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.remove(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } providerInfo.authority = authority return SentryPerformanceProvider(logger, buildInfoProvider).apply { attachInfo(mockContext, providerInfo) @@ -104,24 +112,11 @@ class SentryPerformanceProviderTest { @Test fun `provider sets both appstart and sdk init start + end times`() { val provider = fixture.getSut() - provider.onAppStartDone() + provider.onCreate() val metrics = AppStartMetrics.getInstance() assertTrue(metrics.appStartTimeSpan.hasStarted()) - assertTrue(metrics.appStartTimeSpan.hasStopped()) - assertTrue(metrics.sdkInitTimeSpan.hasStarted()) - assertTrue(metrics.sdkInitTimeSpan.hasStopped()) - } - - @Test - fun `provider properly registers and unregisters ActivityLifecycleCallbacks`() { - val provider = fixture.getSut() - - // It register once for the provider itself and once for the appStartMetrics - verify(fixture.mockContext, times(2)).registerActivityLifecycleCallbacks(any()) - provider.onAppStartDone() - verify(fixture.mockContext).unregisterActivityLifecycleCallbacks(any()) } //region app start profiling diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index d8b9e727e20..d36ff01a5fb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -1,9 +1,12 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.Application import android.content.ContentProvider import android.os.Build +import android.os.Bundle import android.os.Looper +import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ITransactionProfiler import io.sentry.android.core.SentryAndroidOptions @@ -75,6 +78,7 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled and app start time span is started, appStartTimeSpanWithFallback returns it`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.WARM appStartTimeSpan.start() val options = SentryAndroidOptions().apply { @@ -88,7 +92,12 @@ class AppStartMetricsTest { @Test fun `if perf-2 is disabled but app start time span has started, appStartTimeSpanWithFallback returns the sdk init span instead`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } + appStartTimeSpan.setStartedAt(123) val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false @@ -101,8 +110,11 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled but app start time span has not started, appStartTimeSpanWithFallback returns the sdk init span instead`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - assertTrue(appStartTimeSpan.hasNotStarted()) + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = true @@ -121,6 +133,8 @@ class AppStartMetricsTest { @Test fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { AppStartMetrics.getInstance().isAppLaunchedInForeground = false + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) @@ -136,19 +150,50 @@ class AppStartMetricsTest { } @Test - fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() - assertTrue(appStartTimeSpan.hasStarted()) - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + fun `if app is launched in background, but an activity launches later, a new warm start is reported with correct timings`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) - val options = SentryAndroidOptions().apply { - isEnablePerformanceV2 = true - } + // when the looper runs + Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) - assertFalse(timeSpan.hasStarted()) + // but no activity creation happened + // then the app wasn't launched in foreground and nothing should be sent + assertFalse(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements()) + + val now = TimeUnit.MINUTES.toMillis(2) + 1234567 + SystemClock.setCurrentTimeMillis(now) + + // once an activity launches + AppStartMetrics.getInstance().onActivityCreated(mock(), null) + + // then it should restart the timespan + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `if app is launched in background, the first created activity assumes a warm start`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + metrics.registerLifecycleCallbacks(mock()) + + // when the handler callback is executed and no activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // isAppLaunchedInForeground should be false + assertFalse(metrics.isAppLaunchedInForeground) + + // but when the first activity launches + metrics.onActivityCreated(mock(), null) + + // then a warm start should be set + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test @@ -172,14 +217,15 @@ class AppStartMetricsTest { @Test fun `if activity is never started, returns an empty span`() { - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) + val timeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) assertFalse(timeSpan.hasStarted()) } @@ -189,7 +235,7 @@ class AppStartMetricsTest { whenever(profiler.isRunning).thenReturn(true) AppStartMetrics.getInstance().appStartProfiler = profiler - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -203,7 +249,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().appStartProfiler = profiler AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -231,33 +277,26 @@ class AppStartMetricsTest { @Test fun `when multiple registerApplicationForegroundCheck, only one callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application, times(1)).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + verify( + application, + times(1) + ).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } @Test fun `when registerApplicationForegroundCheck, a callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } - @Test - fun `when registerApplicationForegroundCheck, a job is posted on main thread to unregistered the callback`() { - val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - verify(application, never()).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(application).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - } - @Test fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // Main thread performs the check and sets the flag to false if no activity was created Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -268,7 +307,7 @@ class AppStartMetricsTest { fun `registerApplicationForegroundCheck keeps foreground state to true if an activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) @@ -277,12 +316,6 @@ class AppStartMetricsTest { assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } - @Test - fun `isColdStartValid is false if app was launched in background`() { - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - assertFalse(AppStartMetrics.getInstance().isColdStartValid) - } - @Test fun `isColdStartValid is false if app launched in more than 1 minute`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -291,7 +324,6 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - assertFalse(AppStartMetrics.getInstance().isColdStartValid) } @Test @@ -307,19 +339,104 @@ class AppStartMetricsTest { } @Test - fun `restartAppStart set measurement flag and clear internal lists`() { + fun `a warm start gets reported after a cold start`() { val appStartMetrics = AppStartMetrics.getInstance() + + // when the first activity launches and gets destroyed + val activity0 = mock() + whenever(activity0.isChangingConfigurations).thenReturn(false) + appStartMetrics.onActivityCreated(activity0, null) + + // then the app start type should be cold and measurements should be sent + assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + + // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - appStartMetrics.isAppLaunchedInForeground = false assertFalse(appStartMetrics.shouldSendStartMeasurements()) - assertFalse(appStartMetrics.isColdStartValid) - appStartMetrics.restartAppStart(10) + appStartMetrics.onActivityDestroyed(activity0) + // then it should reset sending the measurements for the next warm activity + appStartMetrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) assertTrue(appStartMetrics.shouldSendStartMeasurements()) - assertTrue(appStartMetrics.isColdStartValid) - assertTrue(appStartMetrics.appStartTimeSpan.hasStarted()) - assertTrue(appStartMetrics.appStartTimeSpan.hasNotStopped()) - assertEquals(10, appStartMetrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + + assertFalse(metrics.appStartTimeSpan.hasStopped()) + assertFalse(metrics.sdkInitTimeSpan.hasStopped()) + + metrics.onFirstFrameDrawn() + + assertTrue(metrics.appStartTimeSpan.hasStopped()) + assertTrue(metrics.sdkInitTimeSpan.hasStopped()) + } + + @Test + fun `Sets app launch type to cold`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), mock()) + + // then the app start is still considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm if process init was too long ago`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + val app = mock() + metrics.registerLifecycleCallbacks(app) + + // when an activity is created later with a null bundle + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), mock()) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), null) + + // then the app start is still considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } } diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 8d8fb9601ef..9fe0cd40877 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -28,6 +28,8 @@ import io.sentry.metrics.EncodedMetrics import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User +import io.sentry.test.getProperty +import io.sentry.test.injectForField import org.awaitility.kotlin.await import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -37,6 +39,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import java.io.File +import java.util.Timer import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.Test @@ -412,18 +415,16 @@ class RateLimiterTest { @Test fun `close cancels the timer`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 1, 2001) - - val applied = AtomicBoolean(true) - rateLimiter.addRateLimitObserver { - applied.set(rateLimiter.isActiveForCategory(Replay)) - } + val timer = mock() + rateLimiter.injectForField("timer", timer) - rateLimiter.updateRetryAfterLimits("1:replay:key", null, 1) + // When the rate limiter is closed rateLimiter.close() - // wait for 1.5s to ensure the timer has run after 1s - await.untilTrue(applied) - assertTrue(applied.get()) + // Then the timer is cancelled + verify(timer).cancel() + + // And is removed by the rateLimiter + assertNull(rateLimiter.getProperty("timer")) } } From 90fd679d2dc39074c879df2a5adf0048d1be0ac1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 10 Mar 2025 17:01:17 +0100 Subject: [PATCH 16/24] Fix properly reset application/content-provider timespans (#4244) * Fix properly reset application/content-provider timespans * Update Changelog --- CHANGELOG.md | 1 + .../android/core/performance/AppStartMetrics.java | 2 ++ .../core/performance/AppStartMetricsTest.kt | 15 +++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983bbc29685..bf7c185c408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) +- Fix properly reset application/content-provider timespans for warm app starts ([#4244](https://github.com/getsentry/sentry-java/pull/4244)) ## 7.22.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 20a85584866..6107c2e1178 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -318,6 +318,8 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved appStartSpan.start(); appStartSpan.setStartedAt(nowUptimeMs); CLASS_LOADED_UPTIME_MS = nowUptimeMs; + contentProviderOnCreates.clear(); + applicationOnCreate.reset(); } else { appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index d36ff01a5fb..855973d9418 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -154,6 +154,19 @@ class AppStartMetricsTest { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) + metrics.contentProviderOnCreateTimeSpans.add( + TimeSpan().apply { + description = "ExampleContentProvider" + setStartedAt(1) + setStoppedAt(2) + } + ) + + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(3) + setStoppedAt(4) + } + // when the looper runs Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -173,6 +186,8 @@ class AppStartMetricsTest { assertTrue(metrics.shouldSendStartMeasurements()) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + assertTrue(metrics.contentProviderOnCreateTimeSpans.isEmpty()) } @Test From 2384975ebc7bbea5450d6a505d74bf8f53a2321d Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 10 Mar 2025 16:18:39 +0000 Subject: [PATCH 17/24] release: 7.22.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7c185c408..46243ecee2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 944f3ae3a02..fe3050ebc19 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.0 +versionName=7.22.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 7c028eb882350c7750690514d3cc7e379792a972 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 13 Mar 2025 11:14:48 +0100 Subject: [PATCH 18/24] Fix AbstractMethodError when using SentryTraced for Jetpack Compose (7.x.x) (#4256) * Fix AbstractMethodError when using SentryTraced for Jetpack Compose * Override default interface impl to fix AbstractMethodError * Update Changelog * Update Changelog --- CHANGELOG.md | 6 ++++++ .../androidMain/kotlin/io/sentry/compose/SentryModifier.kt | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46243ecee2e..1a5eb4f7b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4256](https://github.com/getsentry/sentry-java/pull/4256)) + ## 7.22.1 ### Fixes diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 39ac3216610..e2b7bb07192 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -51,6 +51,12 @@ public object SentryModifier { Modifier.Node(), SemanticsModifierNode { + override val shouldClearDescendantSemantics: Boolean + get() = false + + override val shouldMergeDescendantSemantics: Boolean + get() = false + override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } From 434c8033164f832a515956f9c687c87d2338f8d6 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 13 Mar 2025 17:07:34 +0000 Subject: [PATCH 19/24] release: 7.22.2 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5eb4f7b71..a40a5e77618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.2 ### Fixes diff --git a/gradle.properties b/gradle.properties index fe3050ebc19..a45a1152629 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.1 +versionName=7.22.2 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 9c964d04b4015b065a9839ffca68a7e8e05806a0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 17 Mar 2025 13:36:23 +0100 Subject: [PATCH 20/24] Reduce excessive CPU usage when serializing breadcrumbs to disk (#4181) (#4260) * WIP * WIP * Remove redundant line * Add Tests * api dump * Formatting * REset scope cache on new init * Clean up * Comment * Changelog * Workaround https://github.com/square/tape/issues/173 * Add a comment to setBreadcrumbs * Address PR review * Update CHANGELOG.md --- CHANGELOG.md | 6 + buildSrc/src/main/java/Config.kt | 1 + .../core/AndroidOptionsInitializer.java | 14 +- .../android/core/AnrV2EventProcessor.java | 47 +- .../core/AndroidOptionsInitializerTest.kt | 7 +- .../android/core/AnrV2EventProcessorTest.kt | 16 +- .../sentry/android/core/SentryAndroidTest.kt | 56 +- .../android/replay/ReplayIntegration.kt | 8 +- .../android/replay/ReplayIntegrationTest.kt | 22 +- sentry/api/sentry.api | 50 +- sentry/build.gradle.kts | 1 + sentry/src/main/java/io/sentry/Sentry.java | 11 + .../main/java/io/sentry/SentryOptions.java | 12 + .../main/java/io/sentry/cache/CacheUtils.java | 17 +- .../sentry/cache/PersistingScopeObserver.java | 148 +++- .../sentry/cache/tape/EmptyObjectQueue.java | 52 ++ .../io/sentry/cache/tape/FileObjectQueue.java | 148 ++++ .../io/sentry/cache/tape/ObjectQueue.java | 108 +++ .../java/io/sentry/cache/tape/QueueFile.java | 817 ++++++++++++++++++ .../java/io/sentry/cache/CacheUtilsTest.kt | 10 + .../cache/PersistingScopeObserverTest.kt | 71 +- .../sentry/cache/tape/CorruptQueueFileTest.kt | 43 + .../io/sentry/cache/tape/ObjectQueueTest.kt | 252 ++++++ .../io/sentry/cache/tape/QueueFileTest.kt | 730 ++++++++++++++++ .../src/test/resources/corrupt_queue_file.txt | Bin 0 -> 4100 bytes 25 files changed, 2523 insertions(+), 124 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/QueueFile.java create mode 100644 sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt create mode 100644 sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt create mode 100644 sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt create mode 100644 sentry/src/test/resources/corrupt_queue_file.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a40a5e77618..936bd3fe290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) + ## 7.22.2 ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8f7f495e27f..26c6e9775b1 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -202,6 +202,7 @@ object Config { val msgpack = "org.msgpack:msgpack-core:0.9.8" val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$composeVersion" + val okio = "com.squareup.okio:okio:1.13.0" } object QualityPlugins { 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 80d81671ee5..5378f93110b 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 @@ -143,6 +143,11 @@ static void initializeIntegrationsAndProcessors( new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider)); } + if (options.getCacheDirPath() != null) { + options.addScopeObserver(new PersistingScopeObserver(options)); + options.addOptionsObserver(new PersistingOptionsObserver(options)); + } + options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); options.addEventProcessor( new DefaultAndroidEventProcessor(context, buildInfoProvider, options)); @@ -221,13 +226,6 @@ static void initializeIntegrationsAndProcessors( } } options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); - - if (options.getCacheDirPath() != null) { - if (options.isEnableScopePersistence()) { - options.addScopeObserver(new PersistingScopeObserver(options)); - } - options.addOptionsObserver(new PersistingOptionsObserver(options)); - } } static void installDefaultIntegrations( @@ -273,6 +271,8 @@ static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it options.addIntegration(new AppLifecycleIntegration()); + // AnrIntegration must be installed before ReplayIntegration, as ReplayIntegration relies on + // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); // registerActivityLifecycleCallbacks is only available if Context is an AppContext diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 990facd6244..0f07151d2fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -33,6 +33,7 @@ import io.sentry.SentryEvent; import io.sentry.SentryExceptionFactory; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; import io.sentry.android.core.internal.util.CpuInfoUtils; @@ -83,6 +84,8 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable PersistingScopeObserver persistingScopeObserver; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, @@ -90,6 +93,7 @@ public AnrV2EventProcessor( this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; + this.persistingScopeObserver = options.findPersistingScopeObserver(); final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -188,8 +192,7 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { } private void setReplayId(final @NotNull SentryEvent event) { - @Nullable - String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); final @NotNull File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); if (!replayFolder.exists()) { @@ -224,8 +227,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } private void setTrace(final @NotNull SentryEvent event) { - final SpanContext spanContext = - PersistingScopeObserver.read(options, TRACE_FILENAME, SpanContext.class); + final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); if (event.getContexts().getTrace() == null) { if (spanContext != null && spanContext.getSpanId() != null @@ -236,8 +238,7 @@ private void setTrace(final @NotNull SentryEvent event) { } private void setLevel(final @NotNull SentryEvent event) { - final SentryLevel level = - PersistingScopeObserver.read(options, LEVEL_FILENAME, SentryLevel.class); + final SentryLevel level = readFromDisk(options, LEVEL_FILENAME, SentryLevel.class); if (event.getLevel() == null) { event.setLevel(level); } @@ -246,7 +247,7 @@ private void setLevel(final @NotNull SentryEvent event) { @SuppressWarnings("unchecked") private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Object hint) { final List fingerprint = - (List) PersistingScopeObserver.read(options, FINGERPRINT_FILENAME, List.class); + (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); if (event.getFingerprints() == null) { event.setFingerprints(fingerprint); } @@ -262,16 +263,14 @@ private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Ob } private void setTransaction(final @NotNull SentryEvent event) { - final String transaction = - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String.class); + final String transaction = readFromDisk(options, TRANSACTION_FILENAME, String.class); if (event.getTransaction() == null) { event.setTransaction(transaction); } } private void setContexts(final @NotNull SentryBaseEvent event) { - final Contexts persistedContexts = - PersistingScopeObserver.read(options, CONTEXTS_FILENAME, Contexts.class); + final Contexts persistedContexts = readFromDisk(options, CONTEXTS_FILENAME, Contexts.class); if (persistedContexts == null) { return; } @@ -291,7 +290,7 @@ private void setContexts(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setExtras(final @NotNull SentryBaseEvent event) { final Map extras = - (Map) PersistingScopeObserver.read(options, EXTRAS_FILENAME, Map.class); + (Map) readFromDisk(options, EXTRAS_FILENAME, Map.class); if (extras == null) { return; } @@ -309,14 +308,12 @@ private void setExtras(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { final List breadcrumbs = - (List) - PersistingScopeObserver.read( - options, BREADCRUMBS_FILENAME, List.class, new Breadcrumb.Deserializer()); + (List) readFromDisk(options, BREADCRUMBS_FILENAME, List.class); if (breadcrumbs == null) { return; } if (event.getBreadcrumbs() == null) { - event.setBreadcrumbs(new ArrayList<>(breadcrumbs)); + event.setBreadcrumbs(breadcrumbs); } else { event.getBreadcrumbs().addAll(breadcrumbs); } @@ -326,7 +323,7 @@ private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { private void setScopeTags(final @NotNull SentryBaseEvent event) { final Map tags = (Map) - PersistingScopeObserver.read(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); + readFromDisk(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); if (tags == null) { return; } @@ -343,19 +340,29 @@ private void setScopeTags(final @NotNull SentryBaseEvent event) { private void setUser(final @NotNull SentryBaseEvent event) { if (event.getUser() == null) { - final User user = PersistingScopeObserver.read(options, USER_FILENAME, User.class); + final User user = readFromDisk(options, USER_FILENAME, User.class); event.setUser(user); } } private void setRequest(final @NotNull SentryBaseEvent event) { if (event.getRequest() == null) { - final Request request = - PersistingScopeObserver.read(options, REQUEST_FILENAME, Request.class); + final Request request = readFromDisk(options, REQUEST_FILENAME, Request.class); event.setRequest(request); } } + private @Nullable T readFromDisk( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + if (persistingScopeObserver == null) { + return null; + } + + return persistingScopeObserver.read(options, fileName, clazz); + } + // endregion // region options persisted values diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index f234f7a6402..31d1547ca49 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -20,6 +20,7 @@ import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -55,6 +56,7 @@ class AndroidOptionsInitializerTest { configureContext: Context.() -> Unit = {}, assets: AssetManager? = null ) { + sentryOptions.executorService = ImmediateExecutorService() mockContext = if (metadata != null) { ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext), @@ -686,9 +688,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `PersistingScopeObserver is not set to options, if scope persistence is disabled`() { + fun `PersistingScopeObserver is no-op, if scope persistence is disabled`() { fixture.initSut(configureOptions = { isEnableScopePersistence = false }) - assertTrue { fixture.sentryOptions.scopeObservers.none { it is PersistingScopeObserver } } + fixture.sentryOptions.findPersistingScopeObserver()?.setTags(mapOf("key" to "value")) + assertFalse(File(AndroidOptionsInitializer.getCacheDir(fixture.context), PersistingScopeObserver.SCOPE_CACHE).exists()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 6065e81e086..6d2d005eaab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -35,6 +35,7 @@ import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.PersistingScopeObserver.USER_FILENAME +import io.sentry.cache.tape.QueueFile import io.sentry.hints.AbnormalExit import io.sentry.hints.Backfillable import io.sentry.protocol.Browser @@ -61,6 +62,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowBuild +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -98,6 +100,7 @@ class AnrV2EventProcessorTest { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" options.isSendDefaultPii = isSendDefaultPii + options.addScopeObserver(PersistingScopeObserver(options)) whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -147,7 +150,16 @@ class AnrV2EventProcessorTest { fun persistScope(filename: String, entity: T) { val dir = File(options.cacheDirPath, SCOPE_CACHE).also { it.mkdirs() } val file = File(dir, filename) - options.serializer.serialize(entity, file.writer()) + if (filename == BREADCRUMBS_FILENAME) { + val queueFile = QueueFile.Builder(file).build() + (entity as List).forEach { crumb -> + val baos = ByteArrayOutputStream() + options.serializer.serialize(crumb, baos.writer()) + queueFile.add(baos.toByteArray()) + } + } else { + options.serializer.serialize(entity, file.writer()) + } } fun persistOptions(filename: String, entity: T) { @@ -621,7 +633,7 @@ class AnrV2EventProcessorTest { val processed = processor.process(SentryEvent(), hint)!! assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) - assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + assertEquals(replayId1.toString(), fixture.options.findPersistingScopeObserver()?.read(fixture.options, REPLAY_FILENAME, String::class.java)) } private fun processEvent( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index a4608582f78..9645bb0b2d5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -11,6 +11,7 @@ import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger import io.sentry.ISentryClient @@ -36,10 +37,13 @@ import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.cache.tape.QueueFile +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils import org.awaitility.kotlin.await @@ -61,6 +65,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Files import java.util.concurrent.TimeUnit @@ -413,27 +418,31 @@ class SentryAndroidTest { assertEquals("Debug!", event.breadcrumbs!![0].message) assertEquals("staging", event.environment) assertEquals("io.sentry.sample@2.0.0", event.release) + assertEquals("afcb46b1140ade5187c4bbb5daa804df", event.contexts[Contexts.REPLAY_ID]) asserted.set(true) null } // have to do it after the cacheDir is set to options, because it adds a dsn hash after prefillOptionsCache(it.cacheDirPath!!) - prefillScopeCache(it.cacheDirPath!!) + prefillScopeCache(it, it.cacheDirPath!!) it.release = "io.sentry.sample@1.1.0+220" it.environment = "debug" - // this is necessary to delay the AnrV2Integration processing to execute the configure - // scope block below (otherwise it won't be possible as hub is no-op before .init) - it.executorService.submit { - Sentry.configureScope { scope -> - // make sure the scope values changed to test that we're still using previously - // persisted values for the old ANR events - assertEquals("TestActivity", scope.transactionName) - } - } options = it } + options.executorService.submit { + // verify we reset the persisted scope values after the init bg tasks have run to ensure + // clean state for a new process. + assertEquals( + emptyList(), + options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java) + ) + assertEquals( + SentryId.EMPTY_ID.toString(), + options.findPersistingScopeObserver()?.read(options, REPLAY_FILENAME, String::class.java) + ) + } Sentry.configureScope { it.setTransaction("TestActivity") it.addBreadcrumb(Breadcrumb.error("Error!")) @@ -447,7 +456,7 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) + options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java) ) assertEquals( "io.sentry.sample@1.1.0+220", @@ -528,19 +537,22 @@ class SentryAndroidTest { assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) } - private fun prefillScopeCache(cacheDir: String) { + private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } - File(scopeDir, BREADCRUMBS_FILENAME).writeText( - """ - [{ - "timestamp": "2009-11-16T01:08:47.000Z", - "message": "Debug!", - "type": "debug", - "level": "debug" - }] - """.trimIndent() + val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build() + val baos = ByteArrayOutputStream() + options.serializer.serialize( + Breadcrumb(DateUtils.getDateTime("2009-11-16T01:08:47.000Z")).apply { + message = "Debug!" + type = "debug" + level = DEBUG + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(scopeDir, TRANSACTION_FILENAME).writeText("\"MainActivity\"") + File(scopeDir, REPLAY_FILENAME).writeText("\"afcb46b1140ade5187c4bbb5daa804df\"") + File(options.getCacheDirPath(), "replay_afcb46b1140ade5187c4bbb5daa804df").mkdirs() } private fun prefillOptionsCache(cacheDir: String) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 655b3ca354b..8c572c003c1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -37,7 +37,6 @@ import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable @@ -412,7 +411,8 @@ public class ReplayIntegration( // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { - val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + val persistingScopeObserver = options.findPersistingScopeObserver() + val previousReplayIdString = persistingScopeObserver?.read(options, REPLAY_FILENAME, String::class.java) ?: run { cleanupReplays() return@submitSafely } @@ -425,7 +425,9 @@ public class ReplayIntegration( cleanupReplays() return@submitSafely } - val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + + @Suppress("UNCHECKED_CAST") + val breadcrumbs = persistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java) as? List val segment = CaptureStrategy.createSegment( hub = hub, options = options, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 353b11d8f66..93632d2df7d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -32,6 +32,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.tape.QueueFile import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent @@ -59,6 +60,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -456,6 +458,7 @@ class ReplayIntegrationTest { val oldReplayId = SentryId() fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) val oldReplay = File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } @@ -472,17 +475,18 @@ class ReplayIntegrationTest { it.writeText("\"$oldReplayId\"") } val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + val queueFile = QueueFile.Builder(breadcrumbsFile).build() + val baos = ByteArrayOutputStream() fixture.options.serializer.serialize( - listOf( - Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { - category = "navigation" - type = "navigation" - setData("from", "from") - setData("to", "to") - } - ), - breadcrumbsFile.writer() + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(oldReplay, ONGOING_SEGMENT).also { it.writeText( """ diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b7cb1adfc7e..d26d3474911 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2365,6 +2365,7 @@ public class io/sentry/SentryOptions { public fun addScopeObserver (Lio/sentry/IScopeObserver;)V public fun addTracingOrigin (Ljava/lang/String;)V public static fun empty ()Lio/sentry/SentryOptions; + public fun findPersistingScopeObserver ()Lio/sentry/cache/PersistingScopeObserver; public fun getBackpressureMonitor ()Lio/sentry/backpressure/IBackpressureMonitor; public fun getBeforeBreadcrumb ()Lio/sentry/SentryOptions$BeforeBreadcrumbCallback; public fun getBeforeEmitMetricCallback ()Lio/sentry/SentryOptions$BeforeEmitMetricCallback; @@ -3395,8 +3396,9 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static final field TRANSACTION_FILENAME Ljava/lang/String; public static final field USER_FILENAME Ljava/lang/String; public fun (Lio/sentry/SentryOptions;)V - public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; - public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; + public fun resetCache ()V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V public fun setExtras (Ljava/util/Map;)V @@ -3411,6 +3413,50 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;)V } +public abstract class io/sentry/cache/tape/ObjectQueue : java/io/Closeable, java/lang/Iterable { + public fun ()V + public abstract fun add (Ljava/lang/Object;)V + public fun asList ()Ljava/util/List; + public fun clear ()V + public static fun create (Lio/sentry/cache/tape/QueueFile;Lio/sentry/cache/tape/ObjectQueue$Converter;)Lio/sentry/cache/tape/ObjectQueue; + public static fun createEmpty ()Lio/sentry/cache/tape/ObjectQueue; + public abstract fun file ()Lio/sentry/cache/tape/QueueFile; + public fun isEmpty ()Z + public abstract fun peek ()Ljava/lang/Object; + public fun peek (I)Ljava/util/List; + public fun remove ()V + public abstract fun remove (I)V + public abstract fun size ()I +} + +public abstract interface class io/sentry/cache/tape/ObjectQueue$Converter { + public abstract fun from ([B)Ljava/lang/Object; + public abstract fun toStream (Ljava/lang/Object;Ljava/io/OutputStream;)V +} + +public final class io/sentry/cache/tape/QueueFile : java/io/Closeable, java/lang/Iterable { + public fun add ([B)V + public fun add ([BII)V + public fun clear ()V + public fun close ()V + public fun file ()Ljava/io/File; + public fun isAtFullCapacity ()Z + public fun isEmpty ()Z + public fun iterator ()Ljava/util/Iterator; + public fun peek ()[B + public fun remove ()V + public fun remove (I)V + public fun size ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/cache/tape/QueueFile$Builder { + public fun (Ljava/io/File;)V + public fun build ()Lio/sentry/cache/tape/QueueFile; + public fun size (I)Lio/sentry/cache/tape/QueueFile$Builder; + public fun zero (Z)Lio/sentry/cache/tape/QueueFile$Builder; +} + public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun (Ljava/util/Date;Ljava/util/List;)V public fun getDiscardedEvents ()Ljava/util/List; diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 08efc550d5a..726b6f2f2f7 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(Config.TestLibs.awaitility) testImplementation(Config.TestLibs.javaFaker) testImplementation(Config.TestLibs.msgpack) + testImplementation(Config.TestLibs.okio) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 4d363dc2612..49abb6b24cd 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -3,6 +3,7 @@ import io.sentry.backpressure.BackpressureMonitor; import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.config.PropertiesProviderFactory; import io.sentry.internal.debugmeta.NoOpDebugMetaLoader; import io.sentry.internal.debugmeta.ResourcesDebugMetaLoader; @@ -375,6 +376,16 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setReplayErrorSampleRate( options.getSessionReplay().getOnErrorSampleRate()); } + + // since it's a new SDK init we clean up persisted scope values before serializing + // new ones, so they are not making it to the new events if they were e.g. disabled + // (e.g. replayId) or are simply irrelevant (e.g. breadcrumbs). NOTE: this happens + // after the integrations relying on those values are done with processing them. + final @Nullable PersistingScopeObserver scopeCache = + options.findPersistingScopeObserver(); + if (scopeCache != null) { + scopeCache.resetCache(); + } }); } catch (Throwable e) { options.getLogger().log(SentryLevel.DEBUG, "Failed to notify options observers.", e); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 8e528ba5089..b238b057844 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -4,6 +4,7 @@ import io.sentry.backpressure.IBackpressureMonitor; import io.sentry.backpressure.NoOpBackpressureMonitor; import io.sentry.cache.IEnvelopeCache; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.clientreport.ClientReportRecorder; import io.sentry.clientreport.IClientReportRecorder; import io.sentry.clientreport.NoOpClientReportRecorder; @@ -1460,6 +1461,17 @@ public List getScopeObservers() { return observers; } + @ApiStatus.Internal + @Nullable + public PersistingScopeObserver findPersistingScopeObserver() { + for (final @NotNull IScopeObserver observer : observers) { + if (observer instanceof PersistingScopeObserver) { + return (PersistingScopeObserver) observer; + } + } + return null; + } + /** * Adds a SentryOptions observer * diff --git a/sentry/src/main/java/io/sentry/cache/CacheUtils.java b/sentry/src/main/java/io/sentry/cache/CacheUtils.java index 1eb5f7e19f4..eb9732a3439 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheUtils.java +++ b/sentry/src/main/java/io/sentry/cache/CacheUtils.java @@ -38,13 +38,6 @@ static void store( } final File file = new File(cacheDir, fileName); - if (file.exists()) { - options.getLogger().log(DEBUG, "Overwriting %s in scope cache", fileName); - if (!file.delete()) { - options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); - } - } - try (final OutputStream outputStream = new FileOutputStream(file); final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { options.getSerializer().serialize(entity, writer); @@ -64,11 +57,9 @@ static void delete( } final File file = new File(cacheDir, fileName); - if (file.exists()) { - options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); - if (!file.delete()) { - options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); - } + options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); + if (!file.delete()) { + options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); } } @@ -102,7 +93,7 @@ static void delete( return null; } - private static @Nullable File ensureCacheDir( + static @Nullable File ensureCacheDir( final @NotNull SentryOptions options, final @NotNull String cacheDirName) { final String cacheDir = options.getCacheDirPath(); if (cacheDir == null) { diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 908e2c66e41..c9356579c9c 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -1,18 +1,33 @@ package io.sentry.cache; import static io.sentry.SentryLevel.ERROR; +import static io.sentry.SentryLevel.INFO; +import static io.sentry.cache.CacheUtils.ensureCacheDir; import io.sentry.Breadcrumb; import io.sentry.IScope; -import io.sentry.JsonDeserializer; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanContext; +import io.sentry.cache.tape.ObjectQueue; +import io.sentry.cache.tape.QueueFile; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.LazyEvaluator; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; import java.util.Collection; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -20,6 +35,8 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { + private static final Charset UTF_8 = Charset.forName("UTF-8"); + public static final String SCOPE_CACHE = ".scope-cache"; public static final String USER_FILENAME = "user.json"; public static final String BREADCRUMBS_FILENAME = "breadcrumbs.json"; @@ -33,7 +50,60 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String TRACE_FILENAME = "trace.json"; public static final String REPLAY_FILENAME = "replay.json"; - private final @NotNull SentryOptions options; + private @NotNull SentryOptions options; + private final @NotNull LazyEvaluator> breadcrumbsQueue = + new LazyEvaluator<>( + () -> { + final File cacheDir = ensureCacheDir(options, SCOPE_CACHE); + if (cacheDir == null) { + options.getLogger().log(INFO, "Cache dir is not set, cannot store in scope cache"); + return ObjectQueue.createEmpty(); + } + + QueueFile queueFile = null; + final File file = new File(cacheDir, BREADCRUMBS_FILENAME); + try { + try { + queueFile = new QueueFile.Builder(file).size(options.getMaxBreadcrumbs()).build(); + } catch (IOException e) { + // if file is corrupted we simply delete it and try to create it again. We accept + // the trade + // off of losing breadcrumbs for ANRs that happened right before the app has + // received an + // update where the new format was introduced + file.delete(); + + queueFile = new QueueFile.Builder(file).size(options.getMaxBreadcrumbs()).build(); + } + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to create breadcrumbs queue", e); + return ObjectQueue.createEmpty(); + } + return ObjectQueue.create( + queueFile, + new ObjectQueue.Converter() { + @Override + @Nullable + public Breadcrumb from(byte[] source) { + try (final Reader reader = + new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(source), UTF_8))) { + return options.getSerializer().deserialize(reader, Breadcrumb.class); + } catch (Throwable e) { + options.getLogger().log(ERROR, e, "Error reading entity from scope cache"); + } + return null; + } + + @Override + public void toStream(Breadcrumb value, OutputStream sink) throws IOException { + try (final Writer writer = + new BufferedWriter(new OutputStreamWriter(sink, UTF_8))) { + options.getSerializer().serialize(value, writer); + } + } + }); + }); public PersistingScopeObserver(final @NotNull SentryOptions options) { this.options = options; @@ -51,9 +121,32 @@ public void setUser(final @Nullable User user) { }); } + @Override + public void addBreadcrumb(@NotNull Breadcrumb crumb) { + serializeToDisk( + () -> { + try { + breadcrumbsQueue.getValue().add(crumb); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to add breadcrumb to file queue", e); + } + }); + } + @Override public void setBreadcrumbs(@NotNull Collection breadcrumbs) { - serializeToDisk(() -> store(breadcrumbs, BREADCRUMBS_FILENAME)); + if (breadcrumbs.isEmpty()) { + // we only clear the queue if the new collection is empty (someone called clearBreadcrumbs) + // If it's not empty, we'd add breadcrumbs one-by-one in the method above + serializeToDisk( + () -> { + try { + breadcrumbsQueue.getValue().clear(); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to clear breadcrumbs from file queue", e); + } + }); + } } @Override @@ -133,9 +226,16 @@ public void setReplayId(@NotNull SentryId replayId) { @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { + if (!options.isEnableScopePersistence()) { + return; + } if (Thread.currentThread().getName().contains("SentryExecutor")) { // we're already on the sentry executor thread, so we can just execute it directly - task.run(); + try { + task.run(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task failed", e); + } return; } @@ -170,18 +270,42 @@ public static void store( CacheUtils.store(options, entity, SCOPE_CACHE, fileName); } - public static @Nullable T read( + public @Nullable T read( final @NotNull SentryOptions options, final @NotNull String fileName, final @NotNull Class clazz) { - return read(options, fileName, clazz, null); + if (fileName.equals(BREADCRUMBS_FILENAME)) { + try { + return clazz.cast(breadcrumbsQueue.getValue().asList()); + } catch (IOException e) { + options.getLogger().log(ERROR, "Unable to read serialized breadcrumbs from QueueFile"); + return null; + } + } + return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, null); } - public static @Nullable T read( - final @NotNull SentryOptions options, - final @NotNull String fileName, - final @NotNull Class clazz, - final @Nullable JsonDeserializer elementDeserializer) { - return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, elementDeserializer); + /** + * Resets the scope cache by deleting the files and/or clearing the QueueFiles. Note: this does + * I/O and should be called from a background thread. + */ + public void resetCache() { + // since it keeps a reference to the file and we cannot delete it, breadcrumbs we just clear + try { + breadcrumbsQueue.getValue().clear(); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to clear breadcrumbs from file queue", e); + } + + // the rest we can safely delete + delete(USER_FILENAME); + delete(LEVEL_FILENAME); + delete(REQUEST_FILENAME); + delete(FINGERPRINT_FILENAME); + delete(CONTEXTS_FILENAME); + delete(EXTRAS_FILENAME); + delete(TAGS_FILENAME); + delete(TRACE_FILENAME); + delete(TRANSACTION_FILENAME); } } diff --git a/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java new file mode 100644 index 00000000000..2aa41c9b791 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java @@ -0,0 +1,52 @@ +package io.sentry.cache.tape; + +import java.io.IOException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class EmptyObjectQueue extends ObjectQueue { + @Override + public @Nullable QueueFile file() { + return null; + } + + @Override + public int size() { + return 0; + } + + @Override + public void add(T entry) throws IOException {} + + @Override + public @Nullable T peek() throws IOException { + return null; + } + + @Override + public void remove(int n) throws IOException {} + + @Override + public void close() throws IOException {} + + @NotNull + @Override + public Iterator iterator() { + return new EmptyIterator<>(); + } + + private static final class EmptyIterator implements Iterator { + + @Override + public boolean hasNext() { + return false; + } + + @Override + public T next() { + throw new NoSuchElementException("No elements in EmptyIterator!"); + } + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java new file mode 100644 index 00000000000..8ed9cef56e1 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java @@ -0,0 +1,148 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Iterator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class FileObjectQueue extends ObjectQueue { + /** Backing storage implementation. */ + private final QueueFile queueFile; + /** Reusable byte output buffer. */ + private final DirectByteArrayOutputStream bytes = new DirectByteArrayOutputStream(); + + final Converter converter; + + FileObjectQueue(QueueFile queueFile, Converter converter) { + this.queueFile = queueFile; + this.converter = converter; + } + + @Override + public @NotNull QueueFile file() { + return queueFile; + } + + @Override + public int size() { + return queueFile.size(); + } + + @Override + public boolean isEmpty() { + return queueFile.isEmpty(); + } + + @Override + public void add(T entry) throws IOException { + bytes.reset(); + converter.toStream(entry, bytes); + queueFile.add(bytes.getArray(), 0, bytes.size()); + } + + @Override + public @Nullable T peek() throws IOException { + byte[] bytes = queueFile.peek(); + if (bytes == null) return null; + return converter.from(bytes); + } + + @Override + public void remove() throws IOException { + queueFile.remove(); + } + + @Override + public void remove(int n) throws IOException { + queueFile.remove(n); + } + + @Override + public void clear() throws IOException { + queueFile.clear(); + } + + @Override + public void close() throws IOException { + queueFile.close(); + } + + /** + * Returns an iterator over entries in this queue. + * + *

The iterator disallows modifications to the queue during iteration. Removing entries from + * the head of the queue is permitted during iteration using {@link Iterator#remove()}. + * + *

The iterator may throw an unchecked {@link IOException} during {@link Iterator#next()} or + * {@link Iterator#remove()}. + */ + @Override + public Iterator iterator() { + return new QueueFileIterator(queueFile.iterator()); + } + + @Override + public String toString() { + return "FileObjectQueue{" + "queueFile=" + queueFile + '}'; + } + + private final class QueueFileIterator implements Iterator { + final Iterator iterator; + + QueueFileIterator(Iterator iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + @Nullable + public T next() { + byte[] data = iterator.next(); + try { + return converter.from(data); + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } + } + + @Override + public void remove() { + iterator.remove(); + } + } + + /** Enables direct access to the internal array. Avoids unnecessary copying. */ + private static final class DirectByteArrayOutputStream extends ByteArrayOutputStream { + DirectByteArrayOutputStream() {} + + /** + * Gets a reference to the internal byte array. The {@link #size()} method indicates how many + * bytes contain actual data added since the last {@link #reset()} call. + */ + byte[] getArray() { + return buf; + } + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java new file mode 100644 index 00000000000..c92cad36218 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java @@ -0,0 +1,108 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** A queue of objects. */ +@ApiStatus.Internal +public abstract class ObjectQueue implements Iterable, Closeable { + /** A queue for objects that are atomically and durably serialized to {@code file}. */ + public static ObjectQueue create(QueueFile qf, Converter converter) { + return new FileObjectQueue<>(qf, converter); + } + + /** An empty queue for objects that is essentially a no-op. */ + public static ObjectQueue createEmpty() { + return new EmptyObjectQueue<>(); + } + + /** The underlying {@link QueueFile} backing this queue, or null if it's only in memory. */ + public abstract @Nullable QueueFile file(); + + /** Returns the number of entries in the queue. */ + public abstract int size(); + + /** Returns {@code true} if this queue contains no entries. */ + public boolean isEmpty() { + return size() == 0; + } + + /** Enqueues an entry that can be processed at any time. */ + public abstract void add(T entry) throws IOException; + + /** + * Returns the head of the queue, or {@code null} if the queue is empty. Does not modify the + * queue. + */ + public abstract @Nullable T peek() throws IOException; + + /** + * Reads up to {@code max} entries from the head of the queue without removing the entries. If the + * queue's {@link #size()} is less than {@code max} then only {@link #size()} entries are read. + */ + public List peek(int max) throws IOException { + int end = Math.min(max, size()); + List subList = new ArrayList(end); + Iterator iterator = iterator(); + for (int i = 0; i < end; i++) { + subList.add(iterator.next()); + } + return Collections.unmodifiableList(subList); + } + + /** Returns the entries in the queue as an unmodifiable {@link List}. */ + public List asList() throws IOException { + return peek(size()); + } + + /** Removes the head of the queue. */ + public void remove() throws IOException { + remove(1); + } + + /** Removes {@code n} entries from the head of the queue. */ + public abstract void remove(int n) throws IOException; + + /** Clears this queue. Also truncates the file to the initial size. */ + public void clear() throws IOException { + remove(size()); + } + + /** + * Convert a byte stream to and from a concrete type. + * + * @param Object type. + */ + public interface Converter { + /** Converts bytes to an object. */ + @Nullable + T from(byte[] source) throws IOException; + + /** Converts {@code value} to bytes written to the specified stream. */ + void toStream(T value, OutputStream sink) throws IOException; + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java b/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java new file mode 100644 index 00000000000..bc2ed568267 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java @@ -0,0 +1,817 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import static java.lang.Math.min; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.util.ConcurrentModificationException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * A reliable, efficient, file-based, FIFO queue. Additions and removals are O(1). All operations + * are atomic. Writes are synchronous; data will be written to disk before an operation returns. The + * underlying file is structured to survive process and even system crashes. If an I/O exception is + * thrown during a mutating change, the change is aborted. It is safe to continue to use a {@code + * QueueFile} instance after an exception. + * + *

Note that this implementation is not synchronized. + * + *

In a traditional queue, the remove operation returns an element. In this queue, {@link #peek} + * and {@link #remove} are used in conjunction. Use {@code peek} to retrieve the first element, and + * then {@code remove} to remove it after successful processing. If the system crashes after {@code + * peek} and during processing, the element will remain in the queue, to be processed when the + * system restarts. + * + *

NOTE: The current implementation is built for file systems that support + * atomic segment writes (like YAFFS). Most conventional file systems don't support this; if the + * power goes out while writing a segment, the segment will contain garbage and the file will be + * corrupt. We'll add journaling support so this class can be used with more file systems later. + * + *

Construct instances with {@link Builder}. + * + * @author Bob Lee (bob@squareup.com) + */ +@ApiStatus.Internal +public final class QueueFile implements Closeable, Iterable { + /** Leading bit set to 1 indicating a versioned header and the version of 1. */ + private static final int VERSIONED_HEADER = 0x80000001; + + /** Initial file size in bytes. */ + static final int INITIAL_LENGTH = 4096; // one file system block + + /** A block of nothing to write over old data. */ + private static final byte[] ZEROES = new byte[INITIAL_LENGTH]; + + /** + * The underlying file. Uses a ring buffer to store entries. Designed so that a modification isn't + * committed or visible until we write the header. The header is much smaller than a segment. So + * long as the underlying file system supports atomic segment writes, changes to the queue are + * atomic. Storing the file length ensures we can recover from a failed expansion (i.e. if setting + * the file length succeeds but the process dies before the data can be copied). + * + *

This implementation supports two versions of the on-disk format. + * + *

+   * Format:
+   *   16-32 bytes      Header
+   *   ...              Data
+   *
+   * Header (32 bytes):
+   *   1 bit            Versioned indicator [0 = legacy (see "Legacy Header"), 1 = versioned]
+   *   31 bits          Version, always 1
+   *   8 bytes          File length
+   *   4 bytes          Element count
+   *   8 bytes          Head element position
+   *   8 bytes          Tail element position
+   *
+   * Element:
+   *   4 bytes          Data length
+   *   ...              Data
+   * 
+ */ + RandomAccessFile raf; + + /** Keep file around for error reporting. */ + final File file; + + /** The header length in bytes: 16 or 32. */ + final int headerLength = 32; + + /** Cached file length. Always a power of 2. */ + long fileLength; + + /** Number of elements. */ + int elementCount; + + /** Pointer to first (or eldest) element. */ + Element first; + + /** Pointer to last (or newest) element. */ + private Element last; + + /** In-memory buffer. Big enough to hold the header. */ + private final byte[] buffer = new byte[32]; + + /** + * The number of times this file has been structurally modified — it is incremented during {@link + * #remove(int)} and {@link #add(byte[], int, int)}. Used by {@link ElementIterator} to guard + * against concurrent modification. + */ + int modCount = 0; + + /** When true, removing an element will also overwrite data with zero bytes. */ + private final boolean zero; + + /** A number of elements at which this queue will wrap around (ring buffer). */ + private final int maxElements; + + boolean closed; + + static RandomAccessFile initializeFromFile(File file) throws IOException { + if (!file.exists()) { + // Use a temp file so we don't leave a partially-initialized file. + File tempFile = new File(file.getPath() + ".tmp"); + RandomAccessFile raf = open(tempFile); + try { + raf.setLength(INITIAL_LENGTH); + raf.seek(0); + raf.writeInt(VERSIONED_HEADER); + raf.writeLong(INITIAL_LENGTH); + } finally { + raf.close(); + } + + // A rename is atomic. + if (!tempFile.renameTo(file)) { + throw new IOException("Rename failed!"); + } + } + + return open(file); + } + + /** Opens a random access file that writes synchronously. */ + private static RandomAccessFile open(File file) throws FileNotFoundException { + return new RandomAccessFile(file, "rwd"); + } + + QueueFile(File file, RandomAccessFile raf, boolean zero, int maxElements) throws IOException { + this.file = file; + this.raf = raf; + this.zero = zero; + this.maxElements = maxElements; + + readInitialData(); + } + + private void readInitialData() throws IOException { + raf.seek(0); + raf.readFully(buffer); + + long firstOffset; + long lastOffset; + + fileLength = readLong(buffer, 4); + elementCount = readInt(buffer, 12); + firstOffset = readLong(buffer, 16); + lastOffset = readLong(buffer, 24); + + if (fileLength > raf.length()) { + throw new IOException( + "File is truncated. Expected length: " + fileLength + ", Actual length: " + raf.length()); + } else if (fileLength <= headerLength) { + throw new IOException( + "File is corrupt; length stored in header (" + fileLength + ") is invalid."); + } + + first = readElement(firstOffset); + last = readElement(lastOffset); + } + + private void resetFile() throws IOException { + raf.close(); + file.delete(); + raf = initializeFromFile(file); + readInitialData(); + } + + /** + * Stores an {@code int} in the {@code byte[]}. The behavior is equivalent to calling {@link + * RandomAccessFile#writeInt}. + */ + private static void writeInt(byte[] buffer, int offset, int value) { + buffer[offset] = (byte) (value >> 24); + buffer[offset + 1] = (byte) (value >> 16); + buffer[offset + 2] = (byte) (value >> 8); + buffer[offset + 3] = (byte) value; + } + + /** Reads an {@code int} from the {@code byte[]}. */ + private static int readInt(byte[] buffer, int offset) { + return ((buffer[offset] & 0xff) << 24) + + ((buffer[offset + 1] & 0xff) << 16) + + ((buffer[offset + 2] & 0xff) << 8) + + (buffer[offset + 3] & 0xff); + } + + /** + * Stores an {@code long} in the {@code byte[]}. The behavior is equivalent to calling {@link + * RandomAccessFile#writeLong}. + */ + private static void writeLong(byte[] buffer, int offset, long value) { + buffer[offset] = (byte) (value >> 56); + buffer[offset + 1] = (byte) (value >> 48); + buffer[offset + 2] = (byte) (value >> 40); + buffer[offset + 3] = (byte) (value >> 32); + buffer[offset + 4] = (byte) (value >> 24); + buffer[offset + 5] = (byte) (value >> 16); + buffer[offset + 6] = (byte) (value >> 8); + buffer[offset + 7] = (byte) value; + } + + /** Reads an {@code long} from the {@code byte[]}. */ + private static long readLong(byte[] buffer, int offset) { + return ((buffer[offset] & 0xffL) << 56) + + ((buffer[offset + 1] & 0xffL) << 48) + + ((buffer[offset + 2] & 0xffL) << 40) + + ((buffer[offset + 3] & 0xffL) << 32) + + ((buffer[offset + 4] & 0xffL) << 24) + + ((buffer[offset + 5] & 0xffL) << 16) + + ((buffer[offset + 6] & 0xffL) << 8) + + (buffer[offset + 7] & 0xffL); + } + + /** + * Writes header atomically. The arguments contain the updated values. The class member fields + * should not have changed yet. This only updates the state in the file. It's up to the caller to + * update the class member variables *after* this call succeeds. Assumes segment writes are atomic + * in the underlying file system. + */ + private void writeHeader(long fileLength, int elementCount, long firstPosition, long lastPosition) + throws IOException { + raf.seek(0); + + writeInt(buffer, 0, VERSIONED_HEADER); + writeLong(buffer, 4, fileLength); + writeInt(buffer, 12, elementCount); + writeLong(buffer, 16, firstPosition); + writeLong(buffer, 24, lastPosition); + raf.write(buffer, 0, 32); + } + + Element readElement(long position) throws IOException { + if (position == 0) return Element.NULL; + boolean success = ringRead(position, buffer, 0, Element.HEADER_LENGTH); + if (!success) { + return Element.NULL; + } + int length = readInt(buffer, 0); + return new Element(position, length); + } + + /** Wraps the position if it exceeds the end of the file. */ + long wrapPosition(long position) { + return position < fileLength ? position : headerLength + position - fileLength; + } + + /** + * Writes count bytes from buffer to position in file. Automatically wraps write if position is + * past the end of the file or if buffer overlaps it. + * + * @param position in file to write to + * @param buffer to write from + * @param count # of bytes to write + */ + private void ringWrite(long position, byte[] buffer, int offset, int count) throws IOException { + position = wrapPosition(position); + if (position + count <= fileLength) { + raf.seek(position); + raf.write(buffer, offset, count); + } else { + // The write overlaps the EOF. + // # of bytes to write before the EOF. Guaranteed to be less than Integer.MAX_VALUE. + int beforeEof = (int) (fileLength - position); + raf.seek(position); + raf.write(buffer, offset, beforeEof); + raf.seek(headerLength); + raf.write(buffer, offset + beforeEof, count - beforeEof); + } + } + + private void ringErase(long position, long length) throws IOException { + while (length > 0) { + int chunk = (int) min(length, ZEROES.length); + ringWrite(position, ZEROES, 0, chunk); + length -= chunk; + position += chunk; + } + } + + /** + * Reads count bytes into buffer from file. Wraps if necessary. + * + * @param position in file to read from + * @param buffer to read into + * @param count # of bytes to read + * @return true if the read was successful, false if the file is corrupt + */ + boolean ringRead(long position, byte[] buffer, int offset, int count) throws IOException { + try { + position = wrapPosition(position); + if (position + count <= fileLength) { + raf.seek(position); + raf.readFully(buffer, offset, count); + } else { + // The read overlaps the EOF. + // # of bytes to read before the EOF. Guaranteed to be less than Integer.MAX_VALUE. + int beforeEof = (int) (fileLength - position); + raf.seek(position); + raf.readFully(buffer, offset, beforeEof); + raf.seek(headerLength); + raf.readFully(buffer, offset + beforeEof, count - beforeEof); + } + return true; + } catch (EOFException e) { + // since EOFException inherits from IOException, we need to catch it explicitly + // and reset the file + resetFile(); + } catch (IOException e) { + throw e; + } catch (Throwable e) { + // most likely the file is corrupt, so we delete it and recreate, accepting data loss + resetFile(); + } + return false; + } + + /** + * Adds an element to the end of the queue. + * + * @param data to copy bytes from + */ + public void add(byte[] data) throws IOException { + add(data, 0, data.length); + } + + /** + * Adds an element to the end of the queue. + * + * @param data to copy bytes from + * @param offset to start from in buffer + * @param count number of bytes to copy + * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code count < 0}, or if {@code + * offset + count} is bigger than the length of {@code buffer}. + */ + public void add(byte[] data, int offset, int count) throws IOException { + if (data == null) { + throw new NullPointerException("data == null"); + } + if ((offset | count) < 0 || count > data.length - offset) { + throw new IndexOutOfBoundsException(); + } + if (closed) throw new IllegalStateException("closed"); + + // If the queue is at full capacity, remove the oldest element first. + if (isAtFullCapacity()) { + remove(); + } + + expandIfNecessary(count); + + // Insert a new element after the current last element. + boolean wasEmpty = isEmpty(); + long position = + wasEmpty ? headerLength : wrapPosition(last.position + Element.HEADER_LENGTH + last.length); + Element newLast = new Element(position, count); + + // Write length. + writeInt(buffer, 0, count); + ringWrite(newLast.position, buffer, 0, Element.HEADER_LENGTH); + + // Write data. + ringWrite(newLast.position + Element.HEADER_LENGTH, data, offset, count); + + // Commit the addition. If wasEmpty, first == last. + long firstPosition = wasEmpty ? newLast.position : first.position; + writeHeader(fileLength, elementCount + 1, firstPosition, newLast.position); + last = newLast; + elementCount++; + modCount++; + if (wasEmpty) first = last; // first element + } + + private long usedBytes() { + if (elementCount == 0) return headerLength; + + if (last.position >= first.position) { + // Contiguous queue. + return (last.position - first.position) // all but last entry + + Element.HEADER_LENGTH + + last.length // last entry + + headerLength; + } else { + // tail < head. The queue wraps. + return last.position // buffer front + header + + Element.HEADER_LENGTH + + last.length // last entry + + fileLength + - first.position; // buffer end + } + } + + private long remainingBytes() { + return fileLength - usedBytes(); + } + + /** Returns true if this queue contains no entries. */ + public boolean isEmpty() { + return elementCount == 0; + } + + /** + * If necessary, expands the file to accommodate an additional element of the given length. + * + * @param dataLength length of data being added + */ + private void expandIfNecessary(long dataLength) throws IOException { + long elementLength = Element.HEADER_LENGTH + dataLength; + long remainingBytes = remainingBytes(); + if (remainingBytes >= elementLength) return; + + // Expand. + long previousLength = fileLength; + long newLength; + // Double the length until we can fit the new data. + do { + remainingBytes += previousLength; + newLength = previousLength << 1; + previousLength = newLength; + } while (remainingBytes < elementLength); + + setLength(newLength); + + // Calculate the position of the tail end of the data in the ring buffer + long endOfLastElement = wrapPosition(last.position + Element.HEADER_LENGTH + last.length); + long count = 0; + // If the buffer is split, we need to make it contiguous + if (endOfLastElement <= first.position) { + FileChannel channel = raf.getChannel(); + channel.position(fileLength); // destination position + count = endOfLastElement - headerLength; + if (channel.transferTo(headerLength, count, channel) != count) { + throw new AssertionError("Copied insufficient number of bytes!"); + } + } + + // Commit the expansion. + if (last.position < first.position) { + long newLastPosition = fileLength + last.position - headerLength; + writeHeader(newLength, elementCount, first.position, newLastPosition); + last = new Element(newLastPosition, last.length); + } else { + writeHeader(newLength, elementCount, first.position, last.position); + } + + fileLength = newLength; + + if (zero) { + ringErase(headerLength, count); + } + } + + /** Sets the length of the file. */ + private void setLength(long newLength) throws IOException { + // Set new file length (considered metadata) and sync it to storage. + raf.setLength(newLength); + raf.getChannel().force(true); + } + + /** Reads the eldest element. Returns null if the queue is empty. */ + public @Nullable byte[] peek() throws IOException { + if (closed) throw new IllegalStateException("closed"); + if (isEmpty()) return null; + int length = first.length; + byte[] data = new byte[length]; + boolean success = ringRead(first.position + Element.HEADER_LENGTH, data, 0, length); + return success ? data : null; + } + + /** + * Returns an iterator over elements in this QueueFile. + * + *

The iterator disallows modifications to be made to the QueueFile during iteration. Removing + * elements from the head of the QueueFile is permitted during iteration using {@link + * Iterator#remove()}. + * + *

The iterator may throw an unchecked {@link IOException} during {@link Iterator#next()} or + * {@link Iterator#remove()}. + */ + @Override + public Iterator iterator() { + return new ElementIterator(); + } + + private final class ElementIterator implements Iterator { + /** Index of element to be returned by subsequent call to next. */ + int nextElementIndex = 0; + + /** Position of element to be returned by subsequent call to next. */ + private long nextElementPosition = first.position; + + /** + * The {@link #modCount} value that the iterator believes that the backing QueueFile should + * have. If this expectation is violated, the iterator has detected concurrent modification. + */ + int expectedModCount = modCount; + + ElementIterator() {} + + private void checkForComodification() { + if (modCount != expectedModCount) throw new ConcurrentModificationException(); + } + + @Override + public boolean hasNext() { + if (closed) throw new IllegalStateException("closed"); + checkForComodification(); + return nextElementIndex != elementCount; + } + + @Override + public byte[] next() { + if (closed) throw new IllegalStateException("closed"); + checkForComodification(); + if (isEmpty()) throw new NoSuchElementException(); + if (nextElementIndex >= elementCount) throw new NoSuchElementException(); + + try { + // Read the current element. + Element current = readElement(nextElementPosition); + byte[] buffer = new byte[current.length]; + nextElementPosition = wrapPosition(current.position + Element.HEADER_LENGTH); + boolean success = ringRead(nextElementPosition, buffer, 0, current.length); + if (!success) { + // make it run out of bounds immediately + nextElementIndex = elementCount; + return ZEROES; + } + + // Update the pointer to the next element. + nextElementPosition = + wrapPosition(current.position + Element.HEADER_LENGTH + current.length); + nextElementIndex++; + + // Return the read element. + return buffer; + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } catch (OutOfMemoryError e) { + // most likely the file is corrupted, so we delete it and recreate, accepting data loss + try { + resetFile(); + // make it run out of bounds immediately + nextElementIndex = elementCount; + } catch (IOException ex) { + throw QueueFile.getSneakyThrowable(ex); + } + return ZEROES; + } + } + + @Override + public void remove() { + checkForComodification(); + + if (isEmpty()) throw new NoSuchElementException(); + if (nextElementIndex != 1) { + throw new UnsupportedOperationException("Removal is only permitted from the head."); + } + + try { + QueueFile.this.remove(); + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } + + expectedModCount = modCount; + nextElementIndex--; + } + } + + /** Returns the number of elements in this queue. */ + public int size() { + return elementCount; + } + + /** + * Removes the eldest element. + * + * @throws NoSuchElementException if the queue is empty + */ + public void remove() throws IOException { + remove(1); + } + + /** + * Removes the eldest {@code n} elements. + * + * @throws NoSuchElementException if the queue is empty + */ + public void remove(int n) throws IOException { + if (n < 0) { + throw new IllegalArgumentException("Cannot remove negative (" + n + ") number of elements."); + } + if (n == 0) { + return; + } + if (n == elementCount) { + clear(); + return; + } + if (isEmpty()) { + throw new NoSuchElementException(); + } + if (n > elementCount) { + throw new IllegalArgumentException( + "Cannot remove more elements (" + n + ") than present in queue (" + elementCount + ")."); + } + + long eraseStartPosition = first.position; + long eraseTotalLength = 0; + + // Read the position and length of the new first element. + long newFirstPosition = first.position; + int newFirstLength = first.length; + for (int i = 0; i < n; i++) { + eraseTotalLength += Element.HEADER_LENGTH + newFirstLength; + newFirstPosition = wrapPosition(newFirstPosition + Element.HEADER_LENGTH + newFirstLength); + boolean success = ringRead(newFirstPosition, buffer, 0, Element.HEADER_LENGTH); + if (!success) { + return; + } + newFirstLength = readInt(buffer, 0); + } + + // Commit the header. + writeHeader(fileLength, elementCount - n, newFirstPosition, last.position); + elementCount -= n; + modCount++; + first = new Element(newFirstPosition, newFirstLength); + + if (zero) { + ringErase(eraseStartPosition, eraseTotalLength); + } + } + + /** Clears this queue. Truncates the file to the initial size. */ + public void clear() throws IOException { + if (closed) throw new IllegalStateException("closed"); + + // Commit the header. + writeHeader(INITIAL_LENGTH, 0, 0, 0); + + if (zero) { + // Zero out data. + raf.seek(headerLength); + raf.write(ZEROES, 0, INITIAL_LENGTH - headerLength); + } + + elementCount = 0; + first = Element.NULL; + last = Element.NULL; + if (fileLength > INITIAL_LENGTH) setLength(INITIAL_LENGTH); + fileLength = INITIAL_LENGTH; + modCount++; + } + + /** + * Returns {@code true} if the capacity limit of this queue has been reached, i.e. the number of + * elements stored in the queue equals its maximum size. + * + * @return {@code true} if the capacity limit has been reached, {@code false} otherwise + */ + public boolean isAtFullCapacity() { + if (maxElements == -1) { + // unspecified + return false; + } + return size() == maxElements; + } + + /** The underlying {@link File} backing this queue. */ + public File file() { + return file; + } + + @Override + public void close() throws IOException { + closed = true; + raf.close(); + } + + @Override + public String toString() { + return "QueueFile{" + + "file=" + + file + + ", zero=" + + zero + + ", length=" + + fileLength + + ", size=" + + elementCount + + ", first=" + + first + + ", last=" + + last + + '}'; + } + + /** A pointer to an element. */ + static final class Element { + static final Element NULL = new Element(0, 0); + + /** Length of element header in bytes. */ + static final int HEADER_LENGTH = 4; + + /** Position in file. */ + final long position; + + /** The length of the data. */ + final int length; + + /** + * Constructs a new element. + * + * @param position within file + * @param length of data + */ + Element(long position, int length) { + this.position = position; + this.length = length; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "[position=" + position + ", length=" + length + "]"; + } + } + + /** Fluent API for creating {@link QueueFile} instances. */ + public static final class Builder { + final File file; + boolean zero = true; + int size = -1; + + /** Start constructing a new queue backed by the given file. */ + public Builder(File file) { + if (file == null) { + throw new NullPointerException("file == null"); + } + this.file = file; + } + + /** When true, removing an element will also overwrite data with zero bytes. */ + public Builder zero(boolean zero) { + this.zero = zero; + return this; + } + + /** The maximum number of elements this queue can hold before wrapping around. */ + public Builder size(int size) { + this.size = size; + return this; + } + + /** + * Constructs a new queue backed by the given builder. Only one instance should access a given + * file at a time. + */ + public QueueFile build() throws IOException { + RandomAccessFile raf = initializeFromFile(file); + QueueFile qf = null; + try { + qf = new QueueFile(file, raf, zero, size); + return qf; + } finally { + if (qf == null) { + raf.close(); + } + } + } + } + + /** + * Use this to throw checked exceptions from iterator methods that do not declare that they throw + * checked exceptions. + */ + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + static T getSneakyThrowable(Throwable t) throws T { + throw (T) t; + } +} diff --git a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt index ba42810cd95..daf60e679d5 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt @@ -34,6 +34,16 @@ internal class CacheUtilsTest { ) assertEquals("\"Hallo!\"", file.readText()) + + // test overwrite + CacheUtils.store( + SentryOptions().apply { cacheDirPath = cacheDir }, + "Hallo 2!", + "stuff", + "test.json" + ) + + assertEquals("\"Hallo 2!\"", file.readText()) } @Test diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index e1927438e59..631e18cf1d5 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -2,7 +2,6 @@ package io.sentry.cache import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.JsonDeserializer import io.sentry.Scope import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -56,13 +55,12 @@ class DeletedEntityProvider(private val provider: (Scope) -> T?) { } @RunWith(Parameterized::class) -class PersistingScopeObserverTest( +class PersistingScopeObserverTest( private val entity: T, private val store: StoreScopeValue, private val filename: String, private val delete: DeleteScopeValue, - private val deletedEntity: DeletedEntityProvider, - private val elementDeserializer: JsonDeserializer? + private val deletedEntity: DeletedEntityProvider ) { @get:Rule @@ -89,19 +87,19 @@ class PersistingScopeObserverTest( val sut = fixture.getSut(tmpDir) store(entity, sut, fixture.scope) - val persisted = read() + val persisted = sut.read() assertEquals(entity, persisted) delete(sut, fixture.scope) - val persistedAfterDeletion = read() + val persistedAfterDeletion = sut.read() assertEquals(deletedEntity(fixture.scope), persistedAfterDeletion) } - private fun read(): T? = PersistingScopeObserver.read( + private fun PersistingScopeObserver.read(): Any? = read( fixture.options, filename, - entity!!::class.java, - elementDeserializer + // need to cast breadcrumbs to a regular List, not kotlin lists + if (entity!!::class.java.name.contains("List")) List::class.java else entity!!::class.java ) companion object { @@ -115,8 +113,7 @@ class PersistingScopeObserverTest( StoreScopeValue { user, _ -> setUser(user) }, USER_FILENAME, DeleteScopeValue { setUser(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun breadcrumbs(): Array = arrayOf( @@ -124,11 +121,29 @@ class PersistingScopeObserverTest( Breadcrumb.navigation("one", "two"), Breadcrumb.userInteraction("click", "viewId", "viewClass") ), - StoreScopeValue> { breadcrumbs, _ -> setBreadcrumbs(breadcrumbs) }, + StoreScopeValue> { breadcrumbs, _ -> + breadcrumbs.forEach { addBreadcrumb(it) } + }, + BREADCRUMBS_FILENAME, + DeleteScopeValue { setBreadcrumbs(emptyList()) }, + DeletedEntityProvider { emptyList() } + ) + + private fun legacyBreadcrumbs(): Array = arrayOf( + emptyList(), + StoreScopeValue> { _, scope -> + PersistingScopeObserver.store( + scope.options, + listOf( + Breadcrumb.navigation("one", "two"), + Breadcrumb.userInteraction("click", "viewId", "viewClass") + ), + BREADCRUMBS_FILENAME + ) + }, BREADCRUMBS_FILENAME, DeleteScopeValue { setBreadcrumbs(emptyList()) }, - DeletedEntityProvider { emptyList() }, - Breadcrumb.Deserializer() + DeletedEntityProvider { emptyList() } ) private fun tags(): Array = arrayOf( @@ -139,8 +154,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { tags, _ -> setTags(tags) }, TAGS_FILENAME, DeleteScopeValue { setTags(emptyMap()) }, - DeletedEntityProvider { emptyMap() }, - null + DeletedEntityProvider { emptyMap() } ) private fun extras(): Array = arrayOf( @@ -152,8 +166,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { extras, _ -> setExtras(extras) }, EXTRAS_FILENAME, DeleteScopeValue { setExtras(emptyMap()) }, - DeletedEntityProvider { emptyMap() }, - null + DeletedEntityProvider { emptyMap() } ) private fun request(): Array = arrayOf( @@ -168,8 +181,7 @@ class PersistingScopeObserverTest( StoreScopeValue { request, _ -> setRequest(request) }, REQUEST_FILENAME, DeleteScopeValue { setRequest(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun fingerprint(): Array = arrayOf( @@ -177,8 +189,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { fingerprint, _ -> setFingerprint(fingerprint) }, FINGERPRINT_FILENAME, DeleteScopeValue { setFingerprint(emptyList()) }, - DeletedEntityProvider { emptyList() }, - null + DeletedEntityProvider { emptyList() } ) private fun level(): Array = arrayOf( @@ -186,8 +197,7 @@ class PersistingScopeObserverTest( StoreScopeValue { level, _ -> setLevel(level) }, LEVEL_FILENAME, DeleteScopeValue { setLevel(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun transaction(): Array = arrayOf( @@ -195,8 +205,7 @@ class PersistingScopeObserverTest( StoreScopeValue { transaction, _ -> setTransaction(transaction) }, TRANSACTION_FILENAME, DeleteScopeValue { setTransaction(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun trace(): Array = arrayOf( @@ -204,8 +213,7 @@ class PersistingScopeObserverTest( StoreScopeValue { trace, scope -> setTrace(trace, scope) }, TRACE_FILENAME, DeleteScopeValue { scope -> setTrace(null, scope) }, - DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() }, - null + DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() } ) private fun contexts(): Array = arrayOf( @@ -269,8 +277,7 @@ class PersistingScopeObserverTest( StoreScopeValue { contexts, _ -> setContexts(contexts) }, CONTEXTS_FILENAME, DeleteScopeValue { setContexts(Contexts()) }, - DeletedEntityProvider { Contexts() }, - null + DeletedEntityProvider { Contexts() } ) private fun replayId(): Array = arrayOf( @@ -278,8 +285,7 @@ class PersistingScopeObserverTest( StoreScopeValue { replayId, _ -> setReplayId(SentryId(replayId)) }, REPLAY_FILENAME, DeleteScopeValue { setReplayId(SentryId.EMPTY_ID) }, - DeletedEntityProvider { SentryId.EMPTY_ID.toString() }, - null + DeletedEntityProvider { SentryId.EMPTY_ID.toString() } ) @JvmStatic @@ -288,6 +294,7 @@ class PersistingScopeObserverTest( return listOf( user(), breadcrumbs(), + legacyBreadcrumbs(), tags(), extras(), request(), diff --git a/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt new file mode 100644 index 00000000000..1e5e0b03a0b --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt @@ -0,0 +1,43 @@ +package io.sentry.cache.tape + +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.test.assertEquals + +class CorruptQueueFileTest { + + @get:Rule + val folder = TemporaryFolder() + private lateinit var file: File + + @Before + fun setUp() { + val parent = folder.root + file = File(parent, "queue-file") + } + + @Test + fun `does not fail to operate with a corrupt file`() { + val testFile = this::class.java.classLoader.getResource("corrupt_queue_file.txt")!! + Files.copy(Paths.get(testFile.toURI()), file.outputStream()) + + val queueFile = QueueFile.Builder(file).zero(true).build() + val iterator = queueFile.iterator() + while (iterator.hasNext()) { + iterator.next() + } + + queueFile.add("test".toByteArray()) + assertEquals(1, queueFile.size()) + + queueFile.peek() + + queueFile.remove() + assertEquals(0, queueFile.size()) + } +} diff --git a/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt b/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt new file mode 100644 index 00000000000..628db5d57bc --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt @@ -0,0 +1,252 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/test/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape + +import io.sentry.cache.tape.ObjectQueue.Converter +import io.sentry.cache.tape.QueueFile.Builder +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.OutputStream +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class ObjectQueueTest { + enum class QueueFactory { + FILE { + override fun create(queueFile: QueueFile, converter: Converter): ObjectQueue { + return ObjectQueue.create(queueFile, converter) + } + }; + + abstract fun create(queueFile: QueueFile, converter: Converter): ObjectQueue + } + + @get:Rule + val folder = TemporaryFolder() + private lateinit var queue: ObjectQueue + + @Before + fun setUp() { + val parent = folder.root + val file = File(parent, "object-queue") + val queueFile = Builder(file).build() + queue = QueueFactory.FILE.create(queueFile, StringConverter()) + + queue.add("one") + queue.add("two") + queue.add("three") + } + + @Test + fun size() { + assertEquals(queue.size(), 3) + } + + @Test + fun peek() { + assertEquals(queue.peek(), "one") + } + + @Test + fun peekMultiple() { + assertEquals(queue.peek(2), listOf("one", "two")) + } + + @Test + fun peekMaxCanExceedQueueDepth() { + assertEquals(queue.peek(6), listOf("one", "two", "three")) + } + + @Test + fun asList() { + assertEquals(queue.asList(), listOf("one", "two", "three")) + } + + @Test + fun remove() { + queue.remove() + + assertEquals(queue.asList(), listOf("two", "three")) + } + + @Test + fun removeMultiple() { + queue.remove(2) + + assertEquals(queue.asList(), listOf("three")) + } + + @Test + fun clear() { + queue.clear() + + assertEquals(queue.size(), 0) + } + + @Test + fun isEmpty() { + assertFalse(queue.isEmpty) + + queue.clear() + + assertTrue(queue.isEmpty) + } + + @Test + fun testIterator() { + val saw: MutableList = ArrayList() + for (pojo in queue) { + saw.add(pojo) + } + assertEquals(saw, listOf("one", "two", "three")) + } + + @Test + fun testIteratorNextThrowsWhenEmpty() { + queue.clear() + val iterator: Iterator = queue.iterator() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorNextThrowsWhenExhausted() { + val iterator: Iterator = queue.iterator() + iterator.next() + iterator.next() + iterator.next() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorRemove() { + val iterator = queue.iterator() + + iterator.next() + iterator.remove() + assertEquals(queue.asList(), listOf("two", "three")) + + iterator.next() + iterator.remove() + assertEquals(queue.asList(), listOf("three")) + } + + @Test + fun testIteratorRemoveDisallowsConcurrentModification() { + val iterator = queue.iterator() + iterator.next() + queue.remove() + + try { + iterator.remove() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorHasNextDisallowsConcurrentModification() { + val iterator: Iterator = queue.iterator() + iterator.next() + queue.remove() + + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorDisallowsConcurrentModificationWithClear() { + val iterator: Iterator = queue.iterator() + iterator.next() + queue.clear() + + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorOnlyRemovesFromHead() { + val iterator = queue.iterator() + iterator.next() + iterator.next() + + try { + iterator.remove() + fail() + } catch (ex: UnsupportedOperationException) { + assertEquals(ex.message, "Removal is only permitted from the head.") + } + } + + @Test + fun iteratorThrowsIOException() { + val parent = folder.root + val file = File(parent, "object-queue") + val queueFile = Builder(file).build() + val queue = ObjectQueue.create( + queueFile, + object : Converter { + override fun from(bytes: ByteArray): String { + throw IOException() + } + + override fun toStream(o: Any, bytes: OutputStream) { + } + } + ) + queue.add(Any()) + val iterator = queue.iterator() + try { + iterator.next() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + } + + internal class StringConverter : Converter { + override fun from(bytes: ByteArray): String { + return String(bytes, charset("UTF-8")) + } + + override fun toStream(s: String, os: OutputStream) { + os.write(s.toByteArray(charset("UTF-8"))) + } + } +} diff --git a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt new file mode 100644 index 00000000000..8ece592c684 --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt @@ -0,0 +1,730 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/test/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape + +import io.sentry.cache.tape.QueueFile.Builder +import io.sentry.cache.tape.QueueFile.Element +import okio.BufferedSource +import okio.Okio +import org.junit.Assert +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.util.ArrayDeque +import java.util.Queue +import java.util.logging.Logger +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Tests for QueueFile. + * + * @author Bob Lee (bob@squareup.com) + */ +class QueueFileTest { + private val headerLength = 32 + + @get:Rule + val folder = TemporaryFolder() + private lateinit var file: File + + private fun newQueueFile(raf: RandomAccessFile): QueueFile { + return QueueFile(this.file, raf, true, -1) + } + + private fun newQueueFile(zero: Boolean = true, size: Int = -1): QueueFile { + return Builder(file).zero(zero).size(size).build() + } + + @Before + fun setUp() { + val parent = folder.root + file = File(parent, "queue-file") + } + + @Test + fun testAddOneElement() { + // This test ensures that we update 'first' correctly. + var queue = newQueueFile() + val expected = values[253] + queue.add(expected) + assertArrayEquals(queue.peek(), expected) + queue.close() + queue = newQueueFile() + assertArrayEquals(queue.peek(), expected) + } + + @Test + fun testClearErases() { + val queue = newQueueFile() + val expected = values[253] + queue.add(expected) + + // Confirm that the data was in the file before we cleared. + val data = ByteArray(expected!!.size) + queue.raf.seek(headerLength.toLong() + Element.HEADER_LENGTH) + queue.raf.readFully(data, 0, expected.size) + assertArrayEquals(data, expected) + + queue.clear() + + // Should have been erased. + queue.raf.seek(headerLength.toLong() + Element.HEADER_LENGTH) + queue.raf.readFully(data, 0, expected.size) + assertArrayEquals(data, ByteArray(expected.size)) + } + + @Test + fun testClearDoesNotCorrupt() { + var queue = newQueueFile() + val stuff = values[253] + queue.add(stuff) + queue.clear() + + queue = newQueueFile() + assertTrue(queue.isEmpty) + assertNull(queue.peek()) + + queue.add(values[25]) + assertArrayEquals(queue.peek(), values[25]) + } + + @Test + fun removeErasesEagerly() { + val queue = newQueueFile() + + val firstStuff = values[127] + queue.add(firstStuff) + + val secondStuff = values[253] + queue.add(secondStuff) + + // Confirm that first stuff was in the file before we remove. + val data = ByteArray(firstStuff!!.size) + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, firstStuff.size) + assertArrayEquals(data, firstStuff) + + queue.remove() + + // Next record is intact + assertArrayEquals(queue.peek(), secondStuff) + + // First should have been erased. + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, firstStuff.size) + assertArrayEquals(data, ByteArray(firstStuff.size)) + } + + @Test + fun testZeroSizeInHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail("Should have thrown about bad header length") + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (0) is invalid.") + } + } + + @Test + fun testSizeLessThanHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.writeInt(-0x7fffffff) + emptyFile.writeLong((headerLength - 1).toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail() + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (31) is invalid.") + } + } + + @Test + fun testNegativeSizeInHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.seek(0) + emptyFile.writeInt(-2147483648) + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail("Should have thrown about bad header length") + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (0) is invalid.") + } + } + + @Test + fun removeMultipleDoesNotCorrupt() { + var queue = newQueueFile() + for (i in 0..9) { + queue.add(values[i]) + } + + queue.remove(1) + assertEquals(queue.size(), 9) + assertArrayEquals(queue.peek(), values[1]) + + queue.remove(3) + queue = newQueueFile() + assertEquals(queue.size(), 6) + assertArrayEquals(queue.peek(), values[4]) + + queue.remove(6) + assertTrue(queue.isEmpty) + assertNull(queue.peek()) + } + + @Test + fun removeDoesNotCorrupt() { + var queue = newQueueFile() + + queue.add(values[127]) + val secondStuff = values[253] + queue.add(secondStuff) + queue.remove() + + queue = newQueueFile() + assertArrayEquals(queue.peek(), secondStuff) + } + + @Test + fun removeFromEmptyFileThrows() { + val queue = newQueueFile() + + try { + queue.remove() + fail("Should have thrown about removing from empty file.") + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun removeZeroFromEmptyFileDoesNothing() { + val queue = newQueueFile() + queue.remove(0) + assertTrue(queue.isEmpty) + } + + @Test + fun removeNegativeNumberOfElementsThrows() { + val queue = newQueueFile() + queue.add(values[127]) + + try { + queue.remove(-1) + fail("Should have thrown about removing negative number of elements.") + } catch (ex: IllegalArgumentException) { + assertEquals(ex.message, "Cannot remove negative (-1) number of elements.") + } + } + + @Test + fun removeZeroElementsDoesNothing() { + val queue = newQueueFile() + queue.add(values[127]) + + queue.remove(0) + assertEquals(queue.size(), 1) + } + + @Test + fun removeBeyondQueueSizeElementsThrows() { + val queue = newQueueFile() + queue.add(values[127]) + + try { + queue.remove(10) + fail("Should have thrown about removing too many elements.") + } catch (ex: IllegalArgumentException) { + assertEquals(ex.message, "Cannot remove more elements (10) than present in queue (1).") + } + } + + @Test + fun removingBigDamnBlocksErasesEffectively() { + val bigBoy = ByteArray(7000) + var i = 0 + while (i < 7000) { + System.arraycopy(values[100], 0, bigBoy, i, values[100]!!.size) + i += 100 + } + + val queue = newQueueFile() + + queue.add(bigBoy) + val secondStuff = values[123] + queue.add(secondStuff) + + // Confirm that bigBoy was in the file before we remove. + val data = ByteArray(bigBoy.size) + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, bigBoy.size) + assertArrayEquals(data, bigBoy) + + queue.remove() + + // Next record is intact + assertArrayEquals(queue.peek(), secondStuff) + + // First should have been erased. + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, bigBoy.size) + assertArrayEquals(data, ByteArray(bigBoy.size)) + } + + @Test + fun testAddAndRemoveElements() { + val start = System.nanoTime() + + val expected: Queue = ArrayDeque() + + for (round in 0..4) { + val queue = newQueueFile() + for (i in 0 until N) { + queue.add(values[i]) + expected.add(values[i]) + } + + // Leave N elements in round N, 15 total for 5 rounds. Removing all the + // elements would be like starting with an empty queue. + for (i in 0 until N - round - 1) { + assertArrayEquals(queue.peek(), expected.remove()) + queue.remove() + } + queue.close() + } + + // Remove and validate remaining 15 elements. + val queue = newQueueFile() + assertEquals(queue.size(), 15) + assertEquals(queue.size(), expected.size) + while (!expected.isEmpty()) { + assertArrayEquals(queue.peek(), expected.remove()) + queue.remove() + } + queue.close() + + // length() returns 0, but I checked the size w/ 'ls', and it is correct. + // assertEquals(65536, file.length()); + logger.info("Ran in " + ((System.nanoTime() - start) / 1000000) + "ms.") + } + + @Test + fun testFailedAdd() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + queueFile.add(values[252]) + Assert.fail() + } catch (e: IOException) { /* expected */ + } + + braf.rejectCommit = false + + // Allow a subsequent add to succeed. + queueFile.add(values[251]) + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 2) + assertArrayEquals(queueFile.peek(), values[253]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[251]) + } + + @Test + fun testFailedRemoval() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + queueFile.remove() + fail() + } catch (e: IOException) { /* expected */ + } + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 1) + assertArrayEquals(queueFile.peek(), values[253]) + + queueFile.add(values[99]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[99]) + } + + @Test + fun testFailedExpansion() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + // This should trigger an expansion which should fail. + queueFile.add(ByteArray(8000)) + fail() + } catch (e: IOException) { /* expected */ + } + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 1) + assertArrayEquals(queueFile.peek(), values[253]) + assertEquals(queueFile.fileLength, 4096) + + queueFile.add(values[99]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[99]) + } + + @Test + fun removingElementZeroesData() { + val queueFile = newQueueFile(true) + queueFile.add(values[4]) + queueFile.remove() + queueFile.close() + + val source: BufferedSource = Okio.buffer(Okio.source(file)) + source.skip(headerLength.toLong()) + source.skip(Element.HEADER_LENGTH.toLong()) + assertEquals(source.readByteString(4).hex(), "00000000") + } + + @Test + fun removingElementDoesNotZeroData() { + val queueFile = newQueueFile(false) + queueFile.add(values[4]) + queueFile.remove() + queueFile.close() + + val source: BufferedSource = Okio.buffer(Okio.source(file)) + source.skip(headerLength.toLong()) + source.skip(Element.HEADER_LENGTH.toLong()) + assertEquals(source.readByteString(4).hex(), "04030201") + + source.close() + } + + /** + * Exercise a bug where opening a queue whose first or last element's header + * was non contiguous throws an [java.io.EOFException]. + */ + @Test + fun testReadHeadersFromNonContiguousQueueWorks() { + val queueFile = newQueueFile() + + // Fill the queue up to `length - 2` (i.e. remainingBytes() == 2). + for (i in 0..14) { + queueFile.add(values[N - 1]) + } + queueFile.add(values[219]) + + // Remove first item so we have room to add another one without growing the file. + queueFile.remove() + + // Add any element element and close the queue. + queueFile.add(values[6]) + val queueSize = queueFile.size() + queueFile.close() + + // File should not be corrupted. + val queueFile2 = newQueueFile() + assertEquals(queueFile2.size(), queueSize) + } + + @Test + fun testIterator() { + val data = values[10] + + for (i in 0..9) { + val queueFile = newQueueFile() + for (j in 0 until i) { + queueFile.add(data) + } + + var saw = 0 + for (element in queueFile) { + assertArrayEquals(element, data) + saw++ + } + assertEquals(saw, i) + queueFile.close() + file!!.delete() + } + } + + @Test + fun testIteratorNextThrowsWhenEmpty() { + val queueFile = newQueueFile() + + val iterator: Iterator = queueFile.iterator() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorNextThrowsWhenExhausted() { + val queueFile = newQueueFile() + queueFile.add(values[0]) + + val iterator: Iterator = queueFile.iterator() + iterator.next() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorRemove() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + while (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + + assertTrue(queueFile.isEmpty) + } + + @Test + fun testIteratorRemoveDisallowsConcurrentModification() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + iterator.next() + queueFile.remove() + try { + iterator.remove() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorHasNextDisallowsConcurrentModification() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator: Iterator = queueFile.iterator() + iterator.next() + queueFile.remove() + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorDisallowsConcurrentModificationWithClear() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator: Iterator = queueFile.iterator() + iterator.next() + queueFile.clear() + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorOnlyRemovesFromHead() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + iterator.next() + iterator.next() + + try { + iterator.remove() + fail() + } catch (ex: UnsupportedOperationException) { + assertEquals(ex.message, "Removal is only permitted from the head.") + } + } + + @Test + fun iteratorThrowsIOException() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + class BrokenRandomAccessFile(file: File?, mode: String?) : RandomAccessFile(file, mode) { + var fail: Boolean = false + + override fun write(b: ByteArray, off: Int, len: Int) { + if (fail) { + throw IOException() + } + super.write(b, off, len) + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (fail) { + throw IOException() + } + return super.read(b, off, len) + } + } + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + val iterator = queueFile.iterator() + + braf.fail = true + try { + iterator.next() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + + braf.fail = false + iterator.next() + + braf.fail = true + try { + iterator.remove() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + } + + @Test + fun queueToString() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + assertTrue( + queueFile.toString().contains( + "zero=true, length=4096," + + " size=15," + + " first=Element[position=32, length=0], last=Element[position=179, length=14]}" + ) + ) + } + + @Test + fun `wraps elements around when size is specified`() { + val queue = newQueueFile(size = 2) + + for (i in 0 until 3) { + queue.add(values[i]) + } + + // Confirm that first element now is values[1] in the file after wrapping + assertArrayEquals(queue.peek(), values[1]) + queue.remove() + + // Confirm that first element now is values[2] in the file after wrapping + assertArrayEquals(queue.peek(), values[2]) + } + + /** + * A RandomAccessFile that can break when you go to write the COMMITTED + * status. + */ + internal class BrokenRandomAccessFile(file: File?, mode: String?) : RandomAccessFile(file, mode) { + var rejectCommit: Boolean = true + override fun write(b: ByteArray, off: Int, len: Int) { + if (rejectCommit && filePointer == 0L) { + throw IOException("No commit for you!") + } + super.write(b, off, len) + } + } + + companion object { + private val logger: Logger = Logger.getLogger( + QueueFileTest::class.java.name + ) + + /** + * Takes up 33401 bytes in the queue (N*(N+1)/2+4*N). Picked 254 instead of 255 so that the number + * of bytes isn't a multiple of 4. + */ + private const val N = 254 + private val values = Array(N) { i -> + val value = ByteArray(i) + // Example: values[3] = { 3, 2, 1 } + for (ii in 0 until i) value[ii] = (i - ii).toByte() + value + } + } +} diff --git a/sentry/src/test/resources/corrupt_queue_file.txt b/sentry/src/test/resources/corrupt_queue_file.txt new file mode 100644 index 0000000000000000000000000000000000000000..2eca21fb255ad4e651f0a767d5c2c83dc208bb77 GIT binary patch literal 4100 zcmeH@J#WJx5Qa&|O#KNKb8Q(yWt=Hnm9?tW`D`C4l1&p#yi}F)(~Cc%CQ|n{!k#CdW&-irv^oG9c zZy&G&+r|Vxz_2@=Pp(0q*-t*Xa6lI-Z^Kb5Q6vIHfCvx)B0vO)01+SpM1Tko0V42E1bzTiU{pE) literal 0 HcmV?d00001 From 5e52d651f78dd1e67c1bc95e376c07db617fc96b Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 17 Mar 2025 12:37:48 +0000 Subject: [PATCH 21/24] release: 7.22.3 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 936bd3fe290..bc25a92eadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.3 ### Fixes diff --git a/gradle.properties b/gradle.properties index a45a1152629..0b32bcd6377 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.2 +versionName=7.22.3 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From b14c7bf43d0fbaa9d98335c042a544be343729a4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 19 Mar 2025 23:09:24 +0100 Subject: [PATCH 22/24] Avoid reading floats as ints from the manifest in case it's not necessary (#4266) * Avoid logging an error when a float is passed in the manifest * Update CHANGELOG.md --------- Co-authored-by: Stefano --- CHANGELOG.md | 6 ++++++ .../java/io/sentry/android/core/ManifestMetadataReader.java | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc25a92eadc..b0019bf87a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Avoid logging an error when a float is passed in the manifest ([#4266](https://github.com/getsentry/sentry-java/pull/4266)) + ## 7.22.3 ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 6e8a64530fc..9a27544aeff 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -494,7 +494,10 @@ private static boolean readBool( private static @NotNull Double readDouble( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float - final Double value = ((Number) metadata.getFloat(key, metadata.getInt(key, -1))).doubleValue(); + double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); + if (value == -1) { + value = ((Integer) metadata.getInt(key, -1)).doubleValue(); + } logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } From bd16527ca058925ae08a785a8a9ade3e933b9279 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 20 Mar 2025 10:19:04 +0100 Subject: [PATCH 23/24] fix(session-replay): Do not crash if navigation breadcrumb has no destination (#4185) (#4269) * fix(session-replay): Do not crash if navigation breadcrumb has no destination (#4185) * Do not crash if navigation breadcrumb has not destination * Changelog * Fix test --- CHANGELOG.md | 2 ++ .../android/replay/capture/CaptureStrategy.kt | 10 ++++++-- .../capture/SessionCaptureStrategyTest.kt | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0019bf87a1..950576d24f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixes +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) - Avoid logging an error when a float is passed in the manifest ([#4266](https://github.com/getsentry/sentry-java/pull/4266)) ## 7.22.3 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 660a366ecd2..6efaf47e3cb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -58,6 +58,10 @@ internal interface CaptureStrategy { companion object { private const val BREADCRUMB_START_OFFSET = 100L + // 5 minutes, otherwise relay will just drop it. Can prevent the case where the device + // time is wrong and the segment is too long. + private const val MAX_SEGMENT_DURATION = 1000L * 60 * 5 + fun createSegment( hub: IHub?, options: SentryOptions, @@ -76,7 +80,7 @@ internal interface CaptureStrategy { events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( - duration, + minOf(duration, MAX_SEGMENT_DURATION), currentSegmentTimestamp.time, segmentId, height, @@ -179,7 +183,9 @@ internal interface CaptureStrategy { recordingPayload += rrwebEvent // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation" && + rrwebEvent.data?.getOrElse("to", { null }) is String + ) { urls.add(rrwebEvent.data!!["to"] as String) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index dfe4137fb06..c0fd4f2d043 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -336,6 +336,30 @@ class SessionCaptureStrategyTest { ) } + @Test + fun `does not throw when navigation destination is not a String`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertNull(it.urls?.firstOrNull()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertNull(breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + @Test fun `sets screen from scope as replay url`() { fixture.scope.screen = "MainActivity" From 8a2fd325f8ada1b352d70bb4db486dd965dcecb5 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 20 Mar 2025 10:17:21 +0000 Subject: [PATCH 24/24] release: 7.22.4 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 950576d24f3..af5f5bb52e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.4 ### Fixes diff --git a/gradle.properties b/gradle.properties index 0b32bcd6377..7eb71745b3d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.3 +versionName=7.22.4 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android