diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 964107ecd5..a0a7e2405c 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true @@ -70,7 +70,7 @@ jobs: arch: x86 channel: canary # Necessary for ATDs disk-size: 4096M - script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release --daemon + script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release -Denvironment=github --daemon - name: Upload test results if: always() diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6904bd2d9..fe3684e89d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true @@ -35,7 +35,7 @@ jobs: run: make preMerge - name: Upload coverage to Codecov - uses: codecov/codecov-action@015f24e6818733317a2da2edd6290ab26238649a # pin@v4 + uses: codecov/codecov-action@7f8b4b4bde536c465e797be725718b88c5d95e0e # pin@v4 with: name: sentry-java fail_ci_if_error: false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 85ea055e77..30ad0b95d7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,18 +34,18 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # pin@v2 + uses: github/codeql-action/init@df409f7d9260372bd5f19e5b04e83cb3c43714ae # pin@v2 with: - languages: ${{ matrix.language }} + languages: 'java' - name: Build Java run: | ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # pin@v2 + uses: github/codeql-action/analyze@df409f7d9260372bd5f19e5b04e83cb3c43714ae # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index d97e4614b7..ee69bb0b5c 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 26fc5b01a5..564cf4c43d 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true @@ -28,7 +28,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@62fec3add6773ec5dbbf18d2ee4260911aa35cf4 # pin@4.6.9 + uses: JamesIves/github-pages-deploy-action@15de0f09300eea763baee31dff6c6184995c5f6a # pin@4.7.2 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index b9ad44ec1e..1b6295bc5b 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 84f3b0e80e..201b1551f9 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index dc9cdc04a1..2b1bbeacc2 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index a4d9e7befa..0883d940f5 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c160c84b1a..cd08109449 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,16 +17,22 @@ jobs: runs-on: ubuntu-latest name: "Release a new version" steps: + - name: Get auth token + id: token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - uses: actions/checkout@v4 with: - token: ${{ secrets.GH_RELEASE_PAT }} + token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 submodules: 'recursive' - name: Prepare release uses: getsentry/action-prepare-release@v1 env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: version: ${{ github.event.inputs.version }} force: ${{ github.event.inputs.force }} diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 40bb5c92dd..e8bb0d77e8 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -56,7 +56,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4ba34e96c5f6493e99d0696180a9a8d431577ba9 # pin@v3 + uses: gradle/actions/setup-gradle@3839b20885c2c3507be5f0521853826f4b37038a # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8f5e3a43..66f2693ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,21 @@ # Changelog -## Unreleased +## 8.0.0-rc.4 ### Features - Enable `ThreadLocalAccessor` for Spring Boot 3 WebFlux by default ([#4023](https://github.com/getsentry/sentry-java/pull/4023)) +### Internal + +- Warm starts cleanup ([#3954](https://github.com/getsentry/sentry-java/pull/3954)) + +### Dependencies + +- Bump Native SDK from v0.7.16 to v0.7.17 ([#4003](https://github.com/getsentry/sentry-java/pull/4003)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0717) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.16...0.7.17) + ## 8.0.0-rc.3 ### Features @@ -400,6 +410,81 @@ You may also use `LifecycleHelper.close(token)`, e.g. in case you need to pass t - Report exceptions returned by Throwable.getSuppressed() to Sentry as exception groups ([#3396] https://github.com/getsentry/sentry-java/pull/3396) +## 7.20.0 + +### Features + +- Session Replay GA ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) + +To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onErrorSampleRate` options. + + ```kotlin + import io.sentry.SentryReplayOptions + import io.sentry.android.core.SentryAndroid + + SentryAndroid.init(context) { options -> + + options.sessionReplay.sessionSampleRate = 1.0 + options.sessionReplay.onErrorSampleRate = 1.0 + + // To change default redaction behavior (defaults to true) + options.sessionReplay.redactAllImages = true + options.sessionReplay.redactAllText = true + + // To change quality of the recording (defaults to MEDIUM) + options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH) + } + ``` + +### Fixes + +- Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937)) +- Session Replay: Reduce memory allocations, disk space consumption, and payload size ([#4016](https://github.com/getsentry/sentry-java/pull/4016)) +- Session Replay: Do not try to encode corrupted frames multiple times ([#4016](https://github.com/getsentry/sentry-java/pull/4016)) + +### Internal + +- Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014)) +- Session Replay: Send replay options as tags ([#4015](https://github.com/getsentry/sentry-java/pull/4015)) + +### Breaking changes + +- Session Replay options were moved from under `experimental` to the main `options` object ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) + +## 7.19.1 + +### Fixes + +- Change TTFD timeout to 25 seconds ([#3984](https://github.com/getsentry/sentry-java/pull/3984)) +- Session Replay: Fix memory leak when masking Compose screens ([#3985](https://github.com/getsentry/sentry-java/pull/3985)) +- Session Replay: Fix potential ANRs in `GestureRecorder` ([#4001](https://github.com/getsentry/sentry-java/pull/4001)) + +### Internal + +- Session Replay: Flutter improvements ([#4007](https://github.com/getsentry/sentry-java/pull/4007)) + +## 7.19.0 + +### Fixes + +- Session Replay: fix various crashes and issues ([#3970](https://github.com/getsentry/sentry-java/pull/3970)) + - Fix `IndexOutOfBoundsException` when tracking window changes + - Fix `IllegalStateException` when adding/removing draw listener for a dead view + - Fix `ConcurrentModificationException` when registering window listeners and stopping `WindowRecorder`/`GestureRecorder` +- Add support for setting sentry-native handler_strategy ([#3671](https://github.com/getsentry/sentry-java/pull/3671)) + +### Dependencies + +- Bump Native SDK from v0.7.8 to v0.7.16 ([#3671](https://github.com/getsentry/sentry-java/pull/3671)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0716) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.8...0.7.16) + +## 7.18.1 + +### Fixes + +- Fix testTag not working for Jetpack Compose user interaction tracking ([#3878](https://github.com/getsentry/sentry-java/pull/3878)) + ## 7.18.0 ### Features diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 5b4e907e29..c52cfdc642 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -197,6 +197,7 @@ object Config { val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:4.1.0" val mockitoInline = "org.mockito:mockito-inline:4.8.0" val awaitility = "org.awaitility:awaitility-kotlin:4.1.1" + val awaitility3 = "org.awaitility:awaitility-kotlin:3.1.6" // need this due to a conflict of awaitility4+ and espresso on hamcrest val mockWebserver = "com.squareup.okhttp3:mockwebserver:${Libs.okHttpVersion}" val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0" val hsqldb = "org.hsqldb:hsqldb:2.6.1" diff --git a/gradle.properties b/gradle.properties index 8764c2cdf9..0e16313e4c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=8.0.0-rc.3 +versionName=8.0.0-rc.4 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8734d5736b..3f34efa9d5 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -27,8 +27,12 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityPostCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityPostResumed (Landroid/app/Activity;)V + public fun onActivityPostStarted (Landroid/app/Activity;)V + public fun onActivityPreCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityPrePaused (Landroid/app/Activity;)V + public fun onActivityPreStarted (Landroid/app/Activity;)V public fun onActivityResumed (Landroid/app/Activity;)V public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityStarted (Landroid/app/Activity;)V @@ -223,6 +227,14 @@ public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { public fun loadClass (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/lang/Class; } +public final class io/sentry/android/core/NdkHandlerStrategy : java/lang/Enum { + public static final field SENTRY_HANDLER_STRATEGY_CHAIN_AT_START Lio/sentry/android/core/NdkHandlerStrategy; + public static final field SENTRY_HANDLER_STRATEGY_DEFAULT Lio/sentry/android/core/NdkHandlerStrategy; + public fun getValue ()I + public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/NdkHandlerStrategy; + public static fun values ()[Lio/sentry/android/core/NdkHandlerStrategy; +} + public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration, java/io/Closeable { public static final field SENTRY_NDK_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Class;)V @@ -270,6 +282,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; public fun getNativeSdkName ()Ljava/lang/String; + public fun getNdkHandlerStrategy ()I public fun getStartupCrashDurationThresholdMillis ()J public fun isAnrEnabled ()Z public fun isAnrReportInDebug ()Z @@ -313,6 +326,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableScopeSync (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V + public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V public fun setNativeSdkName (Ljava/lang/String;)V public fun setReportHistoricalAnrs (Z)V } @@ -424,6 +438,20 @@ public class io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapte public fun onActivityStopped (Landroid/app/Activity;)V } +public class io/sentry/android/core/performance/ActivityLifecycleSpanHelper { + public fun (Ljava/lang/String;)V + public fun clear ()V + public fun createAndStopOnCreateSpan (Lio/sentry/ISpan;)V + public fun createAndStopOnStartSpan (Lio/sentry/ISpan;)V + public fun getOnCreateSpan ()Lio/sentry/ISpan; + public fun getOnCreateStartTimestamp ()Lio/sentry/SentryDate; + public fun getOnStartSpan ()Lio/sentry/ISpan; + public fun getOnStartStartTimestamp ()Lio/sentry/SentryDate; + public fun saveSpanToAppStartMetrics ()V + public fun setOnCreateStartTimestamp (Lio/sentry/SentryDate;)V + public fun setOnStartStartTimestamp (Lio/sentry/SentryDate;)V +} + public class io/sentry/android/core/performance/ActivityLifecycleTimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)I @@ -437,6 +465,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun ()V public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V public fun clear ()V + public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getActivityLifecycleTimeSpans ()Ljava/util/List; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; @@ -449,17 +478,21 @@ 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 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 setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun shouldSendStartMeasurements ()Z } public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { @@ -492,6 +525,7 @@ public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable public fun setStartUnixTimeMs (J)V public fun setStartedAt (J)V public fun setStoppedAt (J)V + public fun setup (Ljava/lang/String;JJJ)V public fun start ()V public fun stop ()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 bc9658b5d1..eaf05e619d 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 @@ -9,8 +9,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.view.View; -import androidx.annotation.NonNull; +import android.os.SystemClock; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -31,6 +30,7 @@ import io.sentry.TransactionOptions; import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.android.core.performance.ActivityLifecycleSpanHelper; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; @@ -60,7 +60,7 @@ public final class ActivityLifecycleIntegration static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; - static final long TTFD_TIMEOUT_MILLIS = 30000; + static final long TTFD_TIMEOUT_MILLIS = 25000; private static final String TRACE_ORIGIN = "auto.ui.activity"; private final @NotNull Application application; @@ -80,8 +80,10 @@ public final class ActivityLifecycleIntegration private @Nullable ISpan appStartSpan; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); + private final @NotNull WeakHashMap activitySpanHelpers = + new WeakHashMap<>(); private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - private final @NotNull Handler mainHandler = new Handler(Looper.getMainLooper()); + private long lastPausedUptimeMillis = 0; private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -380,9 +382,32 @@ private void finishTransaction( } } + @Override + public void onActivityPreCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { + final ActivityLifecycleSpanHelper helper = + new ActivityLifecycleSpanHelper(activity.getClass().getName()); + activitySpanHelpers.put(activity, helper); + // The very first activity start timestamp cannot be set to the class instantiation time, as it + // may happen before an activity is started (service, broadcast receiver, etc). So we set it + // here. + if (firstActivityCreated) { + return; + } + lastPausedTime = + scopes != null + ? scopes.getOptions().getDateProvider().now() + : AndroidDateUtils.getCurrentSentryDateTime(); + lastPausedUptimeMillis = SystemClock.uptimeMillis(); + helper.setOnCreateStartTimestamp(lastPausedTime); + } + @Override public void onActivityCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { + if (!isAllActivityCallbacksAvailable) { + onActivityPreCreated(activity, savedInstanceState); + } try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { setColdStart(savedInstanceState); if (scopes != null && options != null && options.isEnableScreenTracking()) { @@ -400,9 +425,33 @@ public void onActivityCreated( } } + @Override + public void onActivityPostCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); + if (helper != null) { + helper.createAndStopOnCreateSpan(appStartSpan); + } + } + + @Override + public void onActivityPreStarted(final @NotNull Activity activity) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); + if (helper != null) { + helper.setOnStartStartTimestamp( + options != null + ? options.getDateProvider().now() + : AndroidDateUtils.getCurrentSentryDateTime()); + } + } + @Override public void onActivityStarted(final @NotNull Activity activity) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isAllActivityCallbacksAvailable) { + onActivityPostCreated(activity, null); + onActivityPreStarted(activity); + } if (performanceEnabled) { // The docs on the screen rendering performance tracing // (https://firebase.google.com/docs/perf-mon/screen-traces?platform=android#definition), @@ -415,45 +464,55 @@ public void onActivityStarted(final @NotNull Activity activity) { } } + @Override + public void onActivityPostStarted(final @NotNull Activity activity) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); + if (helper != null) { + helper.createAndStopOnStartSpan(appStartSpan); + // Needed to handle hybrid SDKs + helper.saveSpanToAppStartMetrics(); + } + } + @Override public void onActivityResumed(final @NotNull Activity activity) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isAllActivityCallbacksAvailable) { + onActivityPostStarted(activity); + } if (performanceEnabled) { final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); - final View rootView = activity.findViewById(android.R.id.content); - if (rootView != null) { + if (activity.getWindow() != null) { FirstDrawDoneListener.registerForNextDraw( - rootView, () -> onFirstFrameDrawn(ttfdSpan, ttidSpan), buildInfoProvider); + activity, () -> onFirstFrameDrawn(ttfdSpan, ttidSpan), buildInfoProvider); } else { // Posting a task to the main thread's handler will make it executed after it finished // its current job. That is, right after the activity draws the layout. - mainHandler.post(() -> onFirstFrameDrawn(ttfdSpan, ttidSpan)); + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn(ttfdSpan, ttidSpan)); } } } } @Override - public void onActivityPostResumed(@NonNull Activity activity) { + public void onActivityPostResumed(@NotNull Activity activity) { // empty override, required to avoid a api-level breaking super.onActivityPostResumed() calls } @Override - public void onActivityPrePaused(@NonNull Activity activity) { + public void onActivityPrePaused(@NotNull Activity activity) { // only executed if API >= 29 otherwise it happens on onActivityPaused - if (isAllActivityCallbacksAvailable) { - // as the SDK may gets (re-)initialized mid activity lifecycle, ensure we set the flag here as - // well - // this ensures any newly launched activity will not use the app start timestamp as txn start - firstActivityCreated = true; - if (scopes == null) { - lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - } else { - lastPausedTime = scopes.getOptions().getDateProvider().now(); - } - } + // as the SDK may gets (re-)initialized mid activity lifecycle, ensure we set the flag here as + // well + // this ensures any newly launched activity will not use the app start timestamp as txn start + firstActivityCreated = true; + lastPausedTime = + scopes != null + ? scopes.getOptions().getDateProvider().now() + : AndroidDateUtils.getCurrentSentryDateTime(); + lastPausedUptimeMillis = SystemClock.uptimeMillis(); } @Override @@ -461,17 +520,7 @@ public void onActivityPaused(final @NotNull Activity activity) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { // only executed if API < 29 otherwise it happens on onActivityPrePaused if (!isAllActivityCallbacksAvailable) { - // as the SDK may gets (re-)initialized mid activity lifecycle, ensure we set the flag here - // as - // well - // this ensures any newly launched activity will not use the app start timestamp as txn - // start - firstActivityCreated = true; - if (scopes == null) { - lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - } else { - lastPausedTime = scopes.getOptions().getDateProvider().now(); - } + onActivityPrePaused(activity); } } } @@ -490,6 +539,10 @@ public void onActivitySaveInstanceState( @Override public void onActivityDestroyed(final @NotNull Activity activity) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final ActivityLifecycleSpanHelper helper = activitySpanHelpers.remove(activity); + if (helper != null) { + helper.clear(); + } if (performanceEnabled) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid @@ -516,13 +569,23 @@ public void onActivityDestroyed(final @NotNull Activity activity) { } // clear it up, so we don't start again for the same activity if the activity is in the - // activity - // stack still. + // activity stack still. // if the activity is opened again and not in memory, transactions will be created normally. activitiesWithOngoingTransactions.remove(activity); + + if (activitiesWithOngoingTransactions.isEmpty()) { + clear(); + } } } + private void clear() { + firstActivityCreated = false; + lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + lastPausedUptimeMillis = 0; + activitySpanHelpers.clear(); + } + private void finishSpan(final @Nullable ISpan span) { if (span != null && !span.isFinished()) { span.finish(); @@ -565,8 +628,7 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); - // in case the SentryPerformanceProvider is disabled it does not set the app start end times, - // and we need to set the end time manually here + // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { appStartTimeSpan.stop(); } @@ -627,6 +689,17 @@ WeakHashMap getActivitiesWithOngoingTransactions() { return activitiesWithOngoingTransactions; } + @TestOnly + @NotNull + WeakHashMap getActivitySpanHelpers() { + return activitySpanHelpers; + } + + @TestOnly + void setFirstActivityCreated(boolean firstActivityCreated) { + this.firstActivityCreated = firstActivityCreated; + } + @TestOnly @NotNull ActivityFramesTracker getActivityFramesTracker() { @@ -652,26 +725,22 @@ WeakHashMap getTtfdSpanMap() { } private void setColdStart(final @Nullable Bundle savedInstanceState) { - // The very first activity start timestamp cannot be set to the class instantiation time, as it - // may happen before an activity is started (service, broadcast receiver, etc). So we set it - // here. - if (scopes != null && lastPausedTime.nanoTimestamp() == 0) { - lastPausedTime = scopes.getOptions().getDateProvider().now(); - } else if (lastPausedTime.nanoTimestamp() == 0) { - lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - } if (!firstActivityCreated) { - final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - // if Activity has savedInstanceState then its a warm start - // https://developer.android.com/topic/performance/vitals/launch-time#warm - // SentryPerformanceProvider sets this already - // pre-performance-v2: back-fill with best guess - if ((options != null && !options.isEnablePerformanceV2()) - || appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.UNKNOWN) { - appStartMetrics.setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); + 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); } } } 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 03547ef1fd..de1af477b2 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 @@ -215,14 +215,7 @@ public static Map getAppStartMeasurement() { final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); final @NotNull List> spans = new ArrayList<>(); - final @NotNull TimeSpan processInitNativeSpan = new TimeSpan(); - processInitNativeSpan.setStartedAt(metrics.getAppStartTimeSpan().getStartUptimeMs()); - processInitNativeSpan.setStartUnixTimeMs( - metrics.getAppStartTimeSpan().getStartTimestampMs()); // This has to go after setStartedAt - processInitNativeSpan.setStoppedAt(metrics.getClassLoadedUptimeMs()); - processInitNativeSpan.setDescription("Process Initialization"); - - addTimeSpanToSerializedSpans(processInitNativeSpan, spans); + addTimeSpanToSerializedSpans(metrics.createProcessInitSpan(), spans); addTimeSpanToSerializedSpans(metrics.getApplicationOnCreateTimeSpan(), spans); for (final TimeSpan span : metrics.getContentProviderOnCreateTimeSpans()) { 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 45661866fa..fcb755ec02 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 @@ -379,28 +379,26 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); - if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { + if (options.getSessionReplay().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); + options.getSessionReplay().setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getSessionReplay().getOnErrorSampleRate() == null) { + if (options.getSessionReplay().getOnErrorSampleRate() == null) { final Double onErrorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (onErrorSampleRate != -1) { - options.getExperimental().getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); + options.getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); } } options - .getExperimental() .getSessionReplay() .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); options - .getExperimental() .getSessionReplay() .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkHandlerStrategy.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkHandlerStrategy.java new file mode 100644 index 0000000000..74b446658d --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkHandlerStrategy.java @@ -0,0 +1,19 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public enum NdkHandlerStrategy { + SENTRY_HANDLER_STRATEGY_DEFAULT(0), + SENTRY_HANDLER_STRATEGY_CHAIN_AT_START(1); + + private final int value; + + NdkHandlerStrategy(final int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index 887c63f5c8..f7b51cce62 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -14,7 +14,6 @@ import io.sentry.SpanId; import io.sentry.SpanStatus; import io.sentry.android.core.internal.util.AndroidThreadChecker; -import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.App; @@ -81,12 +80,13 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { return transaction; } + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (!sentStartMeasurement) { + if (appStartMetrics.shouldSendStartMeasurements()) { final @NotNull TimeSpan appStartTimeSpan = - AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -96,14 +96,14 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); final String appStartKey = - AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD + appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD ? MeasurementValue.KEY_APP_START_COLD : MeasurementValue.KEY_APP_START_WARM; transaction.getMeasurements().put(appStartKey, value); - attachColdAppStartSpans(AppStartMetrics.getInstance(), transaction); - sentStartMeasurement = true; + attachAppStartSpans(appStartMetrics, transaction); + appStartMetrics.onAppStartSpansSent(); } } @@ -113,7 +113,7 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { transaction.getContexts().setApp(appContext); } final String appStartType = - AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD + appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD ? "cold" : "warm"; appContext.setStartType(appStartType); @@ -221,10 +221,10 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { || context.getOperation().equals(APP_START_WARM)); } - private void attachColdAppStartSpans( + private void attachAppStartSpans( final @NotNull AppStartMetrics appStartMetrics, final @NotNull SentryTransaction txn) { - // data will be filled only for cold app starts + // We include process init, content providers and application.onCreate spans only on cold start if (appStartMetrics.getAppStartType() != AppStartMetrics.AppStartType.COLD) { return; } @@ -246,18 +246,9 @@ private void attachColdAppStartSpans( } // Process init - final long classInitUptimeMs = appStartMetrics.getClassLoadedUptimeMs(); - final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); - if (appStartTimeSpan.hasStarted() - && Math.abs(classInitUptimeMs - appStartTimeSpan.getStartUptimeMs()) - <= MAX_PROCESS_INIT_APP_START_DIFF_MS) { - final @NotNull TimeSpan processInitTimeSpan = new TimeSpan(); - processInitTimeSpan.setStartedAt(appStartTimeSpan.getStartUptimeMs()); - processInitTimeSpan.setStartUnixTimeMs(appStartTimeSpan.getStartTimestampMs()); - - processInitTimeSpan.setStoppedAt(classInitUptimeMs); - processInitTimeSpan.setDescription("Process Initialization"); - + final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); + if (processInitTimeSpan.hasStarted() + && Math.abs(processInitTimeSpan.getDurationMs()) <= MAX_PROCESS_INIT_APP_START_DIFF_MS) { txn.getSpans() .add( timeSpanToSentrySpan( @@ -283,34 +274,6 @@ private void attachColdAppStartSpans( .add( timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); } - - // Activities - final @NotNull List activityLifecycleTimeSpans = - appStartMetrics.getActivityLifecycleTimeSpans(); - if (!activityLifecycleTimeSpans.isEmpty()) { - for (ActivityLifecycleTimeSpan activityTimeSpan : activityLifecycleTimeSpans) { - if (activityTimeSpan.getOnCreate().hasStarted() - && activityTimeSpan.getOnCreate().hasStopped()) { - txn.getSpans() - .add( - timeSpanToSentrySpan( - activityTimeSpan.getOnCreate(), - parentSpanId, - traceId, - APP_METRICS_ACTIVITIES_OP)); - } - if (activityTimeSpan.getOnStart().hasStarted() - && activityTimeSpan.getOnStart().hasStopped()) { - txn.getSpans() - .add( - timeSpanToSentrySpan( - activityTimeSpan.getOnStart(), - parentSpanId, - traceId, - APP_METRICS_ACTIVITIES_OP)); - } - } - } } @NotNull diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 013c93d909..9c32920be8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -157,6 +157,9 @@ public final class SentryAndroidOptions extends SentryOptions { /** Turns NDK on or off. Default is enabled. */ private boolean enableNdk = true; + @NotNull + private NdkHandlerStrategy ndkHandlerStrategy = + NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT; /** * Enable the Java to NDK Scope sync. The default value for sentry-java is disabled and enabled * for sentry-android. @@ -451,6 +454,16 @@ public void setNativeSdkName(final @Nullable String nativeSdkName) { this.nativeSdkName = nativeSdkName; } + @ApiStatus.Internal + public void setNativeHandlerStrategy(final @NotNull NdkHandlerStrategy ndkHandlerStrategy) { + this.ndkHandlerStrategy = ndkHandlerStrategy; + } + + @ApiStatus.Internal + public int getNdkHandlerStrategy() { + return ndkHandlerStrategy.getValue(); + } + /** * Returns the sdk name for the sentry native ndk module. * 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 64a1ceda60..6658e14560 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,29 +3,22 @@ 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.Bundle; import android.os.Process; import android.os.SystemClock; -import androidx.annotation.NonNull; import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.JsonSerializer; -import io.sentry.NoOpLogger; import io.sentry.SentryAppStartProfilingOptions; import io.sentry.SentryExecutorService; 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.ActivityLifecycleTimeSpan; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.util.AutoClosableReentrantLock; @@ -35,8 +28,6 @@ import java.io.FileNotFoundException; import java.io.InputStreamReader; import java.io.Reader; -import java.util.WeakHashMap; -import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -50,7 +41,6 @@ public final class SentryPerformanceProvider extends EmptySecureContentProvider private static final long sdkInitMillis = SystemClock.uptimeMillis(); private @Nullable Application app; - private @Nullable Application.ActivityLifecycleCallbacks activityCallback; private final @NotNull ILogger logger; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -198,123 +188,5 @@ private void onAppLaunched( final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); appStartMetrics.registerApplicationForegroundCheck(app); - - final AtomicBoolean firstDrawDone = new AtomicBoolean(false); - - activityCallback = - new ActivityLifecycleCallbacksAdapter() { - final WeakHashMap activityLifecycleMap = - new WeakHashMap<>(); - - @Override - public void onActivityPreCreated( - @NonNull Activity activity, @Nullable Bundle savedInstanceState) { - final long now = SystemClock.uptimeMillis(); - if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { - return; - } - - final ActivityLifecycleTimeSpan timeSpan = new ActivityLifecycleTimeSpan(); - timeSpan.getOnCreate().setStartedAt(now); - activityLifecycleMap.put(activity, timeSpan); - } - - @Override - public void onActivityCreated( - @NonNull Activity activity, @Nullable Bundle savedInstanceState) { - if (appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.UNKNOWN) { - appStartMetrics.setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); - } - } - - @Override - public void onActivityPostCreated( - @NonNull Activity activity, @Nullable Bundle savedInstanceState) { - if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { - return; - } - - final @Nullable ActivityLifecycleTimeSpan timeSpan = activityLifecycleMap.get(activity); - if (timeSpan != null) { - timeSpan.getOnCreate().stop(); - timeSpan.getOnCreate().setDescription(activity.getClass().getName() + ".onCreate"); - } - } - - @Override - public void onActivityPreStarted(@NonNull Activity activity) { - final long now = SystemClock.uptimeMillis(); - if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { - return; - } - final @Nullable ActivityLifecycleTimeSpan timeSpan = activityLifecycleMap.get(activity); - if (timeSpan != null) { - timeSpan.getOnStart().setStartedAt(now); - } - } - - @Override - public void onActivityStarted(@NonNull Activity activity) { - if (firstDrawDone.get()) { - return; - } - FirstDrawDoneListener.registerForNextDraw( - activity, - () -> { - if (firstDrawDone.compareAndSet(false, true)) { - onAppStartDone(); - } - }, - // as the SDK isn't initialized yet, we don't have access to SentryOptions - new BuildInfoProvider(NoOpLogger.getInstance())); - } - - @Override - public void onActivityPostStarted(@NonNull Activity activity) { - final @Nullable ActivityLifecycleTimeSpan timeSpan = - activityLifecycleMap.remove(activity); - if (appStartMetrics.getAppStartTimeSpan().hasStopped()) { - return; - } - if (timeSpan != null) { - timeSpan.getOnStart().stop(); - timeSpan.getOnStart().setDescription(activity.getClass().getName() + ".onStart"); - - appStartMetrics.addActivityLifecycleTimeSpans(timeSpan); - } - } - - @Override - public void onActivityDestroyed(@NonNull Activity activity) { - // safety net for activities which were created but never stopped - activityLifecycleMap.remove(activity); - } - }; - - app.registerActivityLifecycleCallbacks(activityCallback); - } - - @TestOnly - void onAppStartDone() { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - appStartMetrics.getSdkInitTimeSpan().stop(); - appStartMetrics.getAppStartTimeSpan().stop(); - - if (app != null) { - if (activityCallback != null) { - app.unregisterActivityLifecycleCallbacks(activityCallback); - } - } - } - } - - @TestOnly - @Nullable - Application.ActivityLifecycleCallbacks getActivityCallback() { - return activityCallback; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java new file mode 100644 index 0000000000..7fed5e0fdb --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelper.java @@ -0,0 +1,138 @@ +package io.sentry.android.core.performance; + +import android.os.Looper; +import android.os.SystemClock; +import io.sentry.ISpan; +import io.sentry.Instrumenter; +import io.sentry.SentryDate; +import io.sentry.SpanDataConvention; +import io.sentry.SpanStatus; +import io.sentry.android.core.AndroidDateUtils; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class ActivityLifecycleSpanHelper { + private static final String APP_METRICS_ACTIVITIES_OP = "activity.load"; + + private final @NotNull String activityName; + + private @Nullable SentryDate onCreateStartTimestamp = null; + private @Nullable SentryDate onStartStartTimestamp = null; + private @Nullable ISpan onCreateSpan = null; + private @Nullable ISpan onStartSpan = null; + + public ActivityLifecycleSpanHelper(final @NotNull String activityName) { + this.activityName = activityName; + } + + public void setOnCreateStartTimestamp(final @NotNull SentryDate onCreateStartTimestamp) { + this.onCreateStartTimestamp = onCreateStartTimestamp; + } + + public void setOnStartStartTimestamp(final @NotNull SentryDate onStartStartTimestamp) { + this.onStartStartTimestamp = onStartStartTimestamp; + } + + public void createAndStopOnCreateSpan(final @Nullable ISpan appStartSpan) { + if (onCreateStartTimestamp != null && appStartSpan != null) { + onCreateSpan = + createLifecycleSpan(appStartSpan, activityName + ".onCreate", onCreateStartTimestamp); + onCreateSpan.finish(); + } + } + + public void createAndStopOnStartSpan(final @Nullable ISpan appStartSpan) { + if (onStartStartTimestamp != null && appStartSpan != null) { + onStartSpan = + createLifecycleSpan(appStartSpan, activityName + ".onStart", onStartStartTimestamp); + onStartSpan.finish(); + } + } + + public @Nullable ISpan getOnCreateSpan() { + return onCreateSpan; + } + + public @Nullable ISpan getOnStartSpan() { + return onStartSpan; + } + + public @Nullable SentryDate getOnCreateStartTimestamp() { + return onCreateStartTimestamp; + } + + public @Nullable SentryDate getOnStartStartTimestamp() { + return onStartStartTimestamp; + } + + public void saveSpanToAppStartMetrics() { + if (onCreateSpan == null || onStartSpan == null) { + return; + } + final @Nullable SentryDate onCreateFinishDate = onCreateSpan.getFinishDate(); + final @Nullable SentryDate onStartFinishDate = onStartSpan.getFinishDate(); + if (onCreateFinishDate == null || onStartFinishDate == null) { + return; + } + final long now = SystemClock.uptimeMillis(); + final @NotNull SentryDate nowDate = AndroidDateUtils.getCurrentSentryDateTime(); + final long onCreateShiftMs = + TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onCreateSpan.getStartDate())); + final long onCreateStopShiftMs = + TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onCreateFinishDate)); + final long onStartShiftMs = + TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onStartSpan.getStartDate())); + final long onStartStopShiftMs = TimeUnit.NANOSECONDS.toMillis(nowDate.diff(onStartFinishDate)); + + ActivityLifecycleTimeSpan activityLifecycleTimeSpan = new ActivityLifecycleTimeSpan(); + activityLifecycleTimeSpan + .getOnCreate() + .setup( + onCreateSpan.getDescription(), + TimeUnit.NANOSECONDS.toMillis(onCreateSpan.getStartDate().nanoTimestamp()), + now - onCreateShiftMs, + now - onCreateStopShiftMs); + activityLifecycleTimeSpan + .getOnStart() + .setup( + onStartSpan.getDescription(), + TimeUnit.NANOSECONDS.toMillis(onStartSpan.getStartDate().nanoTimestamp()), + now - onStartShiftMs, + now - onStartStopShiftMs); + AppStartMetrics.getInstance().addActivityLifecycleTimeSpans(activityLifecycleTimeSpan); + } + + private @NotNull ISpan createLifecycleSpan( + final @NotNull ISpan appStartSpan, + final @NotNull String description, + final @NotNull SentryDate startTimestamp) { + final @NotNull ISpan span = + appStartSpan.startChild( + APP_METRICS_ACTIVITIES_OP, description, startTimestamp, Instrumenter.SENTRY); + setDefaultStartSpanData(span); + return span; + } + + public void clear() { + // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid + // memory leak + if (onCreateSpan != null && !onCreateSpan.isFinished()) { + onCreateSpan.finish(SpanStatus.CANCELLED); + } + onCreateSpan = null; + if (onStartSpan != null && !onStartSpan.isFinished()) { + onStartSpan.finish(SpanStatus.CANCELLED); + } + onStartSpan = null; + } + + private void setDefaultStartSpanData(final @NotNull ISpan span) { + span.setData(SpanDataConvention.THREAD_ID, Looper.getMainLooper().getThread().getId()); + span.setData(SpanDataConvention.THREAD_NAME, "main"); + span.setData(SpanDataConvention.CONTRIBUTES_TTID, true); + span.setData(SpanDataConvention.CONTRIBUTES_TTFD, true); + } +} 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 996c6ab171..5ee32b6f7b 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 @@ -61,6 +61,7 @@ public enum AppStartType { private @Nullable SentryDate onCreateTime = null; private boolean appLaunchTooLong = false; private boolean isCallbackRegistered = false; + private boolean shouldSendStartMeasurements = true; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -91,6 +92,22 @@ public AppStartMetrics() { return appStartSpan; } + /** + * @return the app start span Uses Process.getStartUptimeMillis() as start timestamp, which + * requires API level 24+ + */ + public @NotNull TimeSpan createProcessInitSpan() { + // AppStartSpan and CLASS_LOADED_UPTIME_MS can be modified at any time. + // So, we cannot cache the processInitSpan, but we need to create it when needed. + final @NotNull TimeSpan processInitSpan = new TimeSpan(); + processInitSpan.setup( + "Process Initialization", + appStartSpan.getStartTimestampMs(), + appStartSpan.getStartUptimeMs(), + CLASS_LOADED_UPTIME_MS); + return processInitSpan; + } + /** * @return the SDK init time span, as measured pre-performance-v2 Uses ContentProvider/Sdk init * time as start timestamp @@ -118,6 +135,10 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } + public boolean isColdStartValid() { + return appLaunchedInForeground && !appLaunchTooLong; + } + @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; @@ -129,21 +150,41 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { * @return A sorted list of all onCreate calls */ public @NotNull List getContentProviderOnCreateTimeSpans() { - final List measurements = new ArrayList<>(contentProviderOnCreates.values()); - Collections.sort(measurements); - return measurements; + final List spans = new ArrayList<>(contentProviderOnCreates.values()); + Collections.sort(spans); + return spans; } public @NotNull List getActivityLifecycleTimeSpans() { - final List measurements = new ArrayList<>(activityLifecycles); - Collections.sort(measurements); - return measurements; + final List spans = new ArrayList<>(activityLifecycles); + Collections.sort(spans); + return spans; } public void addActivityLifecycleTimeSpans(final @NotNull ActivityLifecycleTimeSpan timeSpan) { activityLifecycles.add(timeSpan); } + public void onAppStartSpansSent() { + shouldSendStartMeasurements = false; + contentProviderOnCreates.clear(); + activityLifecycles.clear(); + } + + 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(); + } + public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } @@ -154,24 +195,20 @@ 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 validateAppStartSpan(appStartSpan); + return appStartSpan; } } // fallback: use sdk init time span, as it will always have a start time set - return validateAppStartSpan(getSdkInitTimeSpan()); - } - - private @NotNull TimeSpan validateAppStartSpan(final @NotNull TimeSpan appStartSpan) { - // If the app launch took too long or it was launched in the background we return an empty span - if (appLaunchTooLong || !appLaunchedInForeground) { - return new TimeSpan(); - } - return appStartSpan; + return getSdkInitTimeSpan(); } @TestOnly @@ -191,6 +228,7 @@ public void clear() { appLaunchedInForeground = false; onCreateTime = null; isCallbackRegistered = false; + shouldSendStartMeasurements = true; } public @Nullable ITransactionProfiler getAppStartProfiler() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java index dac78920f8..eb63173972 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/TimeSpan.java @@ -4,7 +4,6 @@ import io.sentry.DateUtils; import io.sentry.SentryDate; import io.sentry.SentryLongDate; -import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -21,17 +20,25 @@ public class TimeSpan implements Comparable { private @Nullable String description; - - private long startSystemNanos; private long startUnixTimeMs; private long startUptimeMs; private long stopUptimeMs; + public void setup( + final @Nullable String description, + final long startUnixTimeMs, + final long startUptimeMs, + final long stopUptimeMs) { + this.description = description; + this.startUnixTimeMs = startUnixTimeMs; + this.startUptimeMs = startUptimeMs; + this.stopUptimeMs = stopUptimeMs; + } + /** Start the time span */ public void start() { startUptimeMs = SystemClock.uptimeMillis(); startUnixTimeMs = System.currentTimeMillis(); - startSystemNanos = System.nanoTime(); } /** @@ -43,7 +50,6 @@ public void setStartedAt(final long uptimeMs) { final long shiftMs = SystemClock.uptimeMillis() - startUptimeMs; startUnixTimeMs = System.currentTimeMillis() - shiftMs; - startSystemNanos = System.nanoTime() - TimeUnit.MILLISECONDS.toNanos(shiftMs); } /** Stops the time span */ @@ -166,7 +172,6 @@ public void reset() { startUptimeMs = 0; stopUptimeMs = 0; startUnixTimeMs = 0; - startSystemNanos = 0; } @Override 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 675626522c..1a1d1dc7fe 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 @@ -339,9 +339,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityPostResumed(activity) verify(fixture.scopes, never()).captureTransaction( - check { - assertEquals(SpanStatus.OK, it.status) - }, + any(), anyOrNull(), anyOrNull(), anyOrNull() @@ -381,9 +379,9 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - sut.onActivityPostResumed(activity) - - verify(fixture.scopes, never()).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) + // We don't schedule the transaction to finish + assertFalse(fixture.transaction.isFinishing()) + assertFalse(fixture.transaction.isFinished) } @Test @@ -468,7 +466,7 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When Activity is destroyed, sets ttidSpan status to deadline_exceeded and finish it`() { + fun `When Activity is destroyed, finish ttidSpan with deadline_exceeded and remove from map`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) @@ -477,6 +475,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() sut.onActivityCreated(activity, fixture.bundle) + assertNotNull(sut.ttidSpanMap[activity]) sut.onActivityDestroyed(activity) val span = fixture.transaction.children.first { it.operation == ActivityLifecycleIntegration.TTID_OP } @@ -501,7 +500,7 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When Activity is destroyed, sets ttfdSpan status to deadline_exceeded and finish it`() { + fun `When Activity is destroyed, finish ttfdSpan with deadline_exceeded and remove from map`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true @@ -511,6 +510,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() sut.onActivityCreated(activity, fixture.bundle) + assertNotNull(sut.ttfdSpanMap[activity]) sut.onActivityDestroyed(activity) val span = fixture.transaction.children.first { it.operation == ActivityLifecycleIntegration.TTFD_OP } @@ -548,7 +548,7 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `do not stop transaction on resumed if API 29`() { + fun `do not stop transaction on resumed`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) @@ -561,31 +561,11 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `do not stop transaction on resumed if API less than 29 and ttid and ttfd are finished`() { - val sut = fixture.getSut(Build.VERSION_CODES.P) - fixture.options.tracesSampleRate = 1.0 - fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - sut.onActivityCreated(activity, mock()) - sut.ttidSpanMap.values.first().finish() - sut.ttfdSpanMap.values.first().finish() - sut.onActivityResumed(activity) - - verify(fixture.scopes, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull()) - } - - @Test - fun `start transaction on created if API less than 29`() { - val sut = fixture.getSut(Build.VERSION_CODES.P) + fun `start transaction on created`() { + val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) - - setAppStartTime() - - val activity = mock() - sut.onActivityCreated(activity, mock()) + sut.onActivityCreated(mock(), mock()) verify(fixture.scopes).startTransaction(any(), any()) } @@ -595,6 +575,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true + fixture.options.idleTimeout = 0 sut.register(fixture.scopes, fixture.options) val activity = mock() @@ -603,6 +584,7 @@ class ActivityLifecycleIntegrationTest { sut.ttidSpanMap.values.first().finish() sut.onActivityResumed(activity) sut.onActivityPostResumed(activity) + runFirstDraw(fixture.createView()) assertNotNull(ttfd) assertFalse(ttfd.isFinished) @@ -683,15 +665,18 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When firstActivityCreated is true, start transaction with given appStartTime`() { + fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) + fixture.options.dateProvider = SentryDateProvider { date } val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) // call only once @@ -704,15 +689,17 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When firstActivityCreated is true and app start sampling decision is set, start transaction with isAppStart true`() { + fun `When firstActivityCreated is false and app start sampling decision is set, start transaction with isAppStart true`() { AppStartMetrics.getInstance().appStartSamplingDecision = mock() val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) verify(fixture.scopes).startTransaction( @@ -725,9 +712,10 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When firstActivityCreated is true and app start sampling decision is not set, start transaction with isAppStart false`() { + fun `When firstActivityCreated is false and app start sampling decision is not set, start transaction with isAppStart false`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) val date2 = SentryNanotimeDate(Date(2), 2) @@ -736,6 +724,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() // The activity onCreate date will be ignored fixture.options.dateProvider = SentryDateProvider { date2 } + sut.onActivityPreCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) verify(fixture.scopes).startTransaction( @@ -749,65 +738,71 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When firstActivityCreated is false and app start sampling decision is set, start transaction with isAppStart false`() { + fun `When firstActivityCreated is true and app start sampling decision is set, start transaction with isAppStart false`() { AppStartMetrics.getInstance().appStartSamplingDecision = mock() val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(true) + val date = SentryNanotimeDate(Date(1), 0) + setAppStartTime(date) val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) verify(fixture.scopes).startTransaction(any(), check { assertFalse(it.isAppStartTransaction) }) } @Test - fun `When firstActivityCreated is true, do not create app start span if not foregroundImportance`() { - val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_BACKGROUND) + fun `When firstActivityCreated is false and no app start time is set, default to onActivityPreCreated time`() { + val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(false) // usually set by SentryPerformanceProvider val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(2) + val date2 = SentryNanotimeDate(Date(2), 2) val activity = mock() + // Activity onCreate date will be used + fixture.options.dateProvider = SentryDateProvider { date2 } + sut.onActivityPreCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) - // call only once verify(fixture.scopes).startTransaction( any(), - check { assertNotEquals(date, it.startTimestamp) } + check { + assertEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + assertNotEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + } ) } @Test - fun `When firstActivityCreated is true and no app start time is set, default to onActivityCreated time`() { - val sut = fixture.getSut() + fun `When not foregroundImportance, do not create app start span`() { + val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_BACKGROUND) fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + setAppStartTime(date) val activity = mock() - // Activity onCreate date will be used - fixture.options.dateProvider = SentryDateProvider { date2 } + sut.onActivityPreCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) + // call only once verify(fixture.scopes).startTransaction( any(), - check { - assertEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) - assertNotEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) - } + check { assertNotEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) } ) } @Test fun `Create and finish app start span immediately in case SDK init is deferred`() { - val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) @@ -819,17 +814,19 @@ class ActivityLifecycleIntegrationTest { appStartMetrics.sdkInitTimeSpan.setStoppedAt(2) appStartMetrics.appStartTimeSpan.setStoppedAt(2) - val endDate = appStartMetrics.sdkInitTimeSpan.projectedStopTimestamp - val activity = mock() - sut.onActivityCreated(activity, fixture.bundle) + // An Activity already started, as SDK init is deferred + sut.onActivityPrePaused(activity) + sut.onActivityPaused(activity) + // And when we create a new Activity + sut.onActivityPreCreated(activity, null) + sut.onActivityCreated(activity, null) + sut.onActivityStopped(activity) + sut.onActivityDestroyed(activity) - val appStartSpanCount = fixture.transaction.children.count { - it.spanContext.operation.startsWith("app.start.warm") && - it.startDate.nanoTimestamp() == startDate.nanoTimestamp() && - it.finishDate!!.nanoTimestamp() == endDate!!.nanoTimestamp() - } - assertEquals(1, appStartSpanCount) + // No app start span is created + val appStartSpan = fixture.transaction.children.firstOrNull { it.operation.startsWith("app.start.warm") || it.operation.startsWith("app.start.cold") } + assertNull(appStartSpan) } @Test @@ -923,27 +920,31 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When firstActivityCreated is true, start app start warm span with given appStartTime`() { + 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.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) 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 true, start app start cold span with given appStartTime`() { + 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.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -953,41 +954,52 @@ class ActivityLifecycleIntegrationTest { 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 true, start app start span with Warm description`() { + 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.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) + 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, fixture.bundle) + sut.onActivityPreCreated(activity, null) + 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()) + assertNotEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } @Test - fun `When firstActivityCreated is true, start app start span with Cold description`() { + 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.scopes, fixture.options) + sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) val activity = mock() + sut.onActivityPreCreated(activity, null) sut.onActivityCreated(activity, null) val span = fixture.transaction.children.first() - assertEquals(span.description, "Cold Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) + assertEquals(span.operation, "app.start.warm") + assertEquals(span.description, "Warm Start") + assertNotEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } @Test @@ -1031,10 +1043,11 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When firstActivityCreated is false, start transaction but not with given appStartTime`() { + fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(true) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime() @@ -1043,11 +1056,6 @@ class ActivityLifecycleIntegrationTest { // First invocation: we expect to start a transaction with the appStartTime sut.onActivityCreated(activity, fixture.bundle) sut.onActivityPostResumed(activity) - assertEquals(date.nanoTimestamp(), fixture.transaction.startDate.nanoTimestamp()) - - val newActivity = mock() - // Second invocation: we expect to start a transaction with a different start timestamp - sut.onActivityCreated(newActivity, fixture.bundle) assertNotEquals(date.nanoTimestamp(), fixture.transaction.startDate.nanoTimestamp()) } @@ -1495,6 +1503,189 @@ class ActivityLifecycleIntegrationTest { assertEquals(now.nanoTimestamp(), fixture.transaction.startDate.nanoTimestamp()) } + @Test + fun `On activity preCreated onCreate span is started`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.scopes, fixture.options) + + val date = SentryNanotimeDate(Date(1), 0) + setAppStartTime(date) + + assertTrue(sut.activitySpanHelpers.isEmpty()) + + val activity = mock() + // Activity onPreCreate date will be used + sut.onActivityPreCreated(activity, fixture.bundle) + + assertFalse(sut.activitySpanHelpers.isEmpty()) + assertNotNull(sut.activitySpanHelpers[activity]!!.onCreateStartTimestamp) + } + + @Test + fun `Creates activity lifecycle spans`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + val appStartDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(Date(2), 0) + val appStartMetrics = AppStartMetrics.getInstance() + val activity = mock() + fixture.options.dateProvider = SentryDateProvider { startDate } + setAppStartTime(appStartDate) + + sut.register(fixture.scopes, fixture.options) + assertTrue(sut.activitySpanHelpers.isEmpty()) + + sut.onActivityPreCreated(activity, null) + + assertFalse(sut.activitySpanHelpers.isEmpty()) + val helper = sut.activitySpanHelpers.values.first() + assertNotNull(helper.onCreateStartTimestamp) + assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) + + sut.onActivityCreated(activity, null) + assertNotNull(sut.appStartSpan) + + sut.onActivityPostCreated(activity, null) + assertTrue(helper.onCreateSpan!!.isFinished) + + sut.onActivityPreStarted(activity) + assertNotNull(helper.onStartStartTimestamp) + + sut.onActivityStarted(activity) + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + + sut.onActivityPostStarted(activity) + assertTrue(helper.onStartSpan!!.isFinished) + assertFalse(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + } + + @Test + fun `Save activity lifecycle spans in AppStartMetrics onPostSarted`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + val appStartMetrics = AppStartMetrics.getInstance() + val activity = mock() + setAppStartTime() + + sut.register(fixture.scopes, fixture.options) + assertTrue(sut.activitySpanHelpers.isEmpty()) + + sut.onActivityPreCreated(activity, null) + sut.onActivityCreated(activity, null) + sut.onActivityPostCreated(activity, null) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + sut.onActivityPostStarted(activity) + assertFalse(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + } + + @Test + fun `Creates activity lifecycle spans on API lower than 29`() { + val sut = fixture.getSut(apiVersion = Build.VERSION_CODES.P) + fixture.options.tracesSampleRate = 1.0 + val appStartDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(Date(2), 0) + val appStartMetrics = AppStartMetrics.getInstance() + val activity = mock() + fixture.options.dateProvider = SentryDateProvider { startDate } + setAppStartTime(appStartDate) + + sut.register(fixture.scopes, fixture.options) + assertTrue(sut.activitySpanHelpers.isEmpty()) + + sut.onActivityCreated(activity, null) + + assertFalse(sut.activitySpanHelpers.isEmpty()) + val helper = sut.activitySpanHelpers.values.first() + assertNotNull(helper.onCreateStartTimestamp) + assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) + assertNotNull(sut.appStartSpan) + + sut.onActivityStarted(activity) + assertTrue(helper.onCreateSpan!!.isFinished) + assertNotNull(helper.onStartStartTimestamp) + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + + sut.onActivityResumed(activity) + assertTrue(helper.onStartSpan!!.isFinished) + assertFalse(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + } + + @Test + fun `Save activity lifecycle spans in AppStartMetrics onResumed on API lower than 29`() { + val sut = fixture.getSut(apiVersion = Build.VERSION_CODES.P) + fixture.options.tracesSampleRate = 1.0 + val appStartMetrics = AppStartMetrics.getInstance() + val activity = mock() + setAppStartTime() + + sut.register(fixture.scopes, fixture.options) + assertTrue(sut.activitySpanHelpers.isEmpty()) + + sut.onActivityCreated(activity, null) + sut.onActivityStarted(activity) + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + sut.onActivityResumed(activity) + assertFalse(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + } + + @Test + fun `Does not add activity lifecycle spans when firstActivityCreated is true`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + val appStartDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(Date(2), 0) + val appStartMetrics = AppStartMetrics.getInstance() + val activity = mock() + fixture.options.dateProvider = SentryDateProvider { startDate } + setAppStartTime(appStartDate) + sut.register(fixture.scopes, fixture.options) + sut.setFirstActivityCreated(true) + + sut.onActivityPreCreated(activity, null) + sut.onActivityCreated(activity, null) + sut.onActivityPostCreated(activity, null) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + } + + @Test + fun `When firstActivityCreated is false and app start span has stopped, restart app start to current date`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + val appStartDate = SentryNanotimeDate(Date(1), 0) + val appStartMetrics = AppStartMetrics.getInstance() + val activity = mock() + setAppStartTime(appStartDate) + // Let's pretend app start started and finished + appStartMetrics.appStartTimeSpan.stop() + sut.register(fixture.scopes, fixture.options) + + assertEquals(0, sut.getProperty("lastPausedUptimeMillis")) + + // An Activity (the first) is created after app start has finished + sut.onActivityPreCreated(activity, null) + // lastPausedUptimeMillis is set to current SystemClock.uptimeMillis() + val lastUptimeMillis = sut.getProperty("lastPausedUptimeMillis") + assertNotEquals(0, lastUptimeMillis) + + sut.onActivityPreCreated(activity, null) + 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") + private fun runFirstDraw(view: View) { // Removes OnDrawListener in the next OnGlobalLayout after onDraw view.viewTreeObserver.dispatchOnDraw() 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 5d6e915c0b..fd71fa08a8 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 @@ -1288,7 +1288,7 @@ class ManifestMetadataReaderTest { fun `applyMetadata does not override replays onErrorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() + fixture.options.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1296,7 +1296,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate) } @Test @@ -1336,7 +1336,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate) } @Test @@ -1348,7 +1348,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertNull(fixture.options.sessionReplay.onErrorSampleRate) } @Test @@ -1361,8 +1361,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1374,8 +1374,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1399,8 +1399,8 @@ class ManifestMetadataReaderTest { assertEquals(expectedSampleRate.toDouble(), fixture.options.sampleRate) assertEquals(expectedSampleRate.toDouble(), fixture.options.tracesSampleRate) assertEquals(expectedSampleRate.toDouble(), fixture.options.profilesSampleRate) - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.sessionSampleRate) - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.sessionSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate) } @Test 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 529731c634..b491dcd088 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 @@ -13,6 +13,7 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -59,13 +60,13 @@ class PerformanceAndroidEventProcessorTest { private val fixture = Fixture() - private fun createAppStartSpan(traceId: SentryId) = SentrySpan( + private fun createAppStartSpan(traceId: SentryId, coldStart: Boolean = true) = SentrySpan( 0.0, 1.0, traceId, SpanId(), null, - APP_START_COLD, + if (coldStart) APP_START_COLD else APP_START_WARM, "App Start", SpanStatus.OK, null, @@ -278,17 +279,52 @@ class PerformanceAndroidEventProcessorTest { "application.load" == it.op } ) + } - assertTrue( - tr.spans.any { - "activity.load" == it.op && "MainActivity.onCreate" == it.description - } - ) - assertTrue( - tr.spans.any { - "activity.load" == it.op && "MainActivity.onStart" == it.description - } - ) + @Test + fun `does not add app start metrics to app warm start txn`() { + // given some app start metrics + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.WARM + appStartMetrics.appStartTimeSpan.setStartedAt(123) + appStartMetrics.appStartTimeSpan.setStoppedAt(456) + + val contentProvider = mock() + AppStartMetrics.onContentProviderCreate(contentProvider) + AppStartMetrics.onContentProviderPostCreate(contentProvider) + + appStartMetrics.applicationOnCreateTimeSpan.apply { + setStartedAt(10) + setStoppedAt(42) + } + + val activityTimeSpan = ActivityLifecycleTimeSpan() + activityTimeSpan.onCreate.description = "MainActivity.onCreate" + activityTimeSpan.onStart.description = "MainActivity.onStart" + + activityTimeSpan.onCreate.setStartedAt(200) + activityTimeSpan.onStart.setStartedAt(220) + activityTimeSpan.onStart.setStoppedAt(240) + activityTimeSpan.onCreate.setStoppedAt(260) + appStartMetrics.addActivityLifecycleTimeSpans(activityTimeSpan) + + // when an activity transaction is created + val sut = fixture.getSut(enablePerformanceV2 = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.scopes) + var tr = SentryTransaction(tracer) + + // and it contains an app.start.warm span + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId, false) + tr.spans.add(appStartSpan) + + // then the app start metrics should be attached + tr = sut.process(tr, Hint()) + + // process init, content provider and application span should not be attached + assertFalse(tr.spans.any { "process.load" == it.op }) + assertFalse(tr.spans.any { "contentprovider.load" == it.op }) + assertFalse(tr.spans.any { "application.load" == it.op }) } @Test @@ -443,8 +479,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - // then the app start metrics should not be attached + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + // then the app start metrics should be attached tr = sut.process(tr, Hint()) + assertFalse(appStartMetrics.shouldSendStartMeasurements()) assertTrue( tr.spans.any { 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 71667394a2..efef393dc7 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 @@ -167,6 +167,19 @@ class SentryAndroidOptionsTest { assertTrue(SentryAndroidOptions().isEnableScopeSync) } + @Test + fun `ndk handler option defaults to default strategy`() { + val sentryOptions = SentryAndroidOptions() + assertEquals(NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT.value, sentryOptions.ndkHandlerStrategy) + } + + @Test + fun `ndk handler strategy option can be changed`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.setNativeHandlerStrategy(NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) + assertEquals(NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START.value, sentryOptions.ndkHandlerStrategy) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null override fun clearDebugImages() {} 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 c7c6f8bbae..bcba616905 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 @@ -328,10 +328,10 @@ class SentryAndroidTest { whenever(client.isEnabled).thenReturn(true) initSentryWithForegroundImportance(true, { options -> - options.addIntegration { hub, _ -> - hub.bindClient(client) + options.addIntegration { scopes, _ -> + scopes.bindClient(client) // usually done by LifecycleWatcher - hub.startSession() + scopes.startSession() } }) {} @@ -375,7 +375,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.sessionReplay.onErrorSampleRate = 1.0 + options.sessionReplay.onErrorSampleRate = 1.0 optionsConfig(options) } 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 26a76af30e..1fb44774f1 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 @@ -3,7 +3,6 @@ package io.sentry.android.core import android.app.Application import android.content.pm.ProviderInfo import android.os.Build -import android.os.Bundle import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.JsonSerializer @@ -12,7 +11,6 @@ import io.sentry.SentryAppStartProfilingOptions import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.android.core.performance.AppStartMetrics -import io.sentry.android.core.performance.AppStartMetrics.AppStartType import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -28,7 +26,6 @@ import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -104,73 +101,6 @@ class SentryPerformanceProviderTest { assertTrue(AppStartMetrics.getInstance().appStartTimeSpan.hasStarted()) } - @Test - fun `provider sets cold start based on first activity`() { - val provider = fixture.getSut() - - // up until this point app start is not known - assertEquals(AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) - - // when there's no saved state - provider.activityCallback!!.onActivityCreated(mock(), null) - // then app start should be cold - assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `provider sets warm start based on first activity`() { - val provider = fixture.getSut() - - // up until this point app start is not known - assertEquals(AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) - - // when there's a saved state - provider.activityCallback!!.onActivityCreated(mock(), Bundle()) - - // then app start should be warm - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `provider keeps startup state even if multiple activities are launched`() { - val provider = fixture.getSut() - - // when there's a saved state - provider.activityCallback!!.onActivityCreated(mock(), Bundle()) - - // then app start should be warm - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - - // when another activity is launched cold - provider.activityCallback!!.onActivityCreated(mock(), null) - - // then app start should remain warm - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `provider sets both appstart and sdk init start + end times`() { - val provider = fixture.getSut() - provider.onAppStartDone() - - 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 @Test fun `when config file does not exists, nothing happens`() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt new file mode 100644 index 0000000000..d1225c5aee --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt @@ -0,0 +1,186 @@ +package io.sentry.android.core.performance + +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryNanotimeDate +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.Span +import io.sentry.SpanDataConvention +import io.sentry.SpanOptions +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Date +import java.util.concurrent.TimeUnit +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class ActivityLifecycleSpanHelperTest { + private class Fixture { + val appStartSpan: ISpan + val scopes = mock() + val options = SentryOptions() + val date = SentryNanotimeDate(Date(1), 1000000) + val endDate = SentryNanotimeDate(Date(3), 3000000) + + init { + whenever(scopes.options).thenReturn(options) + appStartSpan = Span( + TransactionContext("name", "op", TracesSamplingDecision(true)), + SentryTracer(TransactionContext("name", "op", TracesSamplingDecision(true)), scopes), + scopes, + SpanOptions() + ) + } + fun getSut(activityName: String = "ActivityName"): ActivityLifecycleSpanHelper { + return ActivityLifecycleSpanHelper(activityName) + } + } + private val fixture = Fixture() + + @BeforeTest + fun setup() { + AppStartMetrics.getInstance().clear() + } + + @Test + fun `createAndStopOnCreateSpan creates and finishes onCreate span`() { + val helper = fixture.getSut() + val date = SentryNanotimeDate(Date(1), 1) + helper.setOnCreateStartTimestamp(date) + helper.createAndStopOnCreateSpan(fixture.appStartSpan) + + val onCreateSpan = helper.onCreateSpan + assertNotNull(onCreateSpan) + assertTrue(onCreateSpan.isFinished) + + assertEquals("activity.load", onCreateSpan.operation) + assertEquals("ActivityName.onCreate", onCreateSpan.description) + assertEquals(date.nanoTimestamp(), onCreateSpan.startDate.nanoTimestamp()) + assertEquals(date.nanoTimestamp(), onCreateSpan.startDate.nanoTimestamp()) + + assertEquals(Looper.getMainLooper().thread.id, onCreateSpan.getData(SpanDataConvention.THREAD_ID)) + assertEquals("main", onCreateSpan.getData(SpanDataConvention.THREAD_NAME)) + assertEquals(true, onCreateSpan.getData(SpanDataConvention.CONTRIBUTES_TTID)) + assertEquals(true, onCreateSpan.getData(SpanDataConvention.CONTRIBUTES_TTFD)) + } + + @Test + fun `createAndStopOnCreateSpan does nothing if no onCreate start timestamp is available`() { + val helper = fixture.getSut() + helper.createAndStopOnCreateSpan(fixture.appStartSpan) + assertNull(helper.onCreateSpan) + } + + @Test + fun `createAndStopOnCreateSpan does nothing if passed appStartSpan is null`() { + val helper = fixture.getSut() + helper.setOnCreateStartTimestamp(SentryNanotimeDate()) + helper.createAndStopOnCreateSpan(null) + assertNull(helper.onCreateSpan) + } + + @Test + fun `createAndStopOnStartSpan creates and finishes onStart span`() { + val helper = fixture.getSut() + val date = SentryNanotimeDate(Date(1), 1) + helper.setOnStartStartTimestamp(date) + helper.createAndStopOnStartSpan(fixture.appStartSpan) + + val onStartSpan = helper.onStartSpan + assertNotNull(onStartSpan) + assertTrue(onStartSpan.isFinished) + + assertEquals("activity.load", onStartSpan.operation) + assertEquals("ActivityName.onStart", onStartSpan.description) + assertEquals(date.nanoTimestamp(), onStartSpan.startDate.nanoTimestamp()) + assertEquals(date.nanoTimestamp(), onStartSpan.startDate.nanoTimestamp()) + + assertEquals(Looper.getMainLooper().thread.id, onStartSpan.getData(SpanDataConvention.THREAD_ID)) + assertEquals("main", onStartSpan.getData(SpanDataConvention.THREAD_NAME)) + assertEquals(true, onStartSpan.getData(SpanDataConvention.CONTRIBUTES_TTID)) + assertEquals(true, onStartSpan.getData(SpanDataConvention.CONTRIBUTES_TTFD)) + } + + @Test + fun `createAndStopOnStartSpan does nothing if no onStart start timestamp is available`() { + val helper = fixture.getSut() + helper.createAndStopOnStartSpan(fixture.appStartSpan) + assertNull(helper.onStartSpan) + } + + @Test + fun `createAndStopOnStartSpan does nothing if passed appStartSpan is null`() { + val helper = fixture.getSut() + helper.setOnStartStartTimestamp(SentryNanotimeDate()) + helper.createAndStopOnStartSpan(null) + assertNull(helper.onStartSpan) + } + + @Test + fun `saveSpanToAppStartMetrics does nothing if onCreate span is null`() { + val helper = fixture.getSut() + helper.setOnCreateStartTimestamp(fixture.date) + helper.setOnStartStartTimestamp(fixture.date) + helper.createAndStopOnStartSpan(fixture.appStartSpan) + assertNull(helper.onCreateSpan) + assertNotNull(helper.onStartSpan) + } + + @Test + fun `saveSpanToAppStartMetrics does nothing if onStart span is null`() { + val helper = fixture.getSut() + helper.setOnCreateStartTimestamp(fixture.date) + helper.createAndStopOnCreateSpan(fixture.appStartSpan) + helper.setOnStartStartTimestamp(fixture.date) + assertNotNull(helper.onCreateSpan) + assertNull(helper.onStartSpan) + } + + @Test + fun `saveSpanToAppStartMetrics saves spans to AppStartMetrics`() { + val helper = fixture.getSut() + helper.setOnCreateStartTimestamp(fixture.date) + helper.createAndStopOnCreateSpan(fixture.appStartSpan) + helper.onCreateSpan!!.updateEndDate(fixture.endDate) + helper.setOnStartStartTimestamp(fixture.date) + helper.createAndStopOnStartSpan(fixture.appStartSpan) + helper.onStartSpan!!.updateEndDate(fixture.endDate) + assertNotNull(helper.onCreateSpan) + assertNotNull(helper.onStartSpan) + + val appStartMetrics = AppStartMetrics.getInstance() + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + + // Save spans to AppStartMetrics + helper.saveSpanToAppStartMetrics() + assertFalse(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + val onCreate = appStartMetrics.activityLifecycleTimeSpans.first().onCreate + val onStart = appStartMetrics.activityLifecycleTimeSpans.first().onStart + + // Check onCreate TimeSpan has same values as helper.onCreateSpan + assertNotNull(onCreate) + assertEquals(helper.onCreateSpan!!.startDate.nanoTimestamp(), onCreate.startTimestamp!!.nanoTimestamp()) + val spanOnCreateDurationNanos = helper.onCreateSpan!!.finishDate!!.diff(helper.onCreateSpan!!.startDate) + assertEquals(onCreate.durationMs, TimeUnit.NANOSECONDS.toMillis(spanOnCreateDurationNanos)) + assertEquals(onCreate.description, helper.onCreateSpan!!.description) + + // Check onStart TimeSpan has same values as helper.onStartSpan + assertNotNull(onStart) + assertEquals(helper.onStartSpan!!.startDate.nanoTimestamp(), onStart.startTimestamp!!.nanoTimestamp()) + val spanOnStartDurationNanos = helper.onStartSpan!!.finishDate!!.diff(helper.onStartSpan!!.startDate) + assertEquals(onStart.durationMs, TimeUnit.NANOSECONDS.toMillis(spanOnStartDurationNanos)) + assertEquals(onStart.description, helper.onStartSpan!!.description) + } +} 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 eb0e85dc28..86edd79b4f 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 @@ -5,7 +5,9 @@ import android.content.ContentProvider import android.os.Build import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils import io.sentry.ITransactionProfiler +import io.sentry.SentryNanotimeDate import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess import org.junit.Before @@ -18,6 +20,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows import org.robolectric.annotation.Config +import java.util.Date import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals @@ -56,7 +59,7 @@ class AppStartMetricsTest { metrics.addActivityLifecycleTimeSpans(ActivityLifecycleTimeSpan()) AppStartMetrics.onApplicationCreate(mock()) AppStartMetrics.onContentProviderCreate(mock()) - metrics.setAppStartProfiler(mock()) + metrics.appStartProfiler = mock() metrics.appStartSamplingDecision = mock() metrics.clear() @@ -276,4 +279,69 @@ class AppStartMetricsTest { Shadows.shadowOf(Looper.getMainLooper()).idle() 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 + appStartTimeSpan.start() + appStartTimeSpan.stop() + appStartTimeSpan.setStartedAt(1) + appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + assertFalse(AppStartMetrics.getInstance().isColdStartValid) + } + + @Test + fun `onAppStartSpansSent set measurement flag and clear internal lists`() { + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.addActivityLifecycleTimeSpans(mock()) + appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + appStartMetrics.onAppStartSpansSent() + assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) + assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) + assertFalse(appStartMetrics.shouldSendStartMeasurements()) + } + + @Test + fun `restartAppStart set measurement flag and clear internal lists`() { + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.onAppStartSpansSent() + appStartMetrics.isAppLaunchedInForeground = false + assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.isColdStartValid) + + appStartMetrics.restartAppStart(10) + + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.isColdStartValid) + assertTrue(appStartMetrics.appStartTimeSpan.hasStarted()) + assertTrue(appStartMetrics.appStartTimeSpan.hasNotStopped()) + assertEquals(10, appStartMetrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `createProcessInitSpan creates a span`() { + val appStartMetrics = AppStartMetrics.getInstance() + val startDate = SentryNanotimeDate(Date(1), 1000000) + appStartMetrics.classLoadedUptimeMs = 10 + val startMillis = DateUtils.nanosToMillis(startDate.nanoTimestamp().toDouble()).toLong() + appStartMetrics.appStartTimeSpan.setStartedAt(1) + appStartMetrics.appStartTimeSpan.setStartUnixTimeMs(startMillis) + val span = appStartMetrics.createProcessInitSpan() + + assertEquals("Process Initialization", span.description) + // Start timestampMs is taken by appStartSpan + assertEquals(startMillis, span.startTimestampMs) + // Start uptime is taken by appStartSpan and stop uptime is class loaded uptime: 10 - 1 + assertEquals(9, span.durationMs) + // Class loaded uptimeMs is 10 ms, and process init span should finish at the same ms + assertEquals(10, span.projectedStopTimestampMs) + } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index dc4dbd171f..e3afc9823f 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -26,6 +26,7 @@ android { // This doesn't work on some devices with Android 11+. Clearing package data resets permissions. // Check the readme for more info. testInstrumentationRunnerArguments["clearPackageData"] = "true" + buildConfigField("String", "ENVIRONMENT", "\"${System.getProperty("environment", "")}\"") } testOptions { @@ -125,6 +126,7 @@ dependencies { androidTestImplementation(Config.TestLibs.mockWebserver) androidTestImplementation(Config.TestLibs.androidxJunit) androidTestImplementation(Config.TestLibs.leakCanaryInstrumentation) + androidTestImplementation(Config.TestLibs.awaitility3) androidTestUtil(Config.TestLibs.androidxTestOrchestrator) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro index 02f5e80ba3..5de2dac4bd 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -40,3 +40,4 @@ -dontwarn org.mockito.internal.** -dontwarn org.jetbrains.annotations.** -dontwarn io.sentry.android.replay.ReplayIntegration +-keep class curtains.** { *; } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt new file mode 100644 index 0000000000..96fb906609 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -0,0 +1,84 @@ +package io.sentry.uitest.android + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import io.sentry.SentryOptions +import leakcanary.LeakAssertions +import leakcanary.LeakCanary +import org.awaitility.kotlin.await +import org.hamcrest.CoreMatchers.`is` +import org.junit.Assume.assumeThat +import org.junit.Before +import shark.AndroidReferenceMatchers +import shark.IgnoredReferenceMatcher +import shark.ReferencePattern +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test + +class ReplayTest : BaseUiTest() { + + @Before + fun setup() { + // we can't run on GH actions emulator, because they don't allow capturing screenshots properly + @Suppress("KotlinConstantConditions") + assumeThat( + BuildConfig.ENVIRONMENT != "github", + `is`(true) + ) + } + + @Test + fun composeReplayDoesNotLeak() { + val sent = AtomicBoolean(false) + + LeakCanary.config = LeakCanary.config.copy( + referenceMatchers = AndroidReferenceMatchers.appDefaults + + listOf( + IgnoredReferenceMatcher( + ReferencePattern.InstanceFieldPattern( + "com.saucelabs.rdcinjector.testfairy.TestFairyEventQueue", + "context" + ) + ), + // Seems like a false-positive returned by LeakCanary when curtains is used in + // the host application (LeakCanary uses it itself internally). We use kind of + // the same approach which possibly clashes with LeakCanary's internal state. + // Only the case when replay is enabled. + // TODO: check if it's actually a leak on our side, or a false-positive and report to LeakCanary's github issue tracker + IgnoredReferenceMatcher( + ReferencePattern.InstanceFieldPattern( + "curtains.internal.RootViewsSpy", + "delegatingViewList" + ) + ) + ) + ('a'..'z').map { char -> + IgnoredReferenceMatcher( + ReferencePattern.StaticFieldPattern( + "com.testfairy.modules.capture.TouchListener", + "$char" + ) + ) + } + ) + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + initSentry { + it.sessionReplay.sessionSampleRate = 1.0 + + it.beforeSendReplay = + SentryOptions.BeforeSendReplayCallback { event, _ -> + sent.set(true) + event + } + } + + // wait until first segment is being sent + await.untilTrue(sent) + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + + LeakAssertions.assertNoLeaks() + } +} diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 906e848bb5..a7f7931326 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -43,12 +43,12 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public static final field $stable I public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; - public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V public static synthetic fun addFrame$default (Lio/sentry/android/replay/ReplayCache;Ljava/io/File;JLjava/lang/String;ILjava/lang/Object;)V public fun close ()V - public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; - public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun createVideoOf (JJIIIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V public final fun rotate (J)Ljava/lang/String; } @@ -60,8 +60,8 @@ public final class io/sentry/android/replay/ReplayCache$Companion { public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V - public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; 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 dcbdd84360..d67605505d 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 @@ -39,8 +39,7 @@ import java.util.concurrent.atomic.AtomicBoolean */ public class ReplayCache( private val options: SentryOptions, - private val replayId: SentryId, - private val recorderConfig: ScreenshotRecorderConfig + private val replayId: SentryId ) : Closeable { private val isClosed = AtomicBoolean(false) @@ -88,7 +87,7 @@ public class ReplayCache( it.createNewFile() } screenshot.outputStream().use { - bitmap.compress(JPEG, 80, it) + bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it) it.flush() } @@ -135,6 +134,8 @@ public class ReplayCache( segmentId: Int, height: Int, width: Int, + frameRate: Int, + bitRate: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { if (videoFile.exists() && videoFile.length() > 0) { @@ -148,7 +149,6 @@ public class ReplayCache( return null } - // TODO: reuse instance of encoder and just change file path to create a different muxer encoder = encoderLock.acquire().use { SimpleVideoEncoder( options, @@ -156,15 +156,15 @@ public class ReplayCache( file = videoFile, recordingHeight = height, recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate + frameRate = frameRate, + bitRate = bitRate ) ).also { it.start() } } - val step = 1000 / recorderConfig.frameRate.toLong() + val step = 1000 / frameRate.toLong() var frameCount = 0 - var lastFrame: ReplayFrame = frames.first() + var lastFrame: ReplayFrame? = frames.first() for (timestamp in from until (from + (duration)) step step) { val iter = frames.iterator() while (iter.hasNext()) { @@ -184,6 +184,12 @@ public class ReplayCache( // to respect the video duration if (encode(lastFrame)) { frameCount++ + } else if (lastFrame != null) { + // if we failed to encode the frame, we delete the screenshot right away as the + // likelihood of it being able to be encoded later is low + deleteFile(lastFrame.screenshot) + frames.remove(lastFrame) + lastFrame = null } } @@ -208,7 +214,10 @@ public class ReplayCache( return GeneratedVideo(videoFile, frameCount, videoDuration) } - private fun encode(frame: ReplayFrame): Boolean { + private fun encode(frame: ReplayFrame?): Boolean { + if (frame == null) { + return false + } return try { val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) encoderLock.acquire().use { @@ -309,7 +318,7 @@ public class ReplayCache( } } - internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? { + internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null): LastSegmentData? { val replayCacheDir = makeReplayCacheDir(options, replayId) val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) if (!lastSegmentFile.exists()) { @@ -363,7 +372,7 @@ public class ReplayCache( scaleFactorY = 1.0f ) - val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + val cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) cache.replayCacheDir?.listFiles { dir, name -> if (name.endsWith(".jpg")) { val file = File(dir, name) 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 5663b80f63..07ecf47756 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 @@ -29,6 +29,7 @@ import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback import io.sentry.android.replay.util.MainLooperHandler 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 @@ -46,15 +47,16 @@ import io.sentry.util.Random import java.io.Closeable import java.io.File import java.util.LinkedList +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean -import kotlin.LazyThreadSafetyMode.NONE public class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider, private val recorderProvider: (() -> Recorder)? = null, private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, - private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : Integration, Closeable, ScreenshotRecorderCallback, @@ -78,7 +80,7 @@ public class ReplayIntegration( dateProvider: ICurrentDateProvider, recorderProvider: (() -> Recorder)?, recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, - replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)?, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)?, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null @@ -93,7 +95,10 @@ public class ReplayIntegration( private var recorder: Recorder? = null private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } - private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } + internal val rootViewsSpy by lazy { RootViewsSpy.install() } + private val replayExecutor by lazy { + Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) @@ -105,8 +110,6 @@ public class ReplayIntegration( private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null - private lateinit var recorderConfig: ScreenshotRecorderConfig - override fun register(scopes: IScopes, options: SentryOptions) { this.options = options @@ -115,24 +118,30 @@ public class ReplayIntegration( return } - if (!options.experimental.sessionReplay.isSessionReplayEnabled && - !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled + if (!options.sessionReplay.isSessionReplayEnabled && + !options.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return } this.scopes = scopes - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler, replayExecutor) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) options.connectionStatusProvider.addConnectionStatusObserver(this) scopes.rateLimiter?.addRateLimitObserver(this) - try { - context.registerComponentCallbacks(this) - } catch (e: Throwable) { - options.logger.log(INFO, "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", e) + if (options.sessionReplay.isTrackOrientationChange) { + try { + context.registerComponentCallbacks(this) + } catch (e: Throwable) { + options.logger.log( + INFO, + "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", + e + ) + } } addIntegrationToSdkVersion("Replay") @@ -158,17 +167,17 @@ public class ReplayIntegration( return } - val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate) - if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) { + val isFullSession = random.sample(options.sessionReplay.sessionSampleRate) + if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { options.logger.log(INFO, "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified") return } - recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { - SessionCaptureStrategy(options, scopes, dateProvider, replayCacheProvider = replayCacheProvider) + SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, replayCacheProvider) } else { - BufferCaptureStrategy(options, scopes, dateProvider, random, replayCacheProvider = replayCacheProvider) + BufferCaptureStrategy(options, scopes, dateProvider, random, replayExecutor, replayCacheProvider) } captureStrategy?.start(recorderConfig) @@ -229,7 +238,6 @@ public class ReplayIntegration( gestureRecorder?.stop() captureStrategy?.stop() isRecording.set(false) - captureStrategy?.close() captureStrategy = null } @@ -256,13 +264,17 @@ public class ReplayIntegration( options.connectionStatusProvider.removeConnectionStatusObserver(this) scopes?.rateLimiter?.removeRateLimitObserver(this) - try { - context.unregisterComponentCallbacks(this) - } catch (ignored: Throwable) { + if (options.sessionReplay.isTrackOrientationChange) { + try { + context.unregisterComponentCallbacks(this) + } catch (ignored: Throwable) { + } } stop() recorder?.close() recorder = null + rootViewsSpy.close() + replayExecutor.gracefullyShutdown(options) } override fun onConfigurationChanged(newConfig: Configuration) { @@ -273,7 +285,7 @@ public class ReplayIntegration( recorder?.stop() // refresh config based on new device configuration - recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) captureStrategy?.onConfigurationChanged(recorderConfig) recorder?.start(recorderConfig) @@ -386,6 +398,7 @@ public class ReplayIntegration( height = lastSegment.recorderConfig.recordingHeight, width = lastSegment.recorderConfig.recordingWidth, frameRate = lastSegment.recorderConfig.frameRate, + bitRate = lastSegment.recorderConfig.bitRate, cache = lastSegment.cache, replayType = lastSegment.replayType, screenAtStart = lastSegment.screenAtStart, @@ -404,4 +417,13 @@ public class ReplayIntegration( private class PreviousReplayHint : Backfillable { override fun shouldEnrich(): Boolean = false } + + private class ReplayExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayIntegration-" + cnt++) + ret.setDaemon(true) + return ret + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 734be2c06a..62752c74cc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap -import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix @@ -15,7 +14,6 @@ import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.view.PixelCopy import android.view.View -import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowManager import io.sentry.SentryLevel.DEBUG @@ -24,8 +22,9 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.addOnDrawListenerSafe import io.sentry.android.replay.util.getVisibleRects -import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.removeOnDrawListenerSafe import io.sentry.android.replay.util.submitSafely import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode @@ -33,8 +32,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarc import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File import java.lang.ref.WeakReference -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt @@ -43,22 +41,25 @@ import kotlin.math.roundToInt internal class ScreenshotRecorder( val config: ScreenshotRecorderConfig, val options: SentryOptions, - val mainLooperHandler: MainLooperHandler, + private val mainLooperHandler: MainLooperHandler, + private val recorder: ScheduledExecutorService, private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { - private val recorder by lazy { - Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) - } private var rootView: WeakReference? = null private val maskingPaint by lazy(NONE) { Paint() } private val singlePixelBitmap: Bitmap by lazy(NONE) { Bitmap.createBitmap( 1, 1, - Bitmap.Config.ARGB_8888 + Bitmap.Config.RGB_565 ) } + private val screenshot = Bitmap.createBitmap( + config.recordingWidth, + config.recordingHeight, + Bitmap.Config.RGB_565 + ) private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } private val prescaledMatrix by lazy(NONE) { Matrix().apply { @@ -67,7 +68,7 @@ internal class ScreenshotRecorder( } private val contentChanged = AtomicBoolean(false) private val isCapturing = AtomicBoolean(true) - private var lastScreenshot: Bitmap? = null + private val lastCaptureSuccessful = AtomicBoolean(false) fun capture() { if (!isCapturing.get()) { @@ -75,14 +76,10 @@ internal class ScreenshotRecorder( return } - if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) { + if (!contentChanged.get() && lastCaptureSuccessful.get()) { options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") - lastScreenshot?.let { - screenshotRecorderCallback?.onScreenshotRecorded( - it.copy(ARGB_8888, false) - ) - } + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) return } @@ -98,38 +95,33 @@ internal class ScreenshotRecorder( return } - val bitmap = Bitmap.createBitmap( - config.recordingWidth, - config.recordingHeight, - Bitmap.Config.ARGB_8888 - ) - // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible mainLooperHandler.post { try { contentChanged.set(false) PixelCopy.request( window, - bitmap, + screenshot, { copyResult: Int -> if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) - bitmap.recycle() + lastCaptureSuccessful.set(false) return@request } // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) if (contentChanged.get()) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - bitmap.recycle() + lastCaptureSuccessful.set(false) return@request } + // TODO: disableAllMasking here and dont traverse? val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy, options) recorder.submitSafely(options, "screenshot_recorder.mask") { - val canvas = Canvas(bitmap) + val canvas = Canvas(screenshot) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> if (node.shouldMask && (node.width > 0 && node.height > 0)) { @@ -143,7 +135,7 @@ internal class ScreenshotRecorder( val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { listOf(node.visibleRect) to - bitmap.dominantColorForRect(node.visibleRect) + screenshot.dominantColorForRect(node.visibleRect) } is TextViewHierarchyNode -> { @@ -170,20 +162,16 @@ internal class ScreenshotRecorder( return@traverse true } - val screenshot = bitmap.copy(ARGB_8888, false) screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - lastScreenshot?.recycle() - lastScreenshot = screenshot + lastCaptureSuccessful.set(true) contentChanged.set(false) - - bitmap.recycle() } }, mainLooperHandler.handler ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) - bitmap.recycle() + lastCaptureSuccessful.set(false) } } } @@ -205,13 +193,13 @@ internal class ScreenshotRecorder( // next bind the new root rootView = WeakReference(root) - root.viewTreeObserver?.addOnDrawListener(this) + root.addOnDrawListenerSafe(this) // invalidate the flag to capture the first frame after new window is attached contentChanged.set(true) } fun unbind(root: View?) { - root?.viewTreeObserver?.removeOnDrawListener(this) + root?.removeOnDrawListenerSafe(this) } fun pause() { @@ -221,16 +209,15 @@ internal class ScreenshotRecorder( fun resume() { // can't use bind() as it will invalidate the weakref - rootView?.get()?.viewTreeObserver?.addOnDrawListener(this) + rootView?.get()?.addOnDrawListenerSafe(this) isCapturing.set(true) } fun close() { unbind(rootView?.get()) rootView?.clear() - lastScreenshot?.recycle() + screenshot.recycle() isCapturing.set(false) - recorder.gracefullyShutdown(options) } private fun Bitmap.dominantColorForRect(rect: Rect): Int { @@ -255,37 +242,6 @@ internal class ScreenshotRecorder( // get the pixel color (= dominant color) return singlePixelBitmap.getPixel(0, 0) } - - private fun View.traverse(parentNode: ViewHierarchyNode) { - if (this !is ViewGroup) { - return - } - - if (this.childCount == 0) { - return - } - - val childNodes = ArrayList(this.childCount) - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child != null) { - val childNode = - ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) - childNodes.add(childNode) - child.traverse(childNode) - } - } - parentNode.children = childNodes - } - - private class RecorderExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayRecorder-" + cnt++) - ret.setDaemon(true) - return ret - } - } } public data class ScreenshotRecorderConfig( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 9e846dfcf0..a5de0f9a2c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -6,8 +6,10 @@ import io.sentry.SentryOptions import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.scheduleAtFixedRateSafely +import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit.MILLISECONDS @@ -17,7 +19,8 @@ import java.util.concurrent.atomic.AtomicBoolean internal class WindowRecorder( private val options: SentryOptions, private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, - private val mainLooperHandler: MainLooperHandler + private val mainLooperHandler: MainLooperHandler, + private val replayExecutor: ScheduledExecutorService ) : Recorder, OnRootViewsChangedListener { internal companion object { @@ -26,6 +29,7 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() + private val rootViewsLock = AutoClosableReentrantLock() private var recorder: ScreenshotRecorder? = null private var capturingTask: ScheduledFuture<*>? = null private val capturer by lazy { @@ -33,16 +37,20 @@ internal class WindowRecorder( } override fun onRootViewsChanged(root: View, added: Boolean) { - if (added) { - rootViews.add(WeakReference(root)) - recorder?.bind(root) - } else { - recorder?.unbind(root) - rootViews.removeAll { it.get() == root } + rootViewsLock.acquire().use { + if (added) { + rootViews.add(WeakReference(root)) + recorder?.bind(root) + } else { + recorder?.unbind(root) + rootViews.removeAll { it.get() == root } - val newRoot = rootViews.lastOrNull()?.get() - if (newRoot != null && root != newRoot) { - recorder?.bind(newRoot) + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null && root != newRoot) { + recorder?.bind(newRoot) + } else { + Unit // synchronized block wants us to return something lol + } } } } @@ -52,7 +60,9 @@ internal class WindowRecorder( return } - recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, screenshotRecorderCallback) + recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) + // TODO: change this to use MainThreadHandler and just post on the main thread with delay + // to avoid thread context switch every time capturingTask = capturer.scheduleAtFixedRateSafely( options, "$TAG.capture", @@ -72,9 +82,11 @@ internal class WindowRecorder( } override fun stop() { - rootViews.forEach { recorder?.unbind(it.get()) } + rootViewsLock.acquire().use { + rootViews.forEach { recorder?.unbind(it.get()) } + rootViews.clear() + } recorder?.close() - rootViews.clear() recorder = null capturingTask?.cancel(false) capturingTask = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 48c7eb5813..18f6ece8b5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -25,7 +25,10 @@ import android.os.Looper import android.util.Log import android.view.View import android.view.Window +import io.sentry.util.AutoClosableReentrantLock +import java.io.Closeable import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE /** @@ -41,35 +44,21 @@ internal val View.phoneWindow: Window? return WindowSpy.pullWindow(rootView) } +@SuppressLint("PrivateApi") internal object WindowSpy { /** - * Originally, DecorView was an inner class of PhoneWindow. In the initial import in 2009, - * PhoneWindow is in com.android.internal.policy.impl.PhoneWindow and that didn't change until - * API 23. - * In API 22: https://android.googlesource.com/platform/frameworks/base/+/android-5.1.1_r38/policy/src/com/android/internal/policy/impl/PhoneWindow.java - * PhoneWindow was then moved to android.view and then again to com.android.internal.policy - * https://android.googlesource.com/platform/frameworks/base/+/b10e33ff804a831c71be9303146cea892b9aeb5d - * https://android.googlesource.com/platform/frameworks/base/+/6711f3b34c2ad9c622f56a08b81e313795fe7647 - * In API 23: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/core/java/com/android/internal/policy/PhoneWindow.java - * Then DecorView moved out of PhoneWindow into its own class: + * DecorView moved out of PhoneWindow into its own class: * https://android.googlesource.com/platform/frameworks/base/+/8804af2b63b0584034f7ec7d4dc701d06e6a8754 * In API 24: https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/core/java/com/android/internal/policy/DecorView.java */ private val decorViewClass by lazy(NONE) { - val sdkInt = SDK_INT - // TODO: we can only consider API 26 - val decorViewClassName = when { - sdkInt >= 24 -> "com.android.internal.policy.DecorView" - sdkInt == 23 -> "com.android.internal.policy.PhoneWindow\$DecorView" - else -> "com.android.internal.policy.impl.PhoneWindow\$DecorView" - } try { - Class.forName(decorViewClassName) + Class.forName("com.android.internal.policy.DecorView") } catch (ignored: Throwable) { Log.d( "WindowSpy", - "Unexpected exception loading $decorViewClassName on API $sdkInt", + "Unexpected exception loading DecorView on API $SDK_INT", ignored ) null @@ -83,18 +72,16 @@ internal object WindowSpy { * https://android.googlesource.com/platform/frameworks/base/+/0daf2102a20d224edeb4ee45dd4ee91889ef3e0c * Then it was extracted into a separate class. * - * Hence the change of window field name from "this$0" to "mWindow" on API 24+. + * Hence we use "mWindow" on API 24+. */ private val windowField by lazy(NONE) { decorViewClass?.let { decorViewClass -> - val sdkInt = SDK_INT - val fieldName = if (sdkInt >= 24) "mWindow" else "this$0" try { - decorViewClass.getDeclaredField(fieldName).apply { isAccessible = true } + decorViewClass.getDeclaredField("mWindow").apply { isAccessible = true } } catch (ignored: NoSuchFieldException) { Log.d( "WindowSpy", - "Unexpected exception retrieving $decorViewClass#$fieldName on API $sdkInt", + "Unexpected exception retrieving $decorViewClass#mWindow on API $SDK_INT", ignored ) null @@ -134,13 +121,18 @@ internal fun interface OnRootViewsChangedListener { /** * A utility that holds the list of root views that WindowManager updates. */ -internal object RootViewsSpy { +internal class RootViewsSpy private constructor() : Closeable { + + private val isClosed = AtomicBoolean(false) + private val viewListLock = AutoClosableReentrantLock() val listeners: CopyOnWriteArrayList = object : CopyOnWriteArrayList() { override fun add(element: OnRootViewsChangedListener?): Boolean { - // notify listener about existing root views immediately - delegatingViewList.forEach { - element?.onRootViewsChanged(it, true) + viewListLock.acquire().use { + // notify listener about existing root views immediately + delegatingViewList.forEach { + element?.onRootViewsChanged(it, true) + } } return super.add(element) } @@ -168,13 +160,25 @@ internal object RootViewsSpy { } } - fun install(): RootViewsSpy { - return apply { - // had to do this as a first message of the main thread queue, otherwise if this is - // called from ContentProvider, it might be too early and the listener won't be installed - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } + override fun close() { + isClosed.set(true) + listeners.clear() + } + + companion object { + fun install(): RootViewsSpy { + return RootViewsSpy().apply { + // had to do this on the main thread queue, otherwise if this is + // called from ContentProvider, it might be too early and the listener won't be installed + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + if (isClosed.get()) { + return@postAtFrontOfQueue + } + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + viewListLock.acquire().use { + delegatingViewList.apply { addAll(mViews) } + } + } } } } @@ -206,9 +210,6 @@ internal object WindowManagerSpy { // You can discourage me all you want I'll still do it. @SuppressLint("PrivateApi", "ObsoleteSdkInt", "DiscouragedPrivateApi") fun swapWindowManagerGlobalMViews(swap: (ArrayList) -> ArrayList) { - if (SDK_INT < 19) { - return - } try { windowManagerInstance?.let { windowManagerInstance -> mViewsField?.let { mViewsField -> 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 c4ace6afd0..776348ed98 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 @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.annotation.TargetApi import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils @@ -14,25 +15,22 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID -import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment -import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter -import io.sentry.android.replay.util.PersistableLinkedList -import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent import io.sentry.transport.ICurrentDateProvider import java.io.File import java.util.Date -import java.util.LinkedList +import java.util.Deque +import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory @@ -42,12 +40,13 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +@TargetApi(26) internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, - executor: ScheduledExecutorService? = null, - private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + protected val replayExecutor: ScheduledExecutorService, + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : CaptureStrategy { internal companion object { @@ -81,16 +80,8 @@ internal abstract class BaseCaptureStrategy( override val replayCacheDir: File? get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) - protected val currentEvents: LinkedList = PersistableLinkedList( - propertyName = SEGMENT_KEY_REPLAY_RECORDING, - options, - persistingExecutor, - cacheProvider = { cache } - ) - - protected val replayExecutor: ScheduledExecutorService by lazy { - executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) - } + + protected val currentEvents: Deque = ConcurrentLinkedDeque() override fun start( recorderConfig: ScreenshotRecorderConfig, @@ -98,7 +89,7 @@ internal abstract class BaseCaptureStrategy( replayId: SentryId, replayType: ReplayType? ) { - cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) this.currentReplayId = replayId this.currentSegment = segmentId @@ -133,9 +124,10 @@ internal abstract class BaseCaptureStrategy( replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, frameRate: Int = recorderConfig.frameRate, + bitRate: Int = recorderConfig.bitRate, screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, - events: LinkedList = this.currentEvents + events: Deque = this.currentEvents ): ReplaySegment = createSegment( scopes, @@ -149,6 +141,7 @@ internal abstract class BaseCaptureStrategy( replayType, cache, frameRate, + bitRate, screenAtStart, breadcrumbs, events @@ -161,22 +154,7 @@ internal abstract class BaseCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { val rrwebEvents = gestureConverter.convert(event, recorderConfig) if (rrwebEvents != null) { - currentEventsLock.acquire().use { - currentEvents += rrwebEvents - } - } - } - - override fun close() { - replayExecutor.gracefullyShutdown(options) - } - - private class ReplayExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayIntegration-" + cnt++) - ret.setDaemon(true) - return ret + currentEvents += rrwebEvents } } @@ -209,10 +187,6 @@ internal abstract class BaseCaptureStrategy( } } - init { - runInBackground { onChange(propertyName, initialValue, initialValue) } - } - override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 9cca35973d..e0ec0a91e2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.annotation.TargetApi import android.graphics.Bitmap import android.view.MotionEvent import io.sentry.DateUtils @@ -23,14 +24,15 @@ import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService +@TargetApi(26) internal class BufferCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, private val random: Random, - executor: ScheduledExecutorService? = null, - replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, scopes, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { + executor: ScheduledExecutorService, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider = replayCacheProvider) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() @@ -63,7 +65,7 @@ internal class BufferCaptureStrategy( isTerminating: Boolean, onSegmentSent: (Date) -> Unit ) { - val sampled = random.sample(options.experimental.sessionReplay.onErrorSampleRate) + val sampled = random.sample(options.sessionReplay.onErrorSampleRate) if (!sampled) { options.logger.log(INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event") @@ -105,7 +107,7 @@ internal class BufferCaptureStrategy( cache?.store(frameTimestamp) val now = dateProvider.currentTimeMillis - val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration + val bufferLimit = now - options.sessionReplay.errorReplayDuration screenAtStart = cache?.rotate(bufferLimit) bufferedSegments.rotate(bufferLimit) } @@ -135,7 +137,7 @@ internal class BufferCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) - val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration + val bufferLimit = dateProvider.currentTimeMillis - options.sessionReplay.errorReplayDuration rotateEvents(currentEvents, bufferLimit) } @@ -187,7 +189,7 @@ internal class BufferCaptureStrategy( } private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { - val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration + val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { // in buffer mode we have to set the timestamp of the first frame as the actual start 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 2916925953..98007c4553 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 @@ -16,10 +16,11 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.rrweb.RRWebVideoEvent -import io.sentry.util.AutoClosableReentrantLock import java.io.File import java.util.Date +import java.util.Deque import java.util.LinkedList internal interface CaptureStrategy { @@ -54,11 +55,8 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy - fun close() - companion object { private const val BREADCRUMB_START_OFFSET = 100L - internal val currentEventsLock = AutoClosableReentrantLock() fun createSegment( scopes: IScopes?, @@ -72,16 +70,19 @@ internal interface CaptureStrategy { replayType: ReplayType, cache: ReplayCache?, frameRate: Int, + bitRate: Int, screenAtStart: String?, breadcrumbs: List?, - events: LinkedList + events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( duration, currentSegmentTimestamp.time, segmentId, height, - width + width, + frameRate, + bitRate ) ?: return ReplaySegment.Failed val (video, frameCount, videoDuration) = generatedVideo @@ -128,7 +129,7 @@ internal interface CaptureStrategy { replayType: ReplayType, screenAtStart: String?, breadcrumbs: List, - events: LinkedList + events: Deque ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = SentryReplayEvent().apply { @@ -195,6 +196,10 @@ internal interface CaptureStrategy { } } + if (segmentId == 0) { + recordingPayload += RRWebOptionsEvent(options) + } + val recording = ReplayRecording().apply { this.segmentId = segmentId this.payload = recordingPayload.sortedBy { it.timestamp } @@ -208,16 +213,16 @@ internal interface CaptureStrategy { } internal fun rotateEvents( - events: LinkedList, + events: Deque, until: Long, callback: ((RRWebEvent) -> Unit)? = null ) { - currentEventsLock.acquire().use { - var event = events.peek() - while (event != null && event.timestamp < until) { + val iter = events.iterator() + while (iter.hasNext()) { + val event = iter.next() + if (event.timestamp < until) { callback?.invoke(event) - events.remove() - event = events.peek() + iter.remove() } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 16a4fd2249..5eefafa25a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -20,8 +20,8 @@ internal class SessionCaptureStrategy( private val options: SentryOptions, private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, - executor: ScheduledExecutorService? = null, - replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + executor: ScheduledExecutorService, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider) { internal companion object { @@ -92,10 +92,10 @@ internal class SessionCaptureStrategy( } val now = dateProvider.currentTimeMillis - if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { + if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { val segment = createSegmentInternal( - options.experimental.sessionReplay.sessionSegmentDuration, + options.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, currentReplayId, currentSegment, @@ -110,7 +110,7 @@ internal class SessionCaptureStrategy( } } - if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt index 57302aaac1..a8da77d851 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt @@ -9,6 +9,7 @@ import io.sentry.SentryOptions import io.sentry.android.replay.OnRootViewsChangedListener import io.sentry.android.replay.phoneWindow import io.sentry.android.replay.util.FixedWindowCallback +import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference class GestureRecorder( @@ -17,20 +18,25 @@ class GestureRecorder( ) : OnRootViewsChangedListener { private val rootViews = ArrayList>() + private val rootViewsLock = AutoClosableReentrantLock() override fun onRootViewsChanged(root: View, added: Boolean) { - if (added) { - rootViews.add(WeakReference(root)) - root.startGestureTracking() - } else { - root.stopGestureTracking() - rootViews.removeAll { it.get() == root } + rootViewsLock.acquire().use { + if (added) { + rootViews.add(WeakReference(root)) + root.startGestureTracking() + } else { + root.stopGestureTracking() + rootViews.removeAll { it.get() == root } + } } } fun stop() { - rootViews.forEach { it.get()?.stopGestureTracking() } - rootViews.clear() + rootViewsLock.acquire().use { + rootViews.forEach { it.get()?.stopGestureTracking() } + rootViews.clear() + } } private fun View.startGestureTracking() { @@ -53,8 +59,9 @@ class GestureRecorder( return } - if (window.callback is SentryReplayGestureRecorder) { - val delegate = (window.callback as SentryReplayGestureRecorder).delegate + val callback = window.callback + if (callback is SentryReplayGestureRecorder) { + val delegate = callback.delegate window.callback = delegate } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt index 59d6b30bce..fa215960c4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -56,7 +56,7 @@ class ReplayGestureConverter( val totalOffset = now - touchMoveBaseline return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { - val moveEvents = mutableListOf() + val moveEvents = ArrayList(currentPositions.size) for ((pointerId, positions) in currentPositions) { if (positions.isNotEmpty()) { moveEvents += RRWebInteractionMoveEvent().apply { @@ -88,7 +88,7 @@ class ReplayGestureConverter( } // new finger down - add a new pointer for tracking movement - currentPositions[pId] = ArrayList() + currentPositions[pId] = ArrayList(10) listOf( RRWebInteractionEvent().apply { timestamp = dateProvider.currentTimeMillis diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt index 453ff49df2..3c07ad7eaa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -50,6 +50,11 @@ internal fun ExecutorService.submitSafely( taskName: String, task: Runnable ): Future<*>? { + if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) { + // we're already on the worker thread, no need to submit + task.run() + return null + } return try { submit { try { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 5608371722..38c2d583b4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.text.TextLayoutResult import kotlin.math.roundToInt @@ -165,8 +166,8 @@ private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float { * * @return boundaries of this layout relative to the window's origin. */ -internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect { - root ?: return Rect() +internal fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates?): Rect { + val root = rootCoordinates ?: findRootCoordinates() val rootWidth = root.size.width.toFloat() val rootHeight = root.size.height.toFloat() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt index a5d3c3e9ec..ab11629c2a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -1,21 +1,25 @@ // ktlint-disable filename package io.sentry.android.replay.util +import android.annotation.TargetApi import io.sentry.ReplayRecording import io.sentry.SentryOptions import io.sentry.android.replay.ReplayCache import io.sentry.rrweb.RRWebEvent import java.io.BufferedWriter import java.io.StringWriter -import java.util.LinkedList +import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.ScheduledExecutorService +// TODO: enable this back after we are able to serialize individual touches to disk to not overload cpu +@Suppress("unused") +@TargetApi(26) internal class PersistableLinkedList( private val propertyName: String, private val options: SentryOptions, private val persistingExecutor: ScheduledExecutorService, private val cacheProvider: () -> ReplayCache? -) : LinkedList() { +) : ConcurrentLinkedDeque() { // only overriding methods that we use, to observe the collection override fun addAll(elements: Collection): Boolean { val result = super.addAll(elements) 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 0a0656de52..f3e667dc32 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 @@ -17,6 +17,7 @@ import android.text.Spanned import android.text.style.ForegroundColorSpan import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode @@ -178,3 +179,17 @@ class AndroidTextLayout(private val layout: Layout) : TextLayout { override fun getLineBottom(line: Int): Int = layout.getLineBottom(line) override fun getLineStart(line: Int): Int = layout.getLineStart(line) } + +internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + viewTreeObserver.addOnDrawListener(listener) +} + +internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + viewTreeObserver.removeOnDrawListener(listener) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 5640fbc96f..a4d82159da 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -27,6 +27,7 @@ import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import java.lang.ref.WeakReference @TargetApi(26) internal object ComposeViewHierarchyNode { @@ -55,14 +56,14 @@ internal object ComposeViewHierarchyNode { } val className = getProxyClassName(isImage) - if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) { + if (options.sessionReplay.unmaskViewClasses.contains(className)) { return false } - return options.experimental.sessionReplay.maskViewClasses.contains(className) + return options.sessionReplay.maskViewClasses.contains(className) } - private var _rootCoordinates: LayoutCoordinates? = null + private var _rootCoordinates: WeakReference? = null private fun fromComposeNode( node: LayoutNode, @@ -77,11 +78,11 @@ internal object ComposeViewHierarchyNode { } if (isComposeRoot) { - _rootCoordinates = node.coordinates.findRootCoordinates() + _rootCoordinates = WeakReference(node.coordinates.findRootCoordinates()) } val semantics = node.collapsedSemantics - val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates) + val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get()) val isVisible = !node.outerCoordinator.isTransparent() && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 03bda7cfc6..329717d62c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -269,22 +269,22 @@ sealed class ViewHierarchyNode( return false } - if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) { + if (this.javaClass.isAssignableFrom(options.sessionReplay.unmaskViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses) + return this.javaClass.isAssignableFrom(options.sessionReplay.maskViewClasses) } private fun ViewParent.isUnmaskContainer(options: SentryOptions): Boolean { val unmaskContainer = - options.experimental.sessionReplay.unmaskViewContainerClass ?: return false + options.sessionReplay.unmaskViewContainerClass ?: return false return this.javaClass.name == unmaskContainer } private fun View.isMaskContainer(options: SentryOptions): Boolean { val maskContainer = - options.experimental.sessionReplay.maskViewContainerClass ?: return false + options.sessionReplay.maskViewContainerClass ?: return false return this.javaClass.name == maskContainer } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt index 5aa210f589..104833bb12 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -153,7 +153,7 @@ class AnrWithReplayIntegrationTest { it.cacheDirPath = cacheDir it.isDebug = true it.setLogger(SystemOutLogger()) - it.experimental.sessionReplay.onErrorSampleRate = 1.0 + it.sessionReplay.onErrorSampleRate = 1.0 // beforeSend is called after event processors are applied, so we can assert here // against the enriched ANR event it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> 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 91a17f5192..b2c8836d40 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 @@ -25,6 +25,7 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBitmapFactory import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -47,14 +48,12 @@ class ReplayCacheTest { val options = SentryOptions() fun getSut( dir: TemporaryFolder?, - replayId: SentryId = SentryId(), - frameRate: Int + replayId: SentryId = SentryId() ): ReplayCache { - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig) + return ReplayCache(options, replayId) } } @@ -63,6 +62,7 @@ class ReplayCacheTest { @BeforeTest fun `set up`() { ReplayShadowMediaCodec.framesToEncode = 5 + ShadowBitmapFactory.setAllowInvalidImageData(true) } @Test @@ -70,8 +70,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( null, - replayId, - frameRate = 1 + replayId ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -85,8 +84,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -101,11 +99,10 @@ class ReplayCacheTest { @Test fun `when no frames are provided, returns nothing`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) - val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertNull(video) } @@ -114,8 +111,7 @@ class ReplayCacheTest { fun `deletes frames after creating a video`() { ReplayShadowMediaCodec.framesToEncode = 3 val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -123,7 +119,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 2001) - val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000) assertEquals(3, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -136,14 +132,13 @@ class ReplayCacheTest { @Test fun `repeats last known frame for the segment duration`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -153,15 +148,14 @@ class ReplayCacheTest { @Test fun `repeats last known frame for the segment duration for each timespan`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 3001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -171,20 +165,19 @@ class ReplayCacheTest { @Test fun `repeats last known frame for each segment`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 5001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) - val segment1 = replayCache.createVideoOf(5000L, 5000L, 1, 100, 200) + val segment1 = replayCache.createVideoOf(5000L, 5000L, 1, 100, 200, 1, 20_000) assertEquals(5, segment1!!.frameCount) assertEquals(5000, segment1.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -196,8 +189,7 @@ class ReplayCacheTest { ReplayShadowMediaCodec.framesToEncode = 6 val replayCache = fixture.getSut( - tmpDir, - frameRate = 2 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -205,7 +197,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 1501) - val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 2, 20_000) assertEquals(6, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -215,8 +207,7 @@ class ReplayCacheTest { @Test fun `does not add frame when bitmap is recycled`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } @@ -228,8 +219,7 @@ class ReplayCacheTest { @Test fun `addFrame with File path works`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val flutterCacheDir = @@ -240,7 +230,7 @@ class ReplayCacheTest { val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } replayCache.addFrame(screenshot, frameTimestamp = 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000, videoFile = video) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) @@ -251,8 +241,7 @@ class ReplayCacheTest { @Test fun `rotates frames`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -269,8 +258,7 @@ class ReplayCacheTest { @Test fun `rotate returns first screen in buffer`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -288,8 +276,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) replayCache.close() @@ -303,8 +290,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) replayCache.persistSegmentValues("key1", "value1") @@ -320,8 +306,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) replayCache.persistSegmentValues("key1", "value1") @@ -467,8 +452,7 @@ class ReplayCacheTest { ReplayShadowMediaCodec.framesToEncode = 3 val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { @@ -480,7 +464,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 2001) - val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, oldVideoFile) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000, oldVideoFile) assertEquals(3, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -518,4 +502,28 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + + @Test + fun `when screenshot is corrupted, deletes it immediately`() { + ShadowBitmapFactory.setAllowInvalidImageData(false) + ReplayShadowMediaCodec.framesToEncode = 1 + val replayCache = fixture.getSut( + tmpDir + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + // corrupt the image + File(replayCache.replayCacheDir, "1.jpg").outputStream().use { + it.write(Int.MIN_VALUE) + it.flush() + } + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000) + assertNull(segment0) + + assertTrue(replayCache.frames.isEmpty()) + assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) + } } 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 9b319b9c0e..747519943a 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 @@ -96,7 +96,7 @@ class ReplayIntegrationTest { val replayCache = mock { on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) - on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any()) } .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) } @@ -113,8 +113,8 @@ class ReplayIntegrationTest { dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { options.run { - experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate - experimental.sessionReplay.sessionSampleRate = sessionSampleRate + sessionReplay.onErrorSampleRate = onErrorSampleRate + sessionReplay.sessionSampleRate = sessionSampleRate connectionStatusProvider = mock { on { connectionStatus }.thenReturn(if (isOffline) DISCONNECTED else CONNECTED) } @@ -127,7 +127,7 @@ class ReplayIntegrationTest { dateProvider, recorderProvider, recorderConfigProvider = recorderConfigProvider, - replayCacheProvider = { _, _ -> replayCache }, + replayCacheProvider = { _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider, gestureRecorderProvider = gestureRecorderProvider ) @@ -411,7 +411,6 @@ class ReplayIntegrationTest { verify(recorder).stop() verify(recorder).close() verify(captureStrategy).stop() - verify(captureStrategy).close() assertFalse(replay.isRecording()) } 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 03fdc43b86..25713ad295 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 @@ -87,10 +87,10 @@ class ReplayIntegrationWithRecorderTest { // fake current time to trigger segment creation, CurrentDateProvider.getInstance() should // be used in prod val dateProvider = ICurrentDateProvider { - System.currentTimeMillis() + fixture.options.experimental.sessionReplay.sessionSegmentDuration + System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration } - fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration @@ -136,9 +136,6 @@ class ReplayIntegrationWithRecorderTest { replay.stop() assertEquals(STOPPED, recorder.state) - replay.close() - assertEquals(CLOSED, recorder.state) - // start again and capture some frames replay.start() @@ -176,6 +173,9 @@ class ReplayIntegrationWithRecorderTest { assertEquals(0, videoEvents?.first()?.segmentId) } ) + + replay.close() + assertEquals(CLOSED, recorder.state) } enum class LifecycleState { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 831f11428e..8a7aa6611d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -45,6 +45,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals +import kotlin.test.assertNotEquals @RunWith(AndroidJUnit4::class) @Config( @@ -107,7 +108,7 @@ class ReplaySmokeTest { captured.set(true) } - fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration = fixture.getSut(context) @@ -154,7 +155,7 @@ class ReplaySmokeTest { captured.set(true) } - fixture.options.experimental.sessionReplay.onErrorSampleRate = 1.0 + fixture.options.sessionReplay.onErrorSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration = fixture.getSut(context) @@ -200,6 +201,40 @@ class ReplaySmokeTest { } ) } + + @Test + fun `works when double inited`() { + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + // first init + close + val falseHub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(fixture.scope) + }.whenever(it).configureScope(any()) + } + val falseReplay: ReplayIntegration = fixture.getSut(context) + falseReplay.register(falseHub, fixture.options) + falseReplay.start() + falseReplay.close() + + // second init + val captured = AtomicBoolean(false) + whenever(fixture.scopes.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + val replay: ReplayIntegration = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + + val controller = buildActivity(ExampleActivity::class.java, null).setup() + controller.create().start().resume() + // wait for windows to be registered in our listeners + shadowOf(Looper.getMainLooper()).idle() + + assertNotEquals(falseReplay.rootViewsSpy, replay.rootViewsSpy) + assertEquals(0, falseReplay.rootViewsSpy.listeners.size) + } } private class ExampleActivity : Activity() { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 64f950ce07..863d5c85f8 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -70,7 +70,7 @@ class BufferCaptureStrategyTest { on { persistSegmentValues(any(), anyOrNull()) }.then { persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) } - on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any()) } .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) } val recorderConfig = ScreenshotRecorderConfig( @@ -91,7 +91,7 @@ class BufferCaptureStrategyTest { whenever(replayCache.replayCacheDir).thenReturn(it) } options.run { - experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate + sessionReplay.onErrorSampleRate = onErrorSampleRate } return BufferCaptureStrategy( options, @@ -104,7 +104,7 @@ class BufferCaptureStrategyTest { null }.whenever(it).submit(any()) } - ) { _, _ -> replayCache } + ) { _ -> replayCache } } fun mockedMotionEvent(action: Int): MotionEvent = mock { @@ -181,7 +181,7 @@ class BufferCaptureStrategyTest { @Test fun `onScreenshotRecorded adds screenshot to cache`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.errorReplayDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -195,7 +195,7 @@ class BufferCaptureStrategyTest { @Test fun `onScreenshotRecorded rotates screenshots when out of buffer bounds`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.errorReplayDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -204,7 +204,7 @@ class BufferCaptureStrategyTest { strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) } - verify(fixture.replayCache).rotate(eq(now - fixture.options.experimental.sessionReplay.errorReplayDuration)) + verify(fixture.replayCache).rotate(eq(now - fixture.options.sessionReplay.errorReplayDuration)) } @Test 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 6a90251c74..79afdb8f85 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 @@ -9,6 +9,8 @@ import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions.SentryReplayQuality.HIGH +import io.sentry.android.replay.BuildConfig import io.sentry.android.replay.DefaultReplayBreadcrumbConverter import io.sentry.android.replay.GeneratedVideo import io.sentry.android.replay.ReplayCache @@ -22,9 +24,11 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.maskAllImages import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule @@ -43,8 +47,10 @@ import org.mockito.kotlin.whenever import java.io.File import java.util.Date import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class SessionCaptureStrategyTest { @@ -76,7 +82,7 @@ class SessionCaptureStrategyTest { on { persistSegmentValues(any(), anyOrNull()) }.then { persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) } - on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any()) } .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) } val recorderConfig = ScreenshotRecorderConfig( @@ -105,7 +111,7 @@ class SessionCaptureStrategyTest { null }.whenever(it).submit(any()) } - ) { _, _ -> replayCache } + ) { _ -> replayCache } } } @@ -208,7 +214,7 @@ class SessionCaptureStrategyTest { @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -223,7 +229,7 @@ class SessionCaptureStrategyTest { @Test fun `onScreenshotRecorded creates new segment when segment duration exceeded`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -254,7 +260,7 @@ class SessionCaptureStrategyTest { @Test fun `onScreenshotRecorded stops replay when replay duration exceeded`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionDuration * 2) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2) var count = 0 val strategy = fixture.getSut( dateProvider = { @@ -309,7 +315,7 @@ class SessionCaptureStrategyTest { @Test fun `fills replay urls from navigation breadcrumbs`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) strategy.start(fixture.recorderConfig) @@ -335,7 +341,7 @@ class SessionCaptureStrategyTest { fixture.scope.screen = "MainActivity" val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) strategy.start(fixture.recorderConfig) @@ -367,4 +373,81 @@ class SessionCaptureStrategyTest { "the current replay cache folder is not being deleted." ) } + + @Test + fun `records replay options event for segment 0`() { + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.maskAllImages = false + fixture.options.sessionReplay.quality = HIGH + fixture.options.sessionReplay.addMaskViewClass("my.custom.View") + + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val optionsEvent = + it.replayRecording?.payload?.filterIsInstance()!! + assertEquals("sentry.java", optionsEvent[0].optionsPayload["nativeSdkName"]) + assertEquals(BuildConfig.VERSION_NAME, optionsEvent[0].optionsPayload["nativeSdkVersion"]) + + assertEquals(null, optionsEvent[0].optionsPayload["errorSampleRate"]) + assertEquals(1.0, optionsEvent[0].optionsPayload["sessionSampleRate"]) + assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"]) + assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"]) + assertEquals("high", optionsEvent[0].optionsPayload["quality"]) + assertContentEquals( + listOf( + "android.widget.TextView", + "android.webkit.WebView", + "android.widget.VideoView", + "androidx.media3.ui.PlayerView", + "com.google.android.exoplayer2.ui.PlayerView", + "com.google.android.exoplayer2.ui.StyledPlayerView", + "my.custom.View" + ), + optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*> + ) + assertContentEquals( + listOf("android.widget.ImageView"), + optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*> + ) + } + ) + } + + @Test + fun `does not record replay options event for segment above 0`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + verify(fixture.scopes).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + + strategy.onScreenshotRecorded(mock()) {} + verify(fixture.scopes).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 + }, + check { + val optionsEvent = + it.replayRecording?.payload?.find { it is RRWebOptionsEvent } + assertNull(optionsEvent) + } + ) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index e5330fa827..8fa3106058 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -57,7 +57,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) @@ -72,7 +72,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) @@ -85,7 +85,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = true + sessionReplay.maskAllImages = true } val imageNodes = activity.get().collectNodesOfType(options) @@ -98,7 +98,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = false + sessionReplay.maskAllImages = false } val imageNodes = activity.get().collectNodesOfType(options) @@ -112,7 +112,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) @@ -132,7 +132,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) @@ -152,7 +152,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt index ff9a125d95..fab8d81ac7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt @@ -38,8 +38,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.maskAllText = true + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textViewInUnmask!!, null, 0, options) @@ -51,8 +51,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = true - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.maskAllImages = true + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageViewInUnmask!!, null, 0, options) @@ -64,7 +64,7 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) + sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) } val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.maskWithChildren!!, null, 0, options) @@ -77,8 +77,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.addMaskViewClass(CustomView::class.java.name) - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.addMaskViewClass(CustomView::class.java.name) + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithChildren!!, null, 0, options) @@ -95,8 +95,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val unmaskNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithMaskChild!!, null, 0, options) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt index 4a40e0a915..6620392bde 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -42,7 +42,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) @@ -60,7 +60,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) @@ -78,7 +78,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = true + sessionReplay.maskAllImages = true } val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) @@ -92,7 +92,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = false + sessionReplay.maskAllImages = false } val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) @@ -106,7 +106,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } MaskingOptionsActivity.textView!!.tag = "sentry-mask" @@ -120,7 +120,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.tag = "sentry-unmask" @@ -134,7 +134,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } MaskingOptionsActivity.textView!!.sentryReplayMask() @@ -148,7 +148,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.sentryReplayUnmask() @@ -162,7 +162,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.visibility = View.GONE @@ -176,7 +176,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) + sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) } val customViewNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) @@ -189,8 +189,8 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true // all TextView subclasses - experimental.sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) + sessionReplay.maskAllText = true // all TextView subclasses + sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) @@ -205,7 +205,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) + sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) } val linearLayoutNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d7ea46862f..5f4d618ad9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -429,9 +429,7 @@ public abstract interface class io/sentry/EventProcessor { } public final class io/sentry/ExperimentalOptions { - public fun (Z)V - public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; - public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V + public fun (ZLio/sentry/protocol/SdkVersion;)V } public final class io/sentry/ExternalOptions { @@ -2895,6 +2893,7 @@ public class io/sentry/SentryOptions { public fun getSerializer ()Lio/sentry/ISerializer; public fun getServerName ()Ljava/lang/String; public fun getSessionFlushTimeoutMillis ()J + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; public fun getSessionTrackingIntervalMillis ()J public fun getShutdownTimeoutMillis ()J public fun getSpanFactory ()Lio/sentry/ISpanFactory; @@ -3020,6 +3019,7 @@ public class io/sentry/SentryOptions { public fun setSerializer (Lio/sentry/ISerializer;)V public fun setServerName (Ljava/lang/String;)V public fun setSessionFlushTimeoutMillis (J)V + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V public fun setSessionTrackingIntervalMillis (J)V public fun setShutdownTimeoutMillis (J)V public fun setSpanFactory (Lio/sentry/ISpanFactory;)V @@ -3184,8 +3184,8 @@ public final class io/sentry/SentryReplayOptions { public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; public static final field VIDEO_VIEW_CLASS_NAME Ljava/lang/String; public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String; - public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun (Z)V + public fun (Ljava/lang/Double;Ljava/lang/Double;Lio/sentry/protocol/SdkVersion;)V + public fun (ZLio/sentry/protocol/SdkVersion;)V public fun addMaskViewClass (Ljava/lang/String;)V public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J @@ -3194,6 +3194,7 @@ public final class io/sentry/SentryReplayOptions { public fun getMaskViewContainerClass ()Ljava/lang/String; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J @@ -3201,12 +3202,15 @@ public final class io/sentry/SentryReplayOptions { public fun getUnmaskViewContainerClass ()Ljava/lang/String; public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z + public fun isTrackOrientationChange ()Z public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V public fun setMaskViewContainerClass (Ljava/lang/String;)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V + public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSessionSampleRate (Ljava/lang/Double;)V + public fun setTrackOrientationChange (Z)V public fun setUnmaskViewContainerClass (Ljava/lang/String;)V } @@ -3215,7 +3219,9 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality; public final field bitRate I + public final field screenshotQuality I public final field sizeScale F + public fun serializedName ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static fun values ()[Lio/sentry/SentryReplayOptions$SentryReplayQuality; } @@ -5762,6 +5768,33 @@ public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { public fun ()V } +public final class io/sentry/rrweb/RRWebOptionsEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/SentryOptions;)V + public fun getDataUnknown ()Ljava/util/Map; + public fun getOptionsPayload ()Ljava/util/Map; + public fun getTag ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setOptionsPayload (Ljava/util/Map;)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebOptionsEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebOptionsEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebOptionsEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public fun ()V +} + public final class io/sentry/rrweb/RRWebSpanEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field EVENT_TAG Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 4a0e7de78d..80d59d4f01 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -1,6 +1,7 @@ package io.sentry; -import org.jetbrains.annotations.NotNull; +import io.sentry.protocol.SdkVersion; +import org.jetbrains.annotations.Nullable; /** * Experimental options for new features, these options are going to be promoted to SentryOptions @@ -9,18 +10,6 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplay; - public ExperimentalOptions(final boolean empty) { - this.sessionReplay = new SentryReplayOptions(empty); - } - - @NotNull - public SentryReplayOptions getSessionReplay() { - return sessionReplay; - } - - public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { - this.sessionReplay = sessionReplayOptions; - } + public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {} } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index a5cbacc4df..50f11cba27 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -4,6 +4,7 @@ import io.sentry.hints.Cached; import io.sentry.protocol.DebugImage; import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; @@ -162,6 +163,11 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { if (shouldApplyScopeData(event, hint)) { processNonCachedEvent(event); + final @Nullable SdkVersion replaySdkVersion = options.getSessionReplay().getSdkVersion(); + if (replaySdkVersion != null) { + // we override the SdkVersion only for replay events as those may come from Hybrid SDKs + event.setSdk(replaySdkVersion); + } } return event; } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index dbbf2a0b31..a00d145507 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -474,7 +474,7 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setEnvironment(options.getEnvironment()); observer.setTags(options.getTags()); observer.setReplayErrorSampleRate( - options.getExperimental().getSessionReplay().getOnErrorSampleRate()); + options.getSessionReplay().getOnErrorSampleRate()); } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index b9824cb008..b518c046c5 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -288,7 +288,6 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin } if (event == null) { - options.getLogger().log(SentryLevel.DEBUG, "Replay was dropped by Event processors."); return SentryId.EMPTY_ID; } @@ -632,8 +631,11 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { envelopeItems.add(replayItem); final SentryId sentryId = event.getEventId(); + // SdkVersion from ReplayOptions defaults to SdkVersion from SentryOptions and can be + // overwritten by the hybrid SDKs final SentryEnvelopeHeader envelopeHeader = - new SentryEnvelopeHeader(sentryId, options.getSdkVersion(), traceContext); + new SentryEnvelopeHeader( + sentryId, options.getSessionReplay().getSdkVersion(), traceContext); return new SentryEnvelope(envelopeHeader, envelopeItems); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 24da2eaa39..3a6d75789a 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -515,6 +515,8 @@ public class SentryOptions { private @NotNull SentryOpenTelemetryMode openTelemetryMode = SentryOpenTelemetryMode.AUTO; + private @NotNull SentryReplayOptions sessionReplay; + /** * Adds an event processor * @@ -1398,6 +1400,13 @@ public void setSslSocketFactory(final @Nullable SSLSocketFactory sslSocketFactor */ @ApiStatus.Internal public void setSdkVersion(final @Nullable SdkVersion sdkVersion) { + final @Nullable SdkVersion replaySdkVersion = getSessionReplay().getSdkVersion(); + if (this.sdkVersion != null + && replaySdkVersion != null + && this.sdkVersion.equals(replaySdkVersion)) { + // if sdkVersion = sessionReplay.sdkVersion we override it, as it means no one else set it + getSessionReplay().setSdkVersion(sdkVersion); + } this.sdkVersion = sdkVersion; } @@ -2503,6 +2512,15 @@ public void setOpenTelemetryMode(final @NotNull SentryOpenTelemetryMode openTele return openTelemetryMode; } + @NotNull + public SentryReplayOptions getSessionReplay() { + return sessionReplay; + } + + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; + } + /** * Load the lazy fields. Useful to load in the background, so that results are already cached. DO * NOT CALL THIS METHOD ON THE MAIN THREAD. @@ -2652,7 +2670,9 @@ public SentryOptions() { * @param empty if options should be empty. */ private SentryOptions(final boolean empty) { - experimental = new ExperimentalOptions(empty); + final @NotNull SdkVersion sdkVersion = createSdkVersion(); + experimental = new ExperimentalOptions(empty, sdkVersion); + sessionReplay = new SentryReplayOptions(empty, sdkVersion); if (!empty) { setSpanFactory(SpanFactoryFactory.create(new LoadClass(), NoOpLogger.getInstance())); // SentryExecutorService should be initialized before any @@ -2674,7 +2694,7 @@ private SentryOptions(final boolean empty) { } setSentryClientName(BuildConfig.SENTRY_JAVA_SDK_NAME + "/" + BuildConfig.VERSION_NAME); - setSdkVersion(createSdkVersion()); + setSdkVersion(sdkVersion); addPackageInfo(); } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index fd492213ac..36afd60879 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,6 +1,8 @@ package io.sentry; +import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; +import java.util.Locale; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import org.jetbrains.annotations.ApiStatus; @@ -19,14 +21,14 @@ public final class SentryReplayOptions { "com.google.android.exoplayer2.ui.StyledPlayerView"; public enum SentryReplayQuality { - /** Video Scale: 80% Bit Rate: 50.000 */ - LOW(0.8f, 50_000), + /** Video Scale: 80% Bit Rate: 50.000 JPEG Compression: 10 */ + LOW(0.8f, 50_000, 10), - /** Video Scale: 100% Bit Rate: 75.000 */ - MEDIUM(1.0f, 75_000), + /** Video Scale: 100% Bit Rate: 75.000 JPEG Compression: 30 */ + MEDIUM(1.0f, 75_000, 30), - /** Video Scale: 100% Bit Rate: 100.000 */ - HIGH(1.0f, 100_000); + /** Video Scale: 100% Bit Rate: 100.000 JPEG Compression: 50 */ + HIGH(1.0f, 100_000, 50); /** The scale related to the window size (in dp) at which the replay will be created. */ public final float sizeScale; @@ -37,9 +39,17 @@ public enum SentryReplayQuality { */ public final int bitRate; - SentryReplayQuality(final float sizeScale, final int bitRate) { + /** Defines the compression quality with which the screenshots are stored to disk. */ + public final int screenshotQuality; + + SentryReplayQuality(final float sizeScale, final int bitRate, final int screenshotQuality) { this.sizeScale = sizeScale; this.bitRate = bitRate; + this.screenshotQuality = screenshotQuality; + } + + public @NotNull String serializedName() { + return name().toLowerCase(Locale.ROOT); } } @@ -108,7 +118,19 @@ public enum SentryReplayQuality { /** The maximum duration of a full session replay, defaults to 1h. */ private long sessionDuration = 60 * 60 * 1000L; - public SentryReplayOptions(final boolean empty) { + /** + * Whether to track orientation changes in session replay. Used in Flutter as it has its own + * callbacks to determine the orientation change. + */ + private boolean trackOrientationChange = true; + + /** + * SdkVersion object that contains the Sentry Client Name and its version. This object is only + * applied to {@link SentryReplayEvent}s. + */ + private @Nullable SdkVersion sdkVersion; + + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { setMaskAllText(true); setMaskAllImages(true); @@ -117,14 +139,18 @@ public SentryReplayOptions(final boolean empty) { maskViewClasses.add(ANDROIDX_MEDIA_VIEW_CLASS_NAME); maskViewClasses.add(EXOPLAYER_CLASS_NAME); maskViewClasses.add(EXOPLAYER_STYLED_CLASS_NAME); + this.sdkVersion = sdkVersion; } } public SentryReplayOptions( - final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { - this(false); + final @Nullable Double sessionSampleRate, + final @Nullable Double onErrorSampleRate, + final @Nullable SdkVersion sdkVersion) { + this(false, sdkVersion); this.sessionSampleRate = sessionSampleRate; this.onErrorSampleRate = onErrorSampleRate; + this.sdkVersion = sdkVersion; } @Nullable @@ -266,4 +292,24 @@ public void setUnmaskViewContainerClass(@NotNull String containerClass) { public @Nullable String getUnmaskViewContainerClass() { return unmaskViewContainerClass; } + + @ApiStatus.Internal + public boolean isTrackOrientationChange() { + return trackOrientationChange; + } + + @ApiStatus.Internal + public void setTrackOrientationChange(final boolean trackOrientationChange) { + this.trackOrientationChange = trackOrientationChange; + } + + @ApiStatus.Internal + public @Nullable SdkVersion getSdkVersion() { + return sdkVersion; + } + + @ApiStatus.Internal + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) { + this.sdkVersion = sdkVersion; + } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java new file mode 100644 index 0000000000..f9a96074c1 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -0,0 +1,228 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.SentryOptions; +import io.sentry.SentryReplayOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebOptionsEvent extends RRWebEvent implements JsonSerializable, JsonUnknown { + public static final String EVENT_TAG = "options"; + + private @NotNull String tag; + // keeping this untyped so hybrids can easily set what they want + private @NotNull Map optionsPayload = new HashMap<>(); + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebOptionsEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + public RRWebOptionsEvent(final @NotNull SentryOptions options) { + this(); + final SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion != null) { + optionsPayload.put("nativeSdkName", sdkVersion.getName()); + optionsPayload.put("nativeSdkVersion", sdkVersion.getVersion()); + } + final @NotNull SentryReplayOptions replayOptions = options.getSessionReplay(); + optionsPayload.put("errorSampleRate", replayOptions.getOnErrorSampleRate()); + optionsPayload.put("sessionSampleRate", replayOptions.getSessionSampleRate()); + optionsPayload.put( + "maskAllImages", + replayOptions.getMaskViewClasses().contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)); + optionsPayload.put( + "maskAllText", + replayOptions.getMaskViewClasses().contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)); + optionsPayload.put("quality", replayOptions.getQuality().serializedName()); + optionsPayload.put("maskedViewClasses", replayOptions.getMaskViewClasses()); + optionsPayload.put("unmaskedViewClasses", replayOptions.getUnmaskViewClasses()); + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public @NotNull Map getOptionsPayload() { + return optionsPayload; + } + + public void setOptionsPayload(final @NotNull Map optionsPayload) { + this.optionsPayload = optionsPayload; + } + + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); + writer.name(JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (optionsPayload != null) { + for (final String key : optionsPayload.keySet()) { + final Object value = optionsPayload.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebOptionsEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebOptionsEvent event = new RRWebOptionsEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebOptionsEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebEvent.JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + + @SuppressWarnings("unchecked") + private void deserializePayload( + final @NotNull RRWebOptionsEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map optionsPayload = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + if (optionsPayload == null) { + optionsPayload = new HashMap<>(); + } + reader.nextUnknown(logger, optionsPayload, nextName); + } + if (optionsPayload != null) { + event.setOptionsPayload(optionsPayload); + } + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index cb560aff99..3fc8293bf1 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -5,6 +5,7 @@ import io.sentry.DataCategory; import io.sentry.Hint; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryEnvelope; import io.sentry.SentryEnvelopeItem; import io.sentry.SentryLevel; @@ -13,6 +14,7 @@ import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.Retryable; import io.sentry.hints.SubmissionResult; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.HintUtils; import io.sentry.util.StringUtils; import java.io.Closeable; @@ -39,7 +41,7 @@ public final class RateLimiter implements Closeable { new ConcurrentHashMap<>(); private final @NotNull List rateLimitObservers = new CopyOnWriteArrayList<>(); private @Nullable Timer timer = null; - private final @NotNull Object timerLock = new Object(); + private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); public RateLimiter( final @NotNull ICurrentDateProvider currentDateProvider, @@ -285,7 +287,7 @@ private void applyRetryAfterOnlyIfLonger( notifyRateLimitObservers(); - synchronized (timerLock) { + try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { if (timer == null) { timer = new Timer(true); } @@ -337,7 +339,7 @@ public void removeRateLimitObserver(@NotNull final IRateLimitObserver observer) @Override public void close() throws IOException { - synchronized (timerLock) { + try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { if (timer != null) { timer.cancel(); timer = null; diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 24c3cf449b..3cb624d380 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -27,7 +27,7 @@ import kotlin.test.assertTrue class MainEventProcessorTest { class Fixture { - private val sentryOptions: SentryOptions = SentryOptions().apply { + val sentryOptions: SentryOptions = SentryOptions().apply { dsn = dsnString release = "release" dist = "dist" @@ -619,6 +619,18 @@ class MainEventProcessorTest { assertEquals("value1", replayEvent.tags!!["tag1"]) } + @Test + fun `uses SdkVersion from replay options for replay events`() { + val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) + + fixture.sentryOptions.sessionReplay.sdkVersion = SdkVersion("dart", "3.2.1") + var replayEvent = SentryReplayEvent() + replayEvent = sut.process(replayEvent, Hint()) + + assertEquals("3.2.1", replayEvent.sdk!!.version) + assertEquals("dart", replayEvent.sdk!!.name) + } + private fun generateCrashedEvent(crashedThread: Thread = Thread.currentThread()) = SentryEvent().apply { val mockThrowable = mock() diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 794a3dac09..48d9d71ac4 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -7,7 +7,7 @@ class SentryReplayOptionsTest { @Test fun `uses medium quality as default`() { - val replayOptions = SentryReplayOptions(true) + val replayOptions = SentryReplayOptions(true, null) assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) assertEquals(75_000, replayOptions.quality.bitRate) @@ -16,7 +16,7 @@ class SentryReplayOptionsTest { @Test fun `low quality`() { - val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + val replayOptions = SentryReplayOptions(true, null).apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } assertEquals(50_000, replayOptions.quality.bitRate) assertEquals(0.8f, replayOptions.quality.sizeScale) @@ -24,7 +24,7 @@ class SentryReplayOptionsTest { @Test fun `high quality`() { - val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } + val replayOptions = SentryReplayOptions(true, null).apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } assertEquals(100_000, replayOptions.quality.bitRate) assertEquals(1.0f, replayOptions.quality.sizeScale) diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index f5a6ad16c1..28c2fe3367 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -809,7 +809,7 @@ class SentryTest { it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") it.environment = "debug" it.setTag("one", "two") - it.experimental.sessionReplay.onErrorSampleRate = 0.5 + it.sessionReplay.onErrorSampleRate = 0.5 } assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt new file mode 100644 index 0000000000..c1ca71b6fd --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions.SentryReplayQuality.LOW +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebOptionsEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebOptionsEvent( + SentryOptions().apply { + sdkVersion = SdkVersion("sentry.java", "7.19.1") + sessionReplay.sessionSampleRate = 0.5 + sessionReplay.onErrorSampleRate = 0.1 + sessionReplay.quality = LOW + sessionReplay.unmaskViewClasses.add("com.example.MyClass") + sessionReplay.maskViewClasses.clear() + } + ).apply { + timestamp = 12345678901 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_options_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_options_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebOptionsEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 1b7ae7fe61..e9a38631cc 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -5,7 +5,6 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.DataCategory.Replay import io.sentry.Hint -import io.sentry.IHub import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ISerializer @@ -314,8 +313,8 @@ class RateLimiterTest { @Test fun `drop replay items as lost`() { val rateLimiter = fixture.getSUT() - val hub = mock() - whenever(hub.options).thenReturn(SentryOptions()) + val scopes = mock() + whenever(scopes.options).thenReturn(SentryOptions()) val replayItem = SentryEnvelopeItem.fromReplay(fixture.serializer, mock(), SentryReplayEvent(), ReplayRecording(), false) val attachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, NoOpLogger.getInstance(), Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) @@ -372,8 +371,10 @@ class RateLimiterTest { rateLimiter.updateRetryAfterLimits("1:replay:key", null, 1) rateLimiter.close() - // wait for 1.5s to ensure the timer has run after 1s - await.untilTrue(applied) + // If rate limit didn't already change, wait for 1.5s to ensure the timer has run after 1s + if (!applied.get()) { + await.untilTrue(applied) + } assertTrue(applied.get()) } } diff --git a/sentry/src/test/resources/json/rrweb_options_event.json b/sentry/src/test/resources/json/rrweb_options_event.json new file mode 100644 index 0000000000..1137997175 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_options_event.json @@ -0,0 +1,18 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "options", + "payload": { + "unmaskedViewClasses": ["com.example.MyClass"], + "nativeSdkVersion": "7.19.1", + "errorSampleRate": 0.1, + "maskAllImages": false, + "maskAllText": false, + "maskedViewClasses": [], + "nativeSdkName": "sentry.java", + "sessionSampleRate": 0.5, + "quality": "low" + } + } +}