diff --git a/CHANGELOG.md b/CHANGELOG.md index f67618933fa..6ac3f135edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 7.12.0-alpha.3 + +- Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) + +We released our fourth Alpha version of the SDK with support. To get access, it requires adding your Sentry org to our feature flag. Please let us know on the [waitlist](https://sentry.io/lp/mobile-replay-beta/) if you're interested + ## 7.11.0 ### Features diff --git a/build.gradle.kts b/build.gradle.kts index 998c547efbb..f44f5410150 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,6 +112,7 @@ subprojects { "sentry-android-ndk", "sentry-android-okhttp", "sentry-android-sqlite", + "sentry-android-replay", "sentry-android-timber" ) if (jacocoAndroidModules.contains(name)) { @@ -296,7 +297,9 @@ private val androidLibs = setOf( "sentry-android-navigation", "sentry-android-okhttp", "sentry-android-timber", - "sentry-compose-android" + "sentry-compose-android", + "sentry-android-sqlite", + "sentry-android-replay" ) private val androidXLibs = listOf( diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 7a0081d5f47..2da41627abd 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -34,6 +34,7 @@ object Config { val minSdkVersion = 19 val minSdkVersionOkHttp = 21 + val minSdkVersionReplay = 19 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion @@ -194,6 +195,7 @@ object Config { val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0" val hsqldb = "org.hsqldb:hsqldb:2.6.1" val javaFaker = "com.github.javafaker:javafaker:1.0.2" + val msgpack = "org.msgpack:msgpack-core:0.9.8" } object QualityPlugins { diff --git a/gradle.properties b/gradle.properties index 35ce98ed2da..66a1b2364ee 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.11.0 +versionName=7.12.0-alpha.3 # 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 adcc6ea87d7..0e493f54a7e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -184,9 +184,11 @@ public final class io/sentry/android/core/CurrentActivityIntegration : android/a public final class io/sentry/android/core/DeviceInfoUtil { public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V public fun collectDeviceInformation (ZZ)Lio/sentry/protocol/Device; + public static fun getBatteryLevel (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Float; public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo; + public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; public static fun resetInstance ()V } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 2ec856cf5ff..12e6e6ad4f6 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { api(projects.sentry) compileOnly(projects.sentryAndroidFragment) compileOnly(projects.sentryAndroidTimber) + compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) compileOnly(projects.sentryComposeHelper) @@ -104,6 +105,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) + testImplementation(projects.sentryAndroidReplay) testImplementation(projects.sentryComposeHelper) testImplementation(projects.sentryAndroidNdk) testRuntimeOnly(Config.Libs.composeUi) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 67d7e7691d5..0c6d47e5ecb 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -72,3 +72,9 @@ -keepnames class io.sentry.exception.SentryHttpClientException ##---------------End: proguard configuration for sentry-okhttp ---------- + +##---------------Begin: proguard configuration for sentry-android-replay ---------- +-dontwarn io.sentry.android.replay.ReplayIntegration +-dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter +-keepnames class io.sentry.android.replay.ReplayIntegration +##---------------End: proguard configuration for sentry-android-replay ---------- 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 1121a6bfe75..205360b8f17 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 @@ -371,7 +371,7 @@ private void finishTransaction( public synchronized void onActivityCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { setColdStart(savedInstanceState); - if (hub != null) { + if (hub != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); hub.configureScope(scope -> scope.setScreen(activityClassName)); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 372448b8e71..2d559fd7817 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -22,6 +22,8 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.fragment.FragmentLifecycleIntegration; +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; +import io.sentry.android.replay.ReplayIntegration; import io.sentry.android.timber.SentryTimberIntegration; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; @@ -29,6 +31,7 @@ import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; @@ -237,7 +240,8 @@ static void installDefaultIntegrations( final @NotNull LoadClass loadClass, final @NotNull ActivityFramesTracker activityFramesTracker, final boolean isFragmentAvailable, - final boolean isTimberAvailable) { + final boolean isTimberAvailable, + final boolean isReplayAvailable) { // Integration MUST NOT cache option values in ctor, as they will be configured later by the // user @@ -302,6 +306,13 @@ static void installDefaultIntegrations( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); + if (isReplayAvailable) { + final ReplayIntegration replay = + new ReplayIntegration(context, CurrentDateProvider.getInstance()); + replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter()); + options.addIntegration(replay); + options.setReplayController(replay); + } } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 45e4b78787d..5ef35cbfe1d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -10,6 +10,7 @@ import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryReplayEvent; import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -303,4 +304,17 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { return transaction; } + + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + final boolean applyScopeData = shouldApplyScopeData(event, hint); + if (applyScopeData) { + processNonCachedEvent(event, hint); + } + + setCommons(event, false, applyScopeData); + + return event; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 8c5d661524e..f1debc5d238 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -16,6 +16,7 @@ import android.util.DisplayMetrics; import io.sentry.DateUtils; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.android.core.internal.util.RootChecker; @@ -184,8 +185,8 @@ public ContextUtils.SideLoadedInfo getSideLoadedInfo() { private void setDeviceIO(final @NotNull Device device, final boolean includeDynamicData) { final Intent batteryIntent = getBatteryIntent(); if (batteryIntent != null) { - device.setBatteryLevel(getBatteryLevel(batteryIntent)); - device.setCharging(isCharging(batteryIntent)); + device.setBatteryLevel(getBatteryLevel(batteryIntent, options)); + device.setCharging(isCharging(batteryIntent, options)); device.setBatteryTemperature(getBatteryTemperature(batteryIntent)); } @@ -270,7 +271,8 @@ private Intent getBatteryIntent() { * @return the device's current battery level (as a percentage of total), or null if unknown */ @Nullable - private Float getBatteryLevel(final @NotNull Intent batteryIntent) { + public static Float getBatteryLevel( + final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) { try { int level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); @@ -294,7 +296,8 @@ private Float getBatteryLevel(final @NotNull Intent batteryIntent) { * @return whether or not the device is currently plugged in and charging, or null if unknown */ @Nullable - private Boolean isCharging(final @NotNull Intent batteryIntent) { + public static Boolean isCharging( + final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) { try { int plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); return plugged == BatteryManager.BATTERY_PLUGGED_AC diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 7b38bcd9c2f..81e77a75fb8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -11,6 +11,7 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,11 +20,12 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); + private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; private @Nullable TimerTask timerTask; - private final @Nullable Timer timer; + private final @NotNull Timer timer = new Timer(true); private final @NotNull Object timerLock = new Object(); private final @NotNull IHub hub; private final boolean enableSessionTracking; @@ -55,11 +57,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; this.hub = hub; this.currentDateProvider = currentDateProvider; - if (enableSessionTracking) { - timer = new Timer(true); - } else { - timer = null; - } } // App goes to foreground @@ -74,41 +71,46 @@ public void onStart(final @NotNull LifecycleOwner owner) { } private void startSession() { - if (enableSessionTracking) { - cancelTask(); + cancelTask(); - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - hub.configureScope( - scope -> { - if (lastUpdatedSession.get() == 0L) { - final @Nullable Session currentSession = scope.getSession(); - if (currentSession != null && currentSession.getStarted() != null) { - lastUpdatedSession.set(currentSession.getStarted().getTime()); - } + hub.configureScope( + scope -> { + if (lastUpdatedSession.get() == 0L) { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + lastUpdatedSession.set(currentSession.getStarted().getTime()); + isFreshSession.set(true); } - }); + } + }); - final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + final long lastUpdatedSession = this.lastUpdatedSession.get(); + if (lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + if (enableSessionTracking) { addSessionBreadcrumb("start"); hub.startSession(); } - this.lastUpdatedSession.set(currentTimeMillis); + hub.getOptions().getReplayController().start(); + } else if (!isFreshSession.get()) { + // only resume if it's not a fresh session, which has been started in SentryAndroid.init + hub.getOptions().getReplayController().resume(); } + isFreshSession.set(false); + this.lastUpdatedSession.set(currentTimeMillis); } // App went to background and triggered this callback after 700ms // as no new screen was shown @Override public void onStop(final @NotNull LifecycleOwner owner) { - if (enableSessionTracking) { - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - this.lastUpdatedSession.set(currentTimeMillis); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + this.lastUpdatedSession.set(currentTimeMillis); - scheduleEndSession(); - } + hub.getOptions().getReplayController().pause(); + scheduleEndSession(); AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); @@ -122,8 +124,11 @@ private void scheduleEndSession() { new TimerTask() { @Override public void run() { - addSessionBreadcrumb("end"); - hub.endSession(); + if (enableSessionTracking) { + addSessionBreadcrumb("end"); + hub.endSession(); + } + hub.getOptions().getReplayController().stop(); } }; @@ -164,7 +169,7 @@ TimerTask getTimerTask() { } @TestOnly - @Nullable + @NotNull Timer getTimer() { return timer; } 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 31e026dd009..e1f227e90c6 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 @@ -104,6 +104,14 @@ final class ManifestMetadataReader { static final String ENABLE_METRICS = "io.sentry.enable-metrics"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; + + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; + + static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + + static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -382,6 +390,41 @@ static void applyMetadata( options.setEnableMetrics( readBool(metadata, logger, ENABLE_METRICS, options.isEnableMetrics())); + + if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { + final Double sessionSampleRate = + readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); + if (sessionSampleRate != -1) { + options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); + } + } + + if (options.getExperimental().getSessionReplay().getErrorSampleRate() == null) { + final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (errorSampleRate != -1) { + options.getExperimental().getSessionReplay().setErrorSampleRate(errorSampleRate); + } + } + + options + .getExperimental() + .getSessionReplay() + .setRedactAllText( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_TEXT, + options.getExperimental().getSessionReplay().getRedactAllText())); + + options + .getExperimental() + .getSessionReplay() + .setRedactAllImages( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_IMAGES, + options.getExperimental().getSessionReplay().getRedactAllImages())); } options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 46590826ef1..d444d08cb0c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -36,6 +36,9 @@ public final class SentryAndroid { static final String SENTRY_TIMBER_INTEGRATION_CLASS_NAME = "io.sentry.android.timber.SentryTimberIntegration"; + static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = + "io.sentry.android.replay.ReplayIntegration"; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -102,6 +105,8 @@ public static synchronized void init( final boolean isTimberAvailable = (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); + final boolean isReplayAvailable = + classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final LoadClass loadClass = new LoadClass(); @@ -121,7 +126,8 @@ public static synchronized void init( loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable); + isTimberAvailable, + isReplayAvailable); configuration.configure(options); @@ -148,22 +154,25 @@ public static synchronized void init( true); final @NotNull IHub hub = Sentry.getCurrentHub(); - if (hub.getOptions().isEnableAutoSessionTracking() && ContextUtils.isForegroundImportance()) { - // The LifecycleWatcher of AppLifecycleIntegration may already started a session - // so only start a session if it's not already started - // This e.g. happens on React Native, or e.g. on deferred SDK init - final AtomicBoolean sessionStarted = new AtomicBoolean(false); - hub.configureScope( - scope -> { - final @Nullable Session currentSession = scope.getSession(); - if (currentSession != null && currentSession.getStarted() != null) { - sessionStarted.set(true); - } - }); - if (!sessionStarted.get()) { - hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); - hub.startSession(); + if (ContextUtils.isForegroundImportance()) { + if (hub.getOptions().isEnableAutoSessionTracking()) { + // The LifecycleWatcher of AppLifecycleIntegration may already started a session + // so only start a session if it's not already started + // This e.g. happens on React Native, or e.g. on deferred SDK init + final AtomicBoolean sessionStarted = new AtomicBoolean(false); + hub.configureScope( + scope -> { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + sessionStarted.set(true); + } + }); + if (!sessionStarted.get()) { + hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); + hub.startSession(); + } } + hub.getOptions().getReplayController().start(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 1c22a7dcc86..dcd92e8bf88 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -6,6 +6,7 @@ import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; import static android.content.Intent.ACTION_APP_ERROR; +import static android.content.Intent.ACTION_BATTERY_CHANGED; import static android.content.Intent.ACTION_BATTERY_LOW; import static android.content.Intent.ACTION_BATTERY_OKAY; import static android.content.Intent.ACTION_BOOT_COMPLETED; @@ -41,10 +42,11 @@ import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IHub; -import io.sentry.ILogger; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; import io.sentry.util.Objects; import io.sentry.util.StringUtils; import java.io.Closeable; @@ -120,7 +122,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio private void startSystemEventsReceiver( final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { - receiver = new SystemEventsBroadcastReceiver(hub, options.getLogger()); + receiver = new SystemEventsBroadcastReceiver(hub, options); final IntentFilter filter = new IntentFilter(); for (String item : actions) { filter.addAction(item); @@ -154,6 +156,7 @@ private void startSystemEventsReceiver( actions.add(ACTION_AIRPLANE_MODE_CHANGED); actions.add(ACTION_BATTERY_LOW); actions.add(ACTION_BATTERY_OKAY); + actions.add(ACTION_BATTERY_CHANGED); actions.add(ACTION_BOOT_COMPLETED); actions.add(ACTION_CAMERA_BUTTON); actions.add(ACTION_CONFIGURATION_CHANGED); @@ -204,45 +207,69 @@ public void close() throws IOException { static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; private final @NotNull IHub hub; - private final @NotNull ILogger logger; + private final @NotNull SentryAndroidOptions options; + private final @NotNull Debouncer debouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); - SystemEventsBroadcastReceiver(final @NotNull IHub hub, final @NotNull ILogger logger) { + SystemEventsBroadcastReceiver( + final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { this.hub = hub; - this.logger = logger; + this.options = options; } @Override public void onReceive(Context context, Intent intent) { + final boolean shouldDebounce = debouncer.checkForDebounce(); + final String action = intent.getAction(); + final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); + if (isBatteryChanged && shouldDebounce) { + // aligning with iOS which only captures battery status changes every minute at maximum + return; + } + final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - final String action = intent.getAction(); String shortAction = StringUtils.getStringAfterDot(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } - final Bundle extras = intent.getExtras(); - final Map newExtras = new HashMap<>(); - if (extras != null && !extras.isEmpty()) { - for (String item : extras.keySet()) { - try { - @SuppressWarnings("deprecation") - Object value = extras.get(item); - if (value != null) { - newExtras.put(item, value.toString()); + if (isBatteryChanged) { + final Float batteryLevel = DeviceInfoUtil.getBatteryLevel(intent, options); + if (batteryLevel != null) { + breadcrumb.setData("level", batteryLevel); + } + final Boolean isCharging = DeviceInfoUtil.isCharging(intent, options); + if (isCharging != null) { + breadcrumb.setData("charging", isCharging); + } + } else { + final Bundle extras = intent.getExtras(); + final Map newExtras = new HashMap<>(); + if (extras != null && !extras.isEmpty()) { + for (String item : extras.keySet()) { + try { + @SuppressWarnings("deprecation") + Object value = extras.get(item); + if (value != null) { + newExtras.put(item, value.toString()); + } + } catch (Throwable exception) { + options + .getLogger() + .log( + SentryLevel.ERROR, + exception, + "%s key of the %s action threw an error.", + item, + action); } - } catch (Throwable exception) { - logger.log( - SentryLevel.ERROR, - exception, - "%s key of the %s action threw an error.", - item, - action); } + breadcrumb.setData("extras", newExtras); } - breadcrumb.setData("extras", newExtras); } breadcrumb.setLevel(SentryLevel.INFO); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 7800063b352..ed2fa3338a5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -15,6 +15,7 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator import io.sentry.android.core.internal.modules.AssetsModulesLoader import io.sentry.android.core.internal.util.AndroidMainThreadChecker import io.sentry.android.fragment.FragmentLifecycleIntegration +import io.sentry.android.replay.ReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver @@ -83,6 +84,7 @@ class AndroidOptionsInitializerTest { loadClass, activityFramesTracker, false, + false, false ) @@ -99,7 +101,8 @@ class AndroidOptionsInitializerTest { minApi: Int = Build.VERSION_CODES.KITKAT, classesToLoad: List = emptyList(), isFragmentAvailable: Boolean = false, - isTimberAvailable: Boolean = false + isTimberAvailable: Boolean = false, + isReplayAvailable: Boolean = false ) { mockContext = ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext = true), @@ -126,7 +129,8 @@ class AndroidOptionsInitializerTest { loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable + isTimberAvailable, + isReplayAvailable ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -478,6 +482,31 @@ class AndroidOptionsInitializerTest { assertNull(actual) } + @Test + fun `ReplayIntegration added to the integration list if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ReplayIntegration } + assertNotNull(actual) + } + + @Test + fun `ReplayIntegration set as ReplayController if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + assertTrue(fixture.sentryOptions.replayController is ReplayIntegration) + } + + @Test + fun `ReplayIntegration won't be enabled, it throws class not found`() { + fixture.initSutWithClassLoader(isReplayAvailable = false) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ReplayIntegration } + assertNull(actual) + } + @Test fun `AndroidEnvelopeCache is set to options`() { fixture.initSut() @@ -634,6 +663,7 @@ class AndroidOptionsInitializerTest { mock(), mock(), false, + false, false ) verify(mockOptions, never()).outboxPath diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index 8219a273d01..c5bb334bb3b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -118,6 +118,7 @@ class AndroidProfilerTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index fd03d346313..02cda7d23ba 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -125,6 +125,7 @@ class AndroidTransactionProfilerTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index be309931429..388bfbe274f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -5,8 +5,10 @@ import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub import io.sentry.IScope +import io.sentry.ReplayController import io.sentry.ScopeCallback import io.sentry.SentryLevel +import io.sentry.SentryOptions import io.sentry.Session import io.sentry.Session.State import io.sentry.transport.ICurrentDateProvider @@ -34,6 +36,8 @@ class LifecycleWatcherTest { val ownerMock = mock() val hub = mock() val dateProvider = mock() + val options = SentryOptions() + val replayController = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -47,6 +51,8 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + options.setReplayController(replayController) + whenever(hub.options).thenReturn(options) return LifecycleWatcher( hub, @@ -70,6 +76,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -79,6 +86,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub, times(2)).startSession() + verify(fixture.replayController, times(2)).start() } @Test @@ -88,6 +96,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -96,6 +105,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStop(fixture.ownerMock) verify(fixture.hub, timeout(10000)).endSession() + verify(fixture.replayController, timeout(10000)).stop() } @Test @@ -110,6 +120,7 @@ class LifecycleWatcherTest { assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() + verify(fixture.replayController, never()).stop() } @Test @@ -123,7 +134,6 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not end session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() } @@ -167,7 +177,6 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) verify(fixture.hub, never()).addBreadcrumb(any()) } @@ -219,12 +228,6 @@ class LifecycleWatcherTest { assertNotNull(watcher.timer) } - @Test - fun `timer is not created if session tracking is disabled`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - assertNull(watcher.timer) - } - @Test fun `if the hub has already a fresh session running, don't start new one`() { val watcher = fixture.getSUT( @@ -249,6 +252,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub, never()).startSession() + verify(fixture.replayController, never()).start() } @Test @@ -275,6 +279,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -290,4 +295,50 @@ class LifecycleWatcherTest { watcher.onStop(fixture.ownerMock) assertTrue(AppState.getInstance().isInBackground!!) } + + @Test + fun `if the hub has already a fresh session running, doesn't resume replay`() { + val watcher = fixture.getSUT( + enableAppLifecycleBreadcrumbs = false, + session = Session( + State.Ok, + DateUtils.getCurrentDateTime(), + DateUtils.getCurrentDateTime(), + 0, + "abc", + UUID.fromString("3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17"), + true, + 0, + 10.0, + null, + null, + null, + "release", + null + ) + ) + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController, never()).resume() + } + + @Test + fun `background-foreground replay`() { + whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) + val watcher = fixture.getSUT( + sessionIntervalMillis = 2L, + enableAppLifecycleBreadcrumbs = false + ) + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).start() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController).pause() + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).resume() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController, timeout(10000)).stop() + } } 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 4a8e57303e7..162b1fde710 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 @@ -1420,4 +1420,73 @@ class ManifestMetadataReaderTest { // Assert assertFalse(fixture.options.isEnableMetrics) } + + @Test + fun `applyMetadata reads replays errorSampleRate from metadata`() { + // Arrange + val expectedSampleRate = 0.99f + + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to expectedSampleRate) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata does not override replays errorSampleRate from options`() { + // Arrange + val expectedSampleRate = 0.99f + fixture.options.experimental.sessionReplay.errorSampleRate = expectedSampleRate.toDouble() + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata without specifying replays errorSampleRate, stays null`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata reads session replay redact flags to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.experimental.sessionReplay.redactAllImages) + assertFalse(fixture.options.experimental.sessionReplay.redactAllText) + } + + @Test + fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.experimental.sessionReplay.redactAllImages) + assertTrue(fixture.options.experimental.sessionReplay.redactAllText) + } } 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 990c3f4b135..cf001735132 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 @@ -28,6 +28,7 @@ import io.sentry.UncaughtExceptionHandlerIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration +import io.sentry.android.replay.ReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingOptionsObserver @@ -331,13 +332,27 @@ class SentryAndroidTest { verify(client, times(1)).captureSession(any(), any()) } + @Test + @Config(sdk = [26]) + fun `init starts session replay if app is in foreground`() { + initSentryWithForegroundImportance(true) { _ -> + assertTrue(Sentry.getCurrentHub().options.replayController.isRecording()) + } + } + + @Test + @Config(sdk = [26]) + fun `init does not start session replay if the app is in background`() { + initSentryWithForegroundImportance(false) { _ -> + assertFalse(Sentry.getCurrentHub().options.replayController.isRecording()) + } + } + private fun initSentryWithForegroundImportance( inForeground: Boolean, optionsConfig: (SentryAndroidOptions) -> Unit = {}, callback: (session: Session?) -> Unit ) { - val context = ContextUtilsTestHelper.createMockContext() - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) @@ -345,6 +360,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true + options.experimental.sessionReplay.errorSampleRate = 1.0 optionsConfig(options) } @@ -432,7 +448,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(20, options.integrations.size) + assertEquals(21, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -452,7 +468,8 @@ class SentryAndroidTest { it is NetworkBreadcrumbsIntegration || it is TempSensorBreadcrumbsIntegration || it is PhoneStateBreadcrumbsIntegration || - it is SpotlightIntegration + it is SpotlightIntegration || + it is ReplayIntegration } } assertEquals(0, optionsRef.integrations.size) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index a83076efb04..5b546523d01 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -143,6 +143,7 @@ class SentryInitProviderTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index 1a441cd8328..e6d3dfadd7e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -16,6 +16,7 @@ import io.sentry.Sentry import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext import io.sentry.UserFeedback @@ -146,6 +147,14 @@ class SessionTrackingIntegrationTest { TODO("Not yet implemented") } + override fun captureReplayEvent( + event: SentryReplayEvent, + scope: IScope?, + hint: Hint? + ): SentryId { + TODO("Not yet implemented") + } + override fun captureUserFeedback(userFeedback: UserFeedback) { TODO("Not yet implemented") } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index f8293f9b87f..3dfca15fdb3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -2,18 +2,22 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.os.BatteryManager +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IHub import io.sentry.ISentryExecutorService import io.sentry.SentryLevel import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals @@ -21,6 +25,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +@RunWith(AndroidJUnit4::class) class SystemEventsBreadcrumbsIntegrationTest { private class Fixture { @@ -111,6 +116,61 @@ class SystemEventsBreadcrumbsIntegrationTest { ) } + @Test + fun `handles battery changes`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + val intent = Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 75) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + sut.receiver!!.onReceive(fixture.context, intent) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("device.event", it.category) + assertEquals("system", it.type) + assertEquals(SentryLevel.INFO, it.level) + assertEquals(it.data["level"], 75f) + assertEquals(it.data["charging"], true) + }, + anyOrNull() + ) + } + + @Test + fun `battery changes are debounced`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + val intent1 = Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 80) + putExtra(BatteryManager.EXTRA_SCALE, 100) + } + val intent2 = Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 75) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + sut.receiver!!.onReceive(fixture.context, intent1) + sut.receiver!!.onReceive(fixture.context, intent2) + + // should only add the first crumb + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(it.data["level"], 80f) + assertEquals(it.data["charging"], false) + }, + anyOrNull() + ) + verifyNoMoreInteractions(fixture.hub) + } + @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 5e03c99e0d2..18468b99c1c 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -81,6 +81,9 @@ class SentryFragmentLifecycleCallbacks( // we only start the tracing for the fragment if the fragment has been added to its activity // and not only to the backstack if (fragment.isAdded) { + if (hub.options.isEnableScreenTracking) { + hub.configureScope { it.screen = getFragmentName(fragment) } + } startTracing(fragment) } } 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 49f7f0749d8..02f5e80ba30 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -39,4 +39,4 @@ -dontwarn org.opentest4j.AssertionFailedError -dontwarn org.mockito.internal.** -dontwarn org.jetbrains.annotations.** - +-dontwarn io.sentry.android.replay.ReplayIntegration diff --git a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt index 8fdf8b0df88..dac8e54e805 100644 --- a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt +++ b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt @@ -1,5 +1,6 @@ package io.sentry.android.navigation +import android.content.Context import android.content.res.Resources.NotFoundException import android.os.Bundle import androidx.navigation.NavController @@ -59,9 +60,15 @@ class SentryNavigationListener @JvmOverloads constructor( arguments: Bundle? ) { val toArguments = arguments.refined() - addBreadcrumb(destination, toArguments) - startTracing(controller, destination, toArguments) + + val routeName = destination.extractName(controller.context) + if (routeName != null) { + if (hub.options.isEnableScreenTracking) { + hub.configureScope { it.screen = routeName } + } + startTracing(routeName, destination, toArguments) + } previousDestinationRef = WeakReference(destination) previousArgs = arguments } @@ -95,7 +102,7 @@ class SentryNavigationListener @JvmOverloads constructor( } private fun startTracing( - controller: NavController, + routeName: String, destination: NavDestination, arguments: Map ) { @@ -118,20 +125,6 @@ class SentryNavigationListener @JvmOverloads constructor( return } - @Suppress("SwallowedException") // we swallow it on purpose - var name = destination.route ?: try { - controller.context.resources.getResourceEntryName(destination.id) - } catch (e: NotFoundException) { - hub.options.logger.log( - DEBUG, - "Destination id cannot be retrieved from Resources, no transaction captured." - ) - return - } - - // we add '/' to the name to match dart and web pattern - name = "/" + name.substringBefore('/') // strip out arguments from the tx name - val transactionOptions = TransactionOptions().also { it.isWaitForChildren = true it.idleTimeout = hub.options.idleTimeout @@ -140,7 +133,7 @@ class SentryNavigationListener @JvmOverloads constructor( } val transaction = hub.startTransaction( - TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP), + TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP), transactionOptions ) @@ -184,6 +177,22 @@ class SentryNavigationListener @JvmOverloads constructor( }.associateWith { args[it] } } ?: emptyMap() + @Suppress("SwallowedException") // we swallow it on purpose + private fun NavDestination.extractName(context: Context): String? { + val name = route ?: try { + context.resources.getResourceEntryName(id) + } catch (e: NotFoundException) { + hub.options.logger.log( + DEBUG, + "Destination id cannot be retrieved from Resources, no transaction captured." + ) + null + } ?: return null + + // we add '/' to the name to match dart and web pattern + return "/" + name.substringBefore('/') // strip out arguments from the tx name + } + companion object { const val NAVIGATION_OP = "navigation" } diff --git a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt index 76c57159c34..342673dafba 100644 --- a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt +++ b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt @@ -56,6 +56,7 @@ class SentryNavigationListenerTest { toId: String? = "destination-id-1", enableBreadcrumbs: Boolean = true, enableTracing: Boolean = true, + enableScreenTracking: Boolean = true, tracesSampleRate: Double? = 1.0, hasViewIdInRes: Boolean = true, transaction: SentryTracer? = null, @@ -66,6 +67,7 @@ class SentryNavigationListenerTest { setTracesSampleRate( tracesSampleRate ) + isEnableScreenTracking = enableScreenTracking } whenever(hub.options).thenReturn(options) @@ -371,7 +373,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).configureScope(any()) + verify(fixture.hub, times(2)).configureScope(any()) assertNotSame(propagationContextAtStart, scope.propagationContext) } @@ -406,4 +408,22 @@ class SentryNavigationListenerTest { } ) } + + @Test + fun `onDestinationChanged sets scope screen`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.scope).screen = "/route" + } + + @Test + fun `onDestinationChanged does not set scope screen when screen tracking is disabled`() { + val sut = fixture.getSut(enableScreenTracking = false) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.scope, never()).screen = "/route" + } } diff --git a/sentry-android-replay/.gitignore b/sentry-android-replay/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/sentry-android-replay/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api new file mode 100644 index 00000000000..c98749c2712 --- /dev/null +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -0,0 +1,195 @@ +public final class io/sentry/android/replay/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public fun ()V + public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; +} + +public final class io/sentry/android/replay/GeneratedVideo { + public fun (Ljava/io/File;IJ)V + public final fun component1 ()Ljava/io/File; + public final fun component2 ()I + public final fun component3 ()J + public final fun copy (Ljava/io/File;IJ)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun copy$default (Lio/sentry/android/replay/GeneratedVideo;Ljava/io/File;IJILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public fun equals (Ljava/lang/Object;)Z + public final fun getDuration ()J + public final fun getFrameCount ()I + public final fun getVideo ()Ljava/io/File; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun start (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public abstract fun stop ()V +} + +public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public final fun addFrame (Ljava/io/File;J)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 rotate (J)V +} + +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable { + 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/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 close ()V + public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; + public final fun getReplayCacheDir ()Ljava/io/File; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun isRecording ()Z + public fun onConfigurationChanged (Landroid/content/res/Configuration;)V + public fun onLowMemory ()V + public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public fun onScreenshotRecorded (Ljava/io/File;J)V + public fun onTouchEvent (Landroid/view/MotionEvent;)V + public fun pause ()V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun resume ()V + public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public fun start ()V + public fun stop ()V +} + +public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { + public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public abstract fun onScreenshotRecorded (Ljava/io/File;J)V +} + +public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; + public fun (IIFFII)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()F + public final fun component4 ()F + public final fun component5 ()I + public final fun component6 ()I + public final fun copy (IIFFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFFIIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getBitRate ()I + public final fun getFrameRate ()I + public final fun getRecordingHeight ()I + public final fun getRecordingWidth ()I + public final fun getScaleFactorX ()F + public final fun getScaleFactorY ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { + public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; +} + +public abstract interface class io/sentry/android/replay/TouchRecorderCallback { + public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V +} + +public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { + public final field delegate Landroid/view/Window$Callback; + public fun (Landroid/view/Window$Callback;)V + public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z + public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z + public fun dispatchKeyShortcutEvent (Landroid/view/KeyEvent;)Z + public fun dispatchPopulateAccessibilityEvent (Landroid/view/accessibility/AccessibilityEvent;)Z + public fun dispatchTouchEvent (Landroid/view/MotionEvent;)Z + public fun dispatchTrackballEvent (Landroid/view/MotionEvent;)Z + public fun onActionModeFinished (Landroid/view/ActionMode;)V + public fun onActionModeStarted (Landroid/view/ActionMode;)V + public fun onAttachedToWindow ()V + public fun onContentChanged ()V + public fun onCreatePanelMenu (ILandroid/view/Menu;)Z + public fun onCreatePanelView (I)Landroid/view/View; + public fun onDetachedFromWindow ()V + public fun onMenuItemSelected (ILandroid/view/MenuItem;)Z + public fun onMenuOpened (ILandroid/view/Menu;)Z + public fun onPanelClosed (ILandroid/view/Menu;)V + public fun onPointerCaptureChanged (Z)V + public fun onPreparePanel (ILandroid/view/View;Landroid/view/Menu;)Z + public fun onProvideKeyboardShortcuts (Ljava/util/List;Landroid/view/Menu;I)V + public fun onSearchRequested ()Z + public fun onSearchRequested (Landroid/view/SearchEvent;)Z + public fun onWindowAttributesChanged (Landroid/view/WindowManager$LayoutParams;)V + public fun onWindowFocusChanged (Z)V + public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;)Landroid/view/ActionMode; + public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; +} + +public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { + public abstract fun getVideoTime ()J + public abstract fun isStarted ()Z + public abstract fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V + public abstract fun release ()V + public abstract fun start (Landroid/media/MediaFormat;)V +} + +public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public fun (Ljava/lang/String;F)V + public fun getVideoTime ()J + public fun isStarted ()Z + public fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V + public fun release ()V + public fun start (Landroid/media/MediaFormat;)V +} + +public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getChildren ()Ljava/util/List; + public final fun getDistance ()I + public final fun getElevation ()F + public final fun getHeight ()I + public final fun getParent ()Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun getShouldRedact ()Z + public final fun getVisibleRect ()Landroid/graphics/Rect; + public final fun getWidth ()I + public final fun getX ()F + public final fun getY ()F + public final fun isImportantForContentCapture ()Z + public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z + public final fun isVisible ()Z + public final fun setChildren (Ljava/util/List;)V + public final fun setImportantForContentCapture (Z)V + public final fun traverse (Lkotlin/jvm/functions/Function1;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { + public final fun fromView (Landroid/view/View;Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ILio/sentry/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDominantColor ()Ljava/lang/Integer; + public final fun getLayout ()Landroid/text/Layout; + public final fun getPaddingLeft ()I + public final fun getPaddingTop ()I +} + diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts new file mode 100644 index 00000000000..bd9b5d961b2 --- /dev/null +++ b/sentry-android-replay/build.gradle.kts @@ -0,0 +1,84 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.config.KotlinCompilerVersion + +plugins { + id("com.android.library") + kotlin("android") + jacoco + id(Config.QualityPlugins.jacocoAndroid) + id(Config.QualityPlugins.gradleVersions) + // TODO: enable it later +// id(Config.QualityPlugins.detektPlugin) +} + +android { + compileSdk = Config.Android.compileSdkVersion + namespace = "io.sentry.android.replay" + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersionReplay + + testInstrumentationRunner = Config.TestLibs.androidJUnitRunner + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + buildTypes { + getByName("debug") + getByName("release") + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +kotlin { + explicitApi() +} + +dependencies { + api(projects.sentry) + + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(Config.TestLibs.robolectric) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.androidxRunner) + testImplementation(Config.TestLibs.androidxJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.awaitility) +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro new file mode 100644 index 00000000000..738204b4c8b --- /dev/null +++ b/sentry-android-replay/proguard-rules.pro @@ -0,0 +1,3 @@ +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt new file mode 100644 index 00000000000..504c4adf214 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -0,0 +1,157 @@ +package io.sentry.android.replay + +import io.sentry.Breadcrumb +import io.sentry.ReplayBreadcrumbConverter +import io.sentry.SentryLevel +import io.sentry.SpanDataConvention +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebSpanEvent +import kotlin.LazyThreadSafetyMode.NONE + +public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { + internal companion object { + private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } + private val supportedNetworkData = setOf( + "status_code", + "method", + "response_content_length", + "request_content_length", + "http.response_content_length", + "http.request_content_length" + ) + } + + private var lastConnectivityState: String? = null + + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { + var breadcrumbMessage: String? = null + var breadcrumbCategory: String? = null + var breadcrumbLevel: SentryLevel? = null + val breadcrumbData = mutableMapOf() + when { + breadcrumb.category == "http" -> { + return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null + } + + breadcrumb.type == "navigation" && + breadcrumb.category == "app.lifecycle" -> { + breadcrumbCategory = "app.${breadcrumb.data["state"]}" + } + + breadcrumb.type == "navigation" && + breadcrumb.category == "device.orientation" -> { + breadcrumbCategory = breadcrumb.category!! + val position = breadcrumb.data["position"] + if (position == "landscape" || position == "portrait") { + breadcrumbData["position"] = position + } else { + return null + } + } + + breadcrumb.type == "navigation" -> { + breadcrumbCategory = "navigation" + breadcrumbData["to"] = when { + breadcrumb.data["state"] == "resumed" -> (breadcrumb.data["screen"] as? String)?.substringAfterLast('.') + "to" in breadcrumb.data -> breadcrumb.data["to"] as? String + else -> null + } ?: return null + } + + breadcrumb.category == "ui.click" -> { + breadcrumbCategory = "ui.tap" + breadcrumbMessage = ( + breadcrumb.data["view.id"] + ?: breadcrumb.data["view.tag"] + ?: breadcrumb.data["view.class"] + ) as? String ?: return null + breadcrumbData.putAll(breadcrumb.data) + } + + breadcrumb.type == "system" && breadcrumb.category == "network.event" -> { + breadcrumbCategory = "device.connectivity" + breadcrumbData["state"] = when { + breadcrumb.data["action"] == "NETWORK_LOST" -> "offline" + "network_type" in breadcrumb.data -> if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) { + breadcrumb.data["network_type"] + } else { + return null + } + + else -> return null + } + + if (lastConnectivityState == breadcrumbData["state"]) { + // debounce same state + return null + } + + lastConnectivityState = breadcrumbData["state"] as? String + } + + breadcrumb.data["action"] == "BATTERY_CHANGED" -> { + breadcrumbCategory = "device.battery" + breadcrumbData.putAll( + breadcrumb.data.filterKeys { it == "level" || it == "charging" } + ) + } + + else -> { + breadcrumbCategory = breadcrumb.category + breadcrumbMessage = breadcrumb.message + breadcrumbLevel = breadcrumb.level + breadcrumbData.putAll(breadcrumb.data) + } + } + return if (!breadcrumbCategory.isNullOrEmpty()) { + RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 + breadcrumbType = "default" + category = breadcrumbCategory + message = breadcrumbMessage + level = breadcrumbLevel + data = breadcrumbData + } + } else { + null + } + } + + private fun Breadcrumb.isValidForRRWebSpan(): Boolean { + return !(data["url"] as? String).isNullOrEmpty() && + SpanDataConvention.HTTP_START_TIMESTAMP in data && + SpanDataConvention.HTTP_END_TIMESTAMP in data + } + + private fun String.snakeToCamelCase(): String { + return replace(snakecasePattern) { it.value.last().uppercase() } + } + + private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { + val breadcrumb = this + return RRWebSpanEvent().apply { + timestamp = breadcrumb.timestamp.time + op = "resource.http" + description = breadcrumb.data["url"] as String + startTimestamp = + (breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0 + endTimestamp = + (breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0 + + val breadcrumbData = mutableMapOf() + for ((key, value) in breadcrumb.data) { + if (key in supportedNetworkData) { + breadcrumbData[ + key + .replace("content_length", "body_size") + .substringAfter(".") + .snakeToCamelCase() + ] = value + } + } + data = breadcrumbData + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt new file mode 100644 index 00000000000..6cf86b6a7e6 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt @@ -0,0 +1,18 @@ +package io.sentry.android.replay + +import java.io.Closeable + +interface Recorder : Closeable { + /** + * @param recorderConfig a [ScreenshotRecorderConfig] that can be used to determine frame rate + * at which the screenshots should be taken, and the screenshots size/resolution, which can + * change e.g. in the case of orientation change or window size change + */ + fun start(recorderConfig: ScreenshotRecorderConfig) + + fun resume() + + fun pause() + + fun stop() +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt new file mode 100644 index 00000000000..f49abfaa846 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -0,0 +1,252 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.BitmapFactory +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SentryId +import java.io.Closeable +import java.io.File + +/** + * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the + * [SentryOptions.cacheDirPath] + [replayId] folder. The class is also capable of creating an mp4 + * video segment out of the stored frames, provided start time and duration using the available + * on-device [android.media.MediaCodec]. + * + * This class is not thread-safe, meaning, [addFrame] cannot be called concurrently with + * [createVideoOf], and they should be invoked from the same thread. + * + * @param options SentryOptions instance, used for logging and cacheDir + * @param replayId the current replay id, used for giving a unique name to the replay folder + * @param recorderConfig ScreenshotRecorderConfig, used for video resolution and frame-rate + */ +public class ReplayCache internal constructor( + private val options: SentryOptions, + private val replayId: SentryId, + private val recorderConfig: ScreenshotRecorderConfig, + private val encoderProvider: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder +) : Closeable { + + public constructor( + options: SentryOptions, + replayId: SentryId, + recorderConfig: ScreenshotRecorderConfig + ) : this(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) + ).also { it.start() } + }) + + private val encoderLock = Any() + private var encoder: SimpleVideoEncoder? = null + + internal val replayCacheDir: File? by lazy { + if (options.cacheDirPath.isNullOrEmpty()) { + options.logger.log( + WARNING, + "SentryOptions.cacheDirPath is not set, session replay is no-op" + ) + null + } else { + File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + } + } + + // TODO: maybe account for multi-threaded access + internal val frames = mutableListOf() + + /** + * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] + * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored + * under [replayCacheDir]. + * + * This method is not thread-safe. + * + * @param bitmap the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { + if (replayCacheDir == null) { + return + } + + val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { + it.createNewFile() + } + screenshot.outputStream().use { + bitmap.compress(JPEG, 80, it) + it.flush() + } + + addFrame(screenshot, frameTimestamp) + } + + /** + * Same as [addFrame], but accepts frame screenshot as [File], the file should contain + * a bitmap/image by the time [createVideoOf] is invoked. + * + * This method is not thread-safe. + * + * @param screenshot file containing the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + public fun addFrame(screenshot: File, frameTimestamp: Long) { + val frame = ReplayFrame(screenshot, frameTimestamp) + frames += frame + } + + /** + * Creates a video out of currently stored [frames] given the start time and duration using the + * on-device codecs [android.media.MediaCodec]. The generated video will be stored in + * [videoFile] location, which defaults to "[replayCacheDir]/[segmentId].mp4". + * + * This method is not thread-safe. + * + * @param duration desired video duration in milliseconds + * @param from desired start of the video represented as unix timestamp in milliseconds + * @param segmentId current segment id, used for inferring the filename to store the + * result video under [replayCacheDir], e.g. "replay_/0.mp4", where segmentId=0 + * @param height desired height of the video in pixels (e.g. it can change from the initial one + * in case of window resize or orientation change) + * @param width desired width of the video in pixels (e.g. it can change from the initial one + * in case of window resize or orientation change) + * @param videoFile optional, location of the file to store the result video. If this is + * provided, [segmentId] from above is disregarded and not used. + * @return a generated video of type [GeneratedVideo], which contains the resulting video file + * location, frame count and duration in milliseconds. + */ + public fun createVideoOf( + duration: Long, + from: Long, + segmentId: Int, + height: Int, + width: Int, + videoFile: File = File(replayCacheDir, "$segmentId.mp4") + ): GeneratedVideo? { + if (frames.isEmpty()) { + options.logger.log( + DEBUG, + "No captured frames, skipping generating a video segment" + ) + return null + } + + // TODO: reuse instance of encoder and just change file path to create a different muxer + encoder = synchronized(encoderLock) { encoderProvider(videoFile, height, width) } + + val step = 1000 / recorderConfig.frameRate.toLong() + var frameCount = 0 + var lastFrame: ReplayFrame = frames.first() + for (timestamp in from until (from + (duration)) step step) { + val iter = frames.iterator() + while (iter.hasNext()) { + val frame = iter.next() + if (frame.timestamp in (timestamp..timestamp + step)) { + lastFrame = frame + break // we only support 1 frame per given interval + } + + // assuming frames are in order, if out of bounds exit early + if (frame.timestamp > timestamp + step) { + break + } + } + + // we either encode a new frame within the step bounds or replicate the last known frame + // to respect the video duration + if (encode(lastFrame)) { + frameCount++ + } + } + + if (frameCount == 0) { + options.logger.log( + DEBUG, + "Generated a video with no frames, not capturing a replay segment" + ) + deleteFile(videoFile) + return null + } + + var videoDuration: Long + synchronized(encoderLock) { + encoder?.release() + videoDuration = encoder?.duration ?: 0 + encoder = null + } + + rotate(until = (from + duration)) + + return GeneratedVideo(videoFile, frameCount, videoDuration) + } + + private fun encode(frame: ReplayFrame): Boolean { + return try { + val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) + synchronized(encoderLock) { + encoder?.encode(bitmap) + } + bitmap.recycle() + true + } catch (e: Throwable) { + options.logger.log(WARNING, "Unable to decode bitmap and encode it into a video, skipping frame", e) + false + } + } + + private fun deleteFile(file: File) { + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay frame: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay frame: %s", file.absolutePath) + } + } + + /** + * Removes frames from the in-memory and disk cache from start to [until]. + * + * @param until value until whose the frames should be removed, represented as unix timestamp + */ + fun rotate(until: Long) { + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } + return@removeAll false + } + } + + override fun close() { + synchronized(encoderLock) { + encoder?.release() + encoder = null + } + } +} + +internal data class ReplayFrame( + val screenshot: File, + val timestamp: Long +) + +public data class GeneratedVideo( + val video: File, + val frameCount: Int, + val duration: Long +) 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 new file mode 100644 index 00000000000..c6410339ba2 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -0,0 +1,245 @@ +package io.sentry.android.replay + +import android.content.ComponentCallbacks +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.os.Build +import android.view.MotionEvent +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Integration +import io.sentry.NoOpReplayBreadcrumbConverter +import io.sentry.ReplayBreadcrumbConverter +import io.sentry.ReplayController +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.android.replay.capture.BufferCaptureStrategy +import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.SessionCaptureStrategy +import io.sentry.android.replay.util.sample +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import java.io.Closeable +import java.io.File +import java.security.SecureRandom +import java.util.concurrent.atomic.AtomicBoolean + +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) -> ReplayCache)? = null +) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, ComponentCallbacks { + + // needed for the Java's call site + constructor(context: Context, dateProvider: ICurrentDateProvider) : this( + context, + dateProvider, + null, + null, + null + ) + + private lateinit var options: SentryOptions + private var hub: IHub? = null + private var recorder: Recorder? = null + private val random by lazy { SecureRandom() } + + // TODO: probably not everything has to be thread-safe here + private val isEnabled = AtomicBoolean(false) + private val isRecording = AtomicBoolean(false) + private var captureStrategy: CaptureStrategy? = null + public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir + private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() + + private lateinit var recorderConfig: ScreenshotRecorderConfig + + override fun register(hub: IHub, options: SentryOptions) { + this.options = options + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + options.logger.log(INFO, "Session replay is only supported on API 26 and above") + return + } + + if (!options.experimental.sessionReplay.isSessionReplayEnabled && + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, no sample rate specified") + return + } + + this.hub = hub + this.options.addScopeObserver(object : ScopeObserverAdapter() { + override fun setContexts(contexts: Contexts) { + // scope screen has fully-qualified name + captureStrategy?.onScreenChanged(contexts.app?.viewNames?.lastOrNull()?.substringAfterLast('.')) + } + }) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this) + isEnabled.set(true) + + 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(javaClass) + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) + } + + override fun isRecording() = isRecording.get() + + override fun start() { + // TODO: add lifecycle state instead and manage it in start/pause/resume/stop + if (!isEnabled.get()) { + return + } + + if (isRecording.getAndSet(true)) { + options.logger.log( + DEBUG, + "Session replay is already being recorded, not starting a new one" + ) + return + } + + val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate) + if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) { + options.logger.log(INFO, "Session replay is not started, full session was not sampled and errorSampleRate is not specified") + return + } + + recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy = if (isFullSession) { + SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) + } else { + BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) + } + + captureStrategy?.start() + recorder?.start(recorderConfig) + } + + override fun resume() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + captureStrategy?.resume() + recorder?.resume() + } + + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + return + } + + sendReplay(event.isCrashed, event.eventId.toString(), hint) + } + + override fun sendReplay(isCrashed: Boolean?, eventId: String?, hint: Hint?) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) { + options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) + return + } + + captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) + captureStrategy = captureStrategy?.convert() + } + + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + + override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { + replayBreadcrumbConverter = converter + } + + override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter + + override fun pause() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + recorder?.pause() + captureStrategy?.pause() + } + + override fun stop() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + recorder?.stop() + captureStrategy?.stop() + isRecording.set(false) + captureStrategy = null + } + + override fun onScreenshotRecorded(bitmap: Bitmap) { + captureStrategy?.onScreenshotRecorded { frameTimeStamp -> + addFrame(bitmap, frameTimeStamp) + } + } + + override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { + captureStrategy?.onScreenshotRecorded { _ -> + addFrame(screenshot, frameTimestamp) + } + } + + override fun close() { + if (!isEnabled.get()) { + return + } + + try { + context.unregisterComponentCallbacks(this) + } catch (ignored: Throwable) { + } + stop() + captureStrategy?.close() + captureStrategy = null + recorder?.close() + recorder = null + } + + override fun onConfigurationChanged(newConfig: Configuration) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + recorder?.stop() + + // refresh config based on new device configuration + recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy?.onConfigurationChanged(recorderConfig) + + recorder?.start(recorderConfig) + } + + override fun onLowMemory() = Unit + + override fun onTouchEvent(event: MotionEvent) { + captureStrategy?.onTouchEvent(event) + } +} 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 new file mode 100644 index 00000000000..6e7b231b928 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -0,0 +1,359 @@ +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 +import android.graphics.Paint +import android.graphics.Point +import android.graphics.Rect +import android.graphics.RectF +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.os.Handler +import android.os.Looper +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 +import io.sentry.SentryLevel.INFO +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.util.getVisibleRects +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +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.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToInt + +@TargetApi(26) +internal class ScreenshotRecorder( + val config: ScreenshotRecorderConfig, + val options: SentryOptions, + private val screenshotRecorderCallback: ScreenshotRecorderCallback? +) : ViewTreeObserver.OnDrawListener { + + private val recorder by lazy { + Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + } + private var rootView: WeakReference? = null + private val handler = Handler(Looper.getMainLooper()) + private val pendingViewHierarchy = AtomicReference() + private val maskingPaint = Paint() + private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) + private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) + private val prescaledMatrix = Matrix().apply { + preScale(config.scaleFactorX, config.scaleFactorY) + } + private val contentChanged = AtomicBoolean(false) + private val isCapturing = AtomicBoolean(true) + private var lastScreenshot: Bitmap? = null + + fun capture() { + if (!isCapturing.get()) { + options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") + return + } + + if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) { + options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") + + lastScreenshot?.let { + screenshotRecorderCallback?.onScreenshotRecorded( + it.copy(ARGB_8888, false) + ) + } + return + } + + val root = rootView?.get() + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { + options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") + return + } + + val window = root.phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not capturing screenshot") + 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 + Handler(Looper.getMainLooper()).post { + try { + contentChanged.set(false) + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + if (copyResult != PixelCopy.SUCCESS) { + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + bitmap.recycle() + return@request + } + + if (contentChanged.get()) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + bitmap.recycle() + return@request + } + + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy) + + recorder.submit { + val canvas = Canvas(bitmap) + canvas.setMatrix(prescaledMatrix) + viewHierarchy.traverse { node -> + if (node.shouldRedact && (node.width > 0 && node.height > 0)) { + node.visibleRect ?: return@traverse false + + if (viewHierarchy.isObscured(node)) { + return@traverse true + } + + val (visibleRects, color) = when (node) { + is ImageViewHierarchyNode -> { + listOf(node.visibleRect) to + bitmap.dominantColorForRect(node.visibleRect) + } + + is TextViewHierarchyNode -> { + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop + ) to (node.dominantColor ?: Color.BLACK) + } + + else -> { + listOf(node.visibleRect) to Color.BLACK + } + } + + maskingPaint.setColor(color) + visibleRects.forEach { rect -> + canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) + } + } + return@traverse true + } + + val screenshot = bitmap.copy(ARGB_8888, false) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastScreenshot?.recycle() + lastScreenshot = screenshot + contentChanged.set(false) + + bitmap.recycle() + } + }, + handler + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture replay recording", e) + bitmap.recycle() + } + } + } + + override fun onDraw() { + val root = rootView?.get() + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { + options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") + return + } + + contentChanged.set(true) + } + + fun bind(root: View) { + // first unbind the current root + unbind(rootView?.get()) + rootView?.clear() + + // next bind the new root + rootView = WeakReference(root) + root.viewTreeObserver?.addOnDrawListener(this) + } + + fun unbind(root: View?) { + root?.viewTreeObserver?.removeOnDrawListener(this) + } + + fun pause() { + isCapturing.set(false) + unbind(rootView?.get()) + } + + fun resume() { + // can't use bind() as it will invalidate the weakref + rootView?.get()?.viewTreeObserver?.addOnDrawListener(this) + isCapturing.set(true) + } + + fun close() { + unbind(rootView?.get()) + rootView?.clear() + lastScreenshot?.recycle() + pendingViewHierarchy.set(null) + isCapturing.set(false) + recorder.gracefullyShutdown(options) + } + + private fun Bitmap.dominantColorForRect(rect: Rect): Int { + // TODO: maybe this ceremony can be just simplified to + // TODO: multiplying the visibleRect by the prescaledMatrix + val visibleRect = Rect(rect) + val visibleRectF = RectF(visibleRect) + + // since we take screenshot with lower scale, we also + // have to apply the same scale to the visibleRect to get the + // correct screenshot part to determine the dominant color + prescaledMatrix.mapRect(visibleRectF) + // round it back to integer values, because drawBitmap below accepts Rect only + visibleRectF.round(visibleRect) + // draw part of the screenshot (visibleRect) to a single pixel bitmap + singlePixelBitmapCanvas.drawBitmap( + this, + visibleRect, + Rect(0, 0, 1, 1), + null + ) + // 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( + val recordingWidth: Int, + val recordingHeight: Int, + val scaleFactorX: Float, + val scaleFactorY: Float, + val frameRate: Int, + val bitRate: Int +) { + companion object { + /** + * Since codec block size is 16, so we have to adjust the width and height to it, otherwise + * the codec might fail to configure on some devices, see https://cs.android.com/android/platform/superproject/+/master:frameworks/base/media/java/android/media/MediaCodecInfo.java;l=1999-2001 + */ + private fun Int.adjustToBlockSize(): Int { + val remainder = this % 16 + return if (remainder <= 8) { + this - remainder + } else { + this + (16 - remainder) + } + } + + fun from( + context: Context, + sessionReplay: SentryReplayOptions + ): ScreenshotRecorderConfig { + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { + wm.currentWindowMetrics.bounds + } else { + val screenBounds = Point() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealSize(screenBounds) + Rect(0, 0, screenBounds.x, screenBounds.y) + } + + // use the baseline density of 1x (mdpi) + val (height, width) = + ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + .roundToInt() + .adjustToBlockSize() to + ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + .roundToInt() + .adjustToBlockSize() + + return ScreenshotRecorderConfig( + recordingWidth = width, + recordingHeight = height, + scaleFactorX = width.toFloat() / screenBounds.width(), + scaleFactorY = height.toFloat() / screenBounds.height(), + frameRate = sessionReplay.frameRate, + bitRate = sessionReplay.quality.bitRate + ) + } + } +} + +/** + * A callback to be invoked when a new screenshot available. Normally, only one of the + * [onScreenshotRecorded] method overloads should be called by a single recorder, however, it will + * still work of both are used at the same time. + */ +public interface ScreenshotRecorderCallback { + /** + * Called whenever a new frame screenshot is available. + * + * @param bitmap a screenshot taken in the form of [android.graphics.Bitmap] + */ + fun onScreenshotRecorded(bitmap: Bitmap) + + /** + * Called whenever a new frame screenshot is available. + * + * @param screenshot file containing the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) +} 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 new file mode 100644 index 00000000000..09f498329d8 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -0,0 +1,165 @@ +package io.sentry.android.replay + +import android.annotation.TargetApi +import android.view.MotionEvent +import android.view.View +import android.view.Window +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import io.sentry.android.replay.util.FixedWindowCallback +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.scheduleAtFixedRateSafely +import java.lang.ref.WeakReference +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE + +@TargetApi(26) +internal class WindowRecorder( + private val options: SentryOptions, + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val touchRecorderCallback: TouchRecorderCallback? = null +) : Recorder { + + internal companion object { + private const val TAG = "WindowRecorder" + } + + private val rootViewsSpy by lazy(NONE) { + RootViewsSpy.install() + } + + private val isRecording = AtomicBoolean(false) + private val rootViews = ArrayList>() + private var recorder: ScreenshotRecorder? = null + private var capturingTask: ScheduledFuture<*>? = null + private val capturer by lazy { + Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + } + + private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + if (added) { + rootViews.add(WeakReference(root)) + recorder?.bind(root) + + root.startGestureTracking() + } else { + root.stopGestureTracking() + + recorder?.unbind(root) + rootViews.removeAll { it.get() == root } + + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null && root != newRoot) { + recorder?.bind(newRoot) + } + } + } + + override fun start(recorderConfig: ScreenshotRecorderConfig) { + if (isRecording.getAndSet(true)) { + return + } + + recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) + rootViewsSpy.listeners += onRootViewsChangedListener + capturingTask = capturer.scheduleAtFixedRateSafely( + options, + "$TAG.capture", + 0L, + 1000L / recorderConfig.frameRate, + MILLISECONDS + ) { + recorder?.capture() + } + } + + override fun resume() { + recorder?.resume() + } + override fun pause() { + recorder?.pause() + } + + override fun stop() { + rootViewsSpy.listeners -= onRootViewsChangedListener + rootViews.forEach { recorder?.unbind(it.get()) } + recorder?.close() + rootViews.clear() + recorder = null + capturingTask?.cancel(false) + capturingTask = null + isRecording.set(false) + } + + override fun close() { + stop() + capturer.gracefullyShutdown(options) + } + + private fun View.startGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not tracking gestures") + return + } + + if (touchRecorderCallback == null) { + options.logger.log(DEBUG, "TouchRecorderCallback is null, not tracking gestures") + return + } + + val delegate = window.callback + window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + } + + private fun View.stopGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window was null in stopGestureTracking") + return + } + + if (window.callback is SentryReplayGestureRecorder) { + val delegate = (window.callback as SentryReplayGestureRecorder).delegate + window.callback = delegate + } + } + + private class SentryReplayGestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback?, + delegate: Window.Callback? + ) : FixedWindowCallback(delegate) { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (event != null) { + val copy: MotionEvent = MotionEvent.obtainNoHistory(event) + try { + touchRecorderCallback?.onTouchEvent(copy) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error dispatching touch event", e) + } finally { + copy.recycle() + } + } + return super.dispatchTouchEvent(event) + } + } + + private class RecorderExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryWindowRecorder-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} + +public interface TouchRecorderCallback { + fun onTouchEvent(event: MotionEvent) +} 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 new file mode 100644 index 00000000000..8ef595f1934 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -0,0 +1,226 @@ +/** + * Adapted from https://github.com/square/curtains/tree/v1.2.5 + * + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.sentry.android.replay + +import android.annotation.SuppressLint +import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import android.view.Window +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.LazyThreadSafetyMode.NONE + +/** + * If this view is part of the view hierarchy from a [android.app.Activity], [android.app.Dialog] or + * [android.service.dreams.DreamService], then this returns the [android.view.Window] instance + * associated to it. Otherwise, this returns null. + * + * Note: this property is called [phoneWindow] because the only implementation of [Window] is + * the internal class android.view.PhoneWindow. + */ +internal val View.phoneWindow: Window? + get() { + return WindowSpy.pullWindow(rootView) + } + +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: + * 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) + } catch (ignored: Throwable) { + Log.d( + "WindowSpy", + "Unexpected exception loading $decorViewClassName on API $sdkInt", + ignored + ) + null + } + } + + /** + * See [decorViewClass] for the AOSP history of the DecorView class. + * Between the latest API 23 release and the first API 24 release, DecorView first became a + * static class: + * 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+. + */ + 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 } + } catch (ignored: NoSuchFieldException) { + Log.d( + "WindowSpy", + "Unexpected exception retrieving $decorViewClass#$fieldName on API $sdkInt", + ignored + ) + null + } + } + } + + fun pullWindow(maybeDecorView: View): Window? { + return decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> + windowField[maybeDecorView] as Window + } + } else { + null + } + } + } +} + +/** + * Listener added to [Curtains.onRootViewsChangedListeners]. + * If you only care about either attached or detached, consider implementing [OnRootViewAddedListener] + * or [OnRootViewRemovedListener] instead. + */ +internal fun interface OnRootViewsChangedListener { + /** + * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] + * are called. + */ + fun onRootViewsChanged( + view: View, + added: Boolean + ) +} + +/** + * A utility that holds the list of root views that WindowManager updates. + */ +internal class RootViewsSpy private constructor() { + + val listeners: CopyOnWriteArrayList = object : CopyOnWriteArrayList() { + override fun add(element: OnRootViewsChangedListener?): Boolean { + // notify listener about existing root views immediately + delegatingViewList.forEach { + element?.onRootViewsChanged(it, true) + } + return super.add(element) + } + } + + private val delegatingViewList: ArrayList = object : ArrayList() { + override fun addAll(elements: Collection): Boolean { + listeners.forEach { listener -> + elements.forEach { element -> + listener.onRootViewsChanged(element, true) + } + } + return super.addAll(elements) + } + + override fun add(element: View): Boolean { + listeners.forEach { it.onRootViewsChanged(element, true) } + return super.add(element) + } + + override fun removeAt(index: Int): View { + val removedView = super.removeAt(index) + listeners.forEach { it.onRootViewsChanged(removedView, false) } + return removedView + } + } + + companion object { + fun install(): RootViewsSpy { + return RootViewsSpy().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) } + } + } + } + } + } +} + +internal object WindowManagerSpy { + + private val windowManagerClass by lazy(NONE) { + val className = "android.view.WindowManagerGlobal" + try { + Class.forName(className) + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + null + } + } + + private val windowManagerInstance by lazy(NONE) { + windowManagerClass?.getMethod("getInstance")?.invoke(null) + } + + private val mViewsField by lazy(NONE) { + windowManagerClass?.let { windowManagerClass -> + windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true } + } + } + + // 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 -> + @Suppress("UNCHECKED_CAST") + val mViews = mViewsField[windowManagerInstance] as ArrayList + mViewsField[windowManagerInstance] = swap(mViews) + } + } + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + } + } +} 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 new file mode 100644 index 00000000000..96bb200d71d --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -0,0 +1,419 @@ +package io.sentry.android.replay.capture + +import android.view.MotionEvent +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.io.File +import java.util.Date +import java.util.LinkedList +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +internal abstract class BaseCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + protected var recorderConfig: ScreenshotRecorderConfig, + executor: ScheduledExecutorService? = null, + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : CaptureStrategy { + + internal companion object { + private const val TAG = "CaptureStrategy" + + // rrweb values + private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 + private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 + } + + protected var cache: ReplayCache? = null + protected val segmentTimestamp = AtomicReference() + protected val replayStartTimestamp = AtomicLong() + protected val screenAtStart = AtomicReference() + override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) + override val currentSegment = AtomicInteger(0) + override val replayCacheDir: File? get() = cache?.replayCacheDir + + protected val currentEvents = LinkedList() + private val currentEventsLock = Any() + private val currentPositions = LinkedHashMap>(10) + private var touchMoveBaseline = 0L + private var lastCapturedMoveEvent = 0L + + protected val replayExecutor: ScheduledExecutorService by lazy { + executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } + + override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { + currentSegment.set(segmentId) + currentReplayId.set(replayId) + + if (cleanupOldReplays) { + replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles { dir, name -> + // TODO: also exclude persisted replay_id from scope when implementing ANRs + if (name.startsWith("replay_") && !name.contains( + currentReplayId.get().toString() + ) + ) { + FileUtils.deleteRecursively(File(dir, name)) + } + false + } + } + } + } + + cache = + replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId, recorderConfig) + + // TODO: replace it with dateProvider.currentTimeMillis to also test it + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + replayStartTimestamp.set(dateProvider.currentTimeMillis) + // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) + } + + override fun resume() { + // TODO: replace it with dateProvider.currentTimeMillis to also test it + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + } + + override fun pause() = Unit + + override fun stop() { + cache?.close() + currentSegment.set(0) + replayStartTimestamp.set(0) + segmentTimestamp.set(null) + currentReplayId.set(SentryId.EMPTY_ID) + } + + protected fun createSegment( + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType = SESSION + ): ReplaySegment { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return ReplaySegment.Failed + + val (video, frameCount, videoDuration) = generatedVideo + return buildReplay( + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + videoDuration, + replayType + ) + } + + private fun buildReplay( + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + duration: Long, + replayType: ReplayType + ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) + val replay = SentryReplayEvent().apply { + eventId = currentReplayId + replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = endTimestamp + replayStartTimestamp = segmentTimestamp + this.replayType = replayType + videoFile = video + } + + val recordingPayload = mutableListOf() + recordingPayload += RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + } + recordingPayload += RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.durationMs = duration + this.frameCount = frameCount + size = video.length() + frameRate = recorderConfig.frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + left = 0 + top = 0 + } + + val urls = LinkedList() + hub?.configureScope { scope -> + scope.breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.time >= segmentTimestamp.time && + breadcrumb.timestamp.time < endTimestamp.time + ) { + val rrwebEvent = options + .replayController + .breadcrumbConverter + .convert(breadcrumb) + + if (rrwebEvent != null) { + recordingPayload += rrwebEvent + + // fill in the urls array from navigation breadcrumbs + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + urls.add(rrwebEvent.data!!["to"] as String) + } + } + } + } + } + + if (screenAtStart.get() != null && urls.first != screenAtStart.get()) { + urls.addFirst(screenAtStart.get()) + } + + rotateCurrentEvents(endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = recordingPayload.sortedBy { it.timestamp } + } + + replay.urls = urls + return ReplaySegment.Created( + videoDuration = duration, + replay = replay, + recording = recording + ) + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + this.recorderConfig = recorderConfig + } + + override fun onTouchEvent(event: MotionEvent) { + val rrwebEvents = event.toRRWebIncrementalSnapshotEvent() + if (rrwebEvents != null) { + synchronized(currentEventsLock) { + currentEvents += rrwebEvents + } + } + } + + override fun close() { + replayExecutor.gracefullyShutdown(options) + } + + protected fun rotateCurrentEvents( + until: Long, + callback: ((RRWebEvent) -> Unit)? = null + ) { + synchronized(currentEventsLock) { + var event = currentEvents.peek() + while (event != null && event.timestamp < until) { + callback?.invoke(event) + currentEvents.remove() + event = currentEvents.peek() + } + } + } + + 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 + } + } + + protected sealed class ReplaySegment { + object Failed : ReplaySegment() + data class Created( + val videoDuration: Long, + val replay: SentryReplayEvent, + val recording: ReplayRecording + ) : ReplaySegment() { + fun capture(hub: IHub?, hint: Hint = Hint()) { + hub?.captureReplay(replay, hint.apply { replayRecording = recording }) + } + + fun setSegmentId(segmentId: Int) { + replay.segmentId = segmentId + recording.payload?.forEach { + when (it) { + is RRWebVideoEvent -> it.segmentId = segmentId + } + } + } + } + } + + private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List? { + val event = this + return when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.currentTimeMillis + if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { + return null + } + lastCapturedMoveEvent = now + + currentPositions.keys.forEach { pId -> + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return@forEach + } + + // idk why but rrweb does it like dis + if (touchMoveBaseline == 0L) { + touchMoveBaseline = now + } + + currentPositions[pId]!! += Position().apply { + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + timeOffset = now - touchMoveBaseline + } + } + + val totalOffset = now - touchMoveBaseline + return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + val moveEvents = mutableListOf() + for ((pointerId, positions) in currentPositions) { + if (positions.isNotEmpty()) { + moveEvents += RRWebInteractionMoveEvent().apply { + this.timestamp = now + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } + this.pointerId = pointerId + } + currentPositions[pointerId]!!.clear() + } + } + touchMoveBaseline = 0L + moveEvents + } else { + null + } + } + + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // new finger down - add a new pointer for tracking movement + currentPositions[pId] = ArrayList() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchStart + } + ) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // finger lift up - remove the pointer from tracking + currentPositions.remove(pId) + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchEnd + } + ) + } + MotionEvent.ACTION_CANCEL -> { + // gesture cancelled - remove all pointers from tracking + currentPositions.clear() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0 + interactionType = InteractionType.TouchCancel + } + ) + } + + else -> null + } + } +} 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 new file mode 100644 index 00000000000..8f3682f9656 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -0,0 +1,223 @@ +package io.sentry.android.replay.capture + +import android.view.MotionEvent +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.io.File +import java.security.SecureRandom + +internal class BufferCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + recorderConfig: ScreenshotRecorderConfig, + private val random: SecureRandom, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) { + + private val bufferedSegments = mutableListOf() + private val bufferedScreensLock = Any() + private val bufferedScreens = mutableListOf>() + + internal companion object { + private const val TAG = "BufferCaptureStrategy" + } + + override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { + super.start(segmentId, replayId, cleanupOldReplays) + + hub?.configureScope { + val screen = it.screen + if (screen != null) { + synchronized(bufferedScreensLock) { + bufferedScreens.add(screen to dateProvider.currentTimeMillis) + } + } + } + } + + override fun onScreenChanged(screen: String?) { + synchronized(bufferedScreensLock) { + val lastKnownScreen = bufferedScreens.lastOrNull()?.first + if (screen != null && lastKnownScreen != screen) { + bufferedScreens.add(screen to dateProvider.currentTimeMillis) + } + } + } + + override fun stop() { + val replayCacheDir = cache?.replayCacheDir + replayExecutor.submitSafely(options, "$TAG.stop") { + FileUtils.deleteRecursively(replayCacheDir) + } + super.stop() + } + + override fun sendReplayForEvent( + isCrashed: Boolean, + eventId: String?, + hint: Hint?, + onSegmentSent: () -> Unit + ) { + val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) + + if (!sampled) { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", eventId) + return + } + + val errorReplayDuration = options.experimental.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 + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + + findAndSetStartScreen(currentSegmentTimestamp.time) + + replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { + var bufferedSegment = bufferedSegments.removeFirstOrNull() + while (bufferedSegment != null) { + // capture without hint, so the buffered segments don't trigger flush notification + bufferedSegment.capture(hub) + bufferedSegment = bufferedSegments.removeFirstOrNull() + Thread.sleep(100L) + } + val segment = + createSegment( + now - currentSegmentTimestamp.time, + currentSegmentTimestamp, + replayId, + segmentId, + height, + width, + BUFFER + ) + if (segment is ReplaySegment.Created) { + segment.capture(hub, hint ?: Hint()) + + // we only want to increment segment_id in the case of success, but currentSegment + // might be irrelevant since we changed strategies, so in the callback we increment + // it on the new strategy already + onSegmentSent() + } + } + } + + override fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) { + // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be + // reflecting the exact time of when it was captured + val frameTimestamp = dateProvider.currentTimeMillis + replayExecutor.submitSafely(options, "$TAG.add_frame") { + cache?.store(frameTimestamp) + + val now = dateProvider.currentTimeMillis + val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration + cache?.rotate(bufferLimit) + + var removed = false + bufferedSegments.removeAll { + // it can be that the buffered segment is half-way older than the buffer limit, but + // we only drop it if its end timestamp is older + if (it.replay.timestamp.time < bufferLimit) { + currentSegment.decrementAndGet() + deleteFile(it.replay.videoFile) + removed = true + return@removeAll true + } + return@removeAll false + } + if (removed) { + // shift segmentIds after rotating buffered segments + bufferedSegments.forEachIndexed { index, segment -> + segment.setSegmentId(index) + } + } + } + } + + private fun deleteFile(file: File?) { + if (file == null) { + return + } + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + } + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + val errorReplayDuration = options.experimental.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 + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + val height = this.recorderConfig.recordingHeight + val width = this.recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { + val segment = + createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment.getAndIncrement() + } + } + super.onConfigurationChanged(recorderConfig) + } + + override fun convert(): CaptureStrategy { + // we hand over replayExecutor to the new strategy to preserve order of execution + val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayExecutor) + captureStrategy.start(segmentId = currentSegment.get(), replayId = currentReplayId.get(), cleanupOldReplays = false) + return captureStrategy + } + + override fun onTouchEvent(event: MotionEvent) { + super.onTouchEvent(event) + val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration + rotateCurrentEvents(bufferLimit) + } + + private fun findAndSetStartScreen(segmentStart: Long) { + synchronized(bufferedScreensLock) { + val startScreen = bufferedScreens.lastOrNull { (_, timestamp) -> + timestamp <= segmentStart + }?.first + // if no screen is found before the segment start, this likely means the buffer is from the + // app start, and the start screen will be taken from the navigation crumbs + if (startScreen != null) { + screenAtStart.set(startScreen) + } + // can clear as we switch to session mode and don't care anymore about buffering + bufferedSegments.clear() + } + } +} 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 new file mode 100644 index 00000000000..61c4107183f --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -0,0 +1,38 @@ +package io.sentry.android.replay.capture + +import android.view.MotionEvent +import io.sentry.Hint +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +internal interface CaptureStrategy { + val currentSegment: AtomicInteger + val currentReplayId: AtomicReference + val replayCacheDir: File? + + fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + + fun stop() + + fun pause() + + fun resume() + + fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) + + fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) + + fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) + + fun onTouchEvent(event: MotionEvent) + + fun onScreenChanged(screen: String?) = Unit + + fun convert(): CaptureStrategy + + fun close() +} 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 new file mode 100644 index 00000000000..05c7dba11d8 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -0,0 +1,145 @@ +package io.sentry.android.replay.capture + +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.util.concurrent.ScheduledExecutorService + +internal class SessionCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + recorderConfig: ScreenshotRecorderConfig, + executor: ScheduledExecutorService? = null, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor, replayCacheProvider) { + + internal companion object { + private const val TAG = "SessionCaptureStrategy" + } + + override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { + super.start(segmentId, replayId, cleanupOldReplays) + // only set replayId on the scope if it's a full session, otherwise all events will be + // tagged with the replay that might never be sent when we're recording in buffer mode + hub?.configureScope { + it.replayId = currentReplayId.get() + screenAtStart.set(it.screen) + } + } + + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + + currentSegment.getAndIncrement() + } + } + super.pause() + } + + override fun stop() { + val replayCacheDir = cache?.replayCacheDir + createCurrentSegment("stop") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + } + FileUtils.deleteRecursively(replayCacheDir) + } + hub?.configureScope { it.replayId = SentryId.EMPTY_ID } + super.stop() + } + + override fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) { + if (!isCrashed) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", eventId) + } else { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, capturing last segment for crashed event %s", eventId) + createCurrentSegment("send_replay_for_event") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub, hint ?: Hint()) + } + } + } + } + + override fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) { + // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be + // reflecting the exact time of when it was captured + val frameTimestamp = dateProvider.currentTimeMillis + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.add_frame") { + cache?.store(frameTimestamp) + + val now = dateProvider.currentTimeMillis + if ((now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)) { + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + + val segment = + createSegment( + options.experimental.sessionReplay.sessionSegmentDuration, + currentSegmentTimestamp, + replayId, + segmentId, + height, + width + ) + if (segment is ReplaySegment.Created) { + segment.capture(hub) + currentSegment.getAndIncrement() + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + } + } else if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + stop() + options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") + } + } + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + val currentSegmentTimestamp = segmentTimestamp.get() + createCurrentSegment("onConfigurationChanged") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + + currentSegment.getAndIncrement() + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + } + } + + // refresh recorder config after submitting the last segment with current config + super.onConfigurationChanged(recorderConfig) + } + + override fun convert(): CaptureStrategy = this + + private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - (currentSegmentTimestamp?.time ?: 0) + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + onSegmentCreated(segment) + } + } +} 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 new file mode 100644 index 00000000000..093416f9bb5 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -0,0 +1,67 @@ +package io.sentry.android.replay.util + +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS + +internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { + synchronized(this) { + if (!isShutdown) { + shutdown() + } + try { + if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { + shutdownNow() + } + } catch (e: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } +} + +internal fun ExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + task: Runnable +): Future<*>? { + return try { + submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + } + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} + +internal fun ScheduledExecutorService.scheduleAtFixedRateSafely( + options: SentryOptions, + taskName: String, + initialDelay: Long, + period: Long, + unit: TimeUnit, + task: Runnable +): ScheduledFuture<*>? { + return try { + scheduleAtFixedRate({ + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + }, initialDelay, period, unit) + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java new file mode 100644 index 00000000000..7245eefabed --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java @@ -0,0 +1,254 @@ +/** + * Adapted from https://github.com/square/curtains/tree/v1.2.5 + * + *

Copyright 2021 Square Inc. + * + *

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.android.replay.util; + +import android.annotation.SuppressLint; +import android.view.ActionMode; +import android.view.KeyEvent; +import android.view.KeyboardShortcutGroup; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SearchEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Implementation of Window.Callback that updates the signature of {@link #onMenuOpened(int, Menu)} + * to change the menu param from non null to nullable to avoid runtime null check crashes. Issue: + * https://issuetracker.google.com/issues/188568911 + */ +public class FixedWindowCallback implements Window.Callback { + + public final @Nullable Window.Callback delegate; + + public FixedWindowCallback(@Nullable Window.Callback delegate) { + this.delegate = delegate; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchKeyShortcutEvent(event); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchTouchEvent(event); + } + + @Override + public boolean dispatchTrackballEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchTrackballEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchGenericMotionEvent(event); + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchPopulateAccessibilityEvent(event); + } + + @Nullable + @Override + public View onCreatePanelView(int featureId) { + if (delegate == null) { + return null; + } + return delegate.onCreatePanelView(featureId); + } + + @Override + public boolean onCreatePanelMenu(int featureId, @NotNull Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onCreatePanelMenu(featureId, menu); + } + + @Override + public boolean onPreparePanel(int featureId, @Nullable View view, @NotNull Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onPreparePanel(featureId, view, menu); + } + + @Override + public boolean onMenuOpened(int featureId, @Nullable Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onMenuOpened(featureId, menu); + } + + @Override + public boolean onMenuItemSelected(int featureId, @NotNull MenuItem item) { + if (delegate == null) { + return false; + } + return delegate.onMenuItemSelected(featureId, item); + } + + @Override + public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { + if (delegate == null) { + return; + } + delegate.onWindowAttributesChanged(attrs); + } + + @Override + public void onContentChanged() { + if (delegate == null) { + return; + } + delegate.onContentChanged(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (delegate == null) { + return; + } + delegate.onWindowFocusChanged(hasFocus); + } + + @Override + public void onAttachedToWindow() { + if (delegate == null) { + return; + } + delegate.onAttachedToWindow(); + } + + @Override + public void onDetachedFromWindow() { + if (delegate == null) { + return; + } + delegate.onDetachedFromWindow(); + } + + @Override + public void onPanelClosed(int featureId, @NotNull Menu menu) { + if (delegate == null) { + return; + } + delegate.onPanelClosed(featureId, menu); + } + + @Override + public boolean onSearchRequested() { + if (delegate == null) { + return false; + } + return delegate.onSearchRequested(); + } + + @SuppressLint("NewApi") + @Override + public boolean onSearchRequested(SearchEvent searchEvent) { + if (delegate == null) { + return false; + } + return delegate.onSearchRequested(searchEvent); + } + + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + if (delegate == null) { + return null; + } + return delegate.onWindowStartingActionMode(callback); + } + + @SuppressLint("NewApi") + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int type) { + if (delegate == null) { + return null; + } + return delegate.onWindowStartingActionMode(callback, type); + } + + @Override + public void onActionModeStarted(ActionMode mode) { + if (delegate == null) { + return; + } + delegate.onActionModeStarted(mode); + } + + @Override + public void onActionModeFinished(ActionMode mode) { + if (delegate == null) { + return; + } + delegate.onActionModeFinished(mode); + } + + @SuppressLint("NewApi") + @Override + public void onProvideKeyboardShortcuts( + List data, @Nullable Menu menu, int deviceId) { + if (delegate == null) { + return; + } + delegate.onProvideKeyboardShortcuts(data, menu, deviceId); + } + + @SuppressLint("NewApi") + @Override + public void onPointerCaptureChanged(boolean hasCapture) { + if (delegate == null) { + return; + } + delegate.onPointerCaptureChanged(hasCapture); + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt new file mode 100644 index 00000000000..8acb6b00a6e --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt @@ -0,0 +1,10 @@ +package io.sentry.android.replay.util + +import java.security.SecureRandom + +internal fun SecureRandom.sample(rate: Double?): Boolean { + if (rate != null) { + return !(rate < this.nextDouble()) // bad luck + } + return false +} 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 new file mode 100644 index 00000000000..8415b42e57c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -0,0 +1,82 @@ +package io.sentry.android.replay.util + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.graphics.Point +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.VectorDrawable +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.text.Layout +import android.view.View + +/** + * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 + */ +internal fun View.isVisibleToUser(): Pair { + if (isAttachedToWindow) { + // Attached to invisible window means this view is not visible. + if (windowVisibility != View.VISIBLE) { + return false to null + } + // An invisible predecessor or one with alpha zero means + // that this view is not visible to the user. + var current: Any = this + while (current is View) { + val view = current + val transitionAlpha = if (VERSION.SDK_INT >= VERSION_CODES.Q) view.transitionAlpha else 1f + // We have attach info so this view is attached and there is no + // need to check whether we reach to ViewRootImpl on the way up. + if (view.alpha <= 0 || transitionAlpha <= 0 || view.visibility != View.VISIBLE) { + return false to null + } + current = view.parent + } + // Check if the view is entirely covered by its predecessors. + val rect = Rect() + val offset = Point() + val isVisible = getGlobalVisibleRect(rect, offset) + return isVisible to rect + } + return false to null +} + +@SuppressLint("ObsoleteSdkInt") +@TargetApi(21) +internal fun Drawable?.isRedactable(): Boolean { + // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network + // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 10 && bitmap.width > 10 + else -> true + } +} + +internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { + if (this == null) { + return listOf(globalRect) + } + + val rects = mutableListOf() + for (i in 0 until lineCount) { + val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt() + val ellipsisCount = getEllipsisCount(i) + val lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() + val lineTop = getLineTop(i) + val lineBottom = getLineBottom(i) + val rect = Rect() + rect.left = globalRect.left + paddingLeft + lineStart + rect.right = rect.left + (lineEnd - lineStart) + rect.top = globalRect.top + paddingTop + lineTop + rect.bottom = rect.top + (lineBottom - lineTop) + + rects += rect + } + return rects +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt new file mode 100644 index 00000000000..17f454967bb --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt @@ -0,0 +1,47 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ + +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import java.nio.ByteBuffer + +interface SimpleFrameMuxer { + fun isStarted(): Boolean + + fun start(videoFormat: MediaFormat) + + fun muxVideoFrame(encodedData: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) + + fun release() + + fun getVideoTime(): Long +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt new file mode 100644 index 00000000000..cf30f9e49fc --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -0,0 +1,83 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleMp4FrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS + +class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { + private val frameDurationUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() + + private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + private var started = false + private var videoTrackIndex = 0 + private var videoFrames = 0 + private var finalVideoTime: Long = 0 + + override fun isStarted(): Boolean = started + + override fun start(videoFormat: MediaFormat) { + videoTrackIndex = muxer.addTrack(videoFormat) + muxer.start() + started = true + } + + override fun muxVideoFrame(encodedData: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) { + // This code will break if the encoder supports B frames. + // Ideally we would use set the value in the encoder, + // don't know how to do that without using OpenGL + finalVideoTime = frameDurationUsec * videoFrames++ + bufferInfo.presentationTimeUs = finalVideoTime + +// encodedData.position(bufferInfo.offset) +// encodedData.limit(bufferInfo.offset + bufferInfo.size) + + muxer.writeSampleData(videoTrackIndex, encodedData, bufferInfo) + } + + override fun release() { + muxer.stop() + muxer.release() + } + + override fun getVideoTime(): Long { + if (videoFrames == 0) { + return 0 + } + // have to add one sec as we calculate it 0-based above + return MILLISECONDS.convert(finalVideoTime + frameDurationUsec, MICROSECONDS) + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt new file mode 100644 index 00000000000..54a3bc1f89b --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -0,0 +1,245 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ +package io.sentry.android.replay.video + +import android.annotation.TargetApi +import android.graphics.Bitmap +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.view.Surface +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryOptions +import java.io.File +import java.nio.ByteBuffer +import kotlin.LazyThreadSafetyMode.NONE + +private const val TIMEOUT_USEC = 100_000L + +@TargetApi(26) +internal class SimpleVideoEncoder( + val options: SentryOptions, + val muxerConfig: MuxerConfig, + val onClose: (() -> Unit)? = null +) { + + internal val mediaCodec: MediaCodec = run { + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + + codec + } + + private val mediaFormat: MediaFormat by lazy(NONE) { + var bitRate = muxerConfig.bitRate + + try { + val videoCapabilities = mediaCodec.codecInfo + .getCapabilitiesForType(muxerConfig.mimeType) + .videoCapabilities + + if (!videoCapabilities.bitrateRange.contains(bitRate)) { + options.logger.log( + DEBUG, + "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one" + ) + bitRate = videoCapabilities.bitrateRange.clamp(bitRate) + } + } catch (e: Throwable) { + options.logger.log(DEBUG, "Could not retrieve MediaCodec info", e) + } + + // TODO: if this ever becomes a problem, move this to ScreenshotRecorderConfig.from() + // TODO: because the screenshot config has to match the video config + +// var frameRate = muxerConfig.recorderConfig.frameRate +// if (!videoCapabilities.supportedFrameRates.contains(frameRate)) { +// options.logger.log(DEBUG, "Encoder doesn't support the provided frameRate: $frameRate, the value will be clamped to the closest one") +// frameRate = videoCapabilities.supportedFrameRates.clamp(frameRate) +// } + +// var height = muxerConfig.recorderConfig.recordingHeight +// var width = muxerConfig.recorderConfig.recordingWidth +// val aspectRatio = height.toFloat() / width.toFloat() +// while (!videoCapabilities.supportedHeights.contains(height) || !videoCapabilities.supportedWidths.contains(width)) { +// options.logger.log(DEBUG, "Encoder doesn't support the provided height x width: ${height}x${width}, the values will be clamped to the closest ones") +// if (!videoCapabilities.supportedHeights.contains(height)) { +// height = videoCapabilities.supportedHeights.clamp(height) +// width = (height / aspectRatio).roundToInt() +// } else if (!videoCapabilities.supportedWidths.contains(width)) { +// width = videoCapabilities.supportedWidths.clamp(width) +// height = (width * aspectRatio).roundToInt() +// } +// } + + val format = MediaFormat.createVideoFormat( + muxerConfig.mimeType, + muxerConfig.recordingWidth, + muxerConfig.recordingHeight + ) + + // this allows reducing bitrate on newer devices, where they enforce higher quality in VBR + // mode, see https://developer.android.com/reference/android/media/MediaCodec#qualityFloor + // TODO: maybe enable this back later, for now variable bitrate seems to provide much better + // TODO: quality with almost no overhead in terms of video size, let's monitor that +// format.setInteger( +// MediaFormat.KEY_BITRATE_MODE, +// MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR +// ) + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + format.setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + format + } + + private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() + private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.frameRate.toFloat()) + val duration get() = frameMuxer.getVideoTime() + + private var surface: Surface? = null + + fun start() { + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + surface = mediaCodec.createInputSurface() + mediaCodec.start() + drainCodec(false) + } + + fun encode(image: Bitmap) { + // NOTE do not use `lockCanvas` like what is done in bitmap2video + // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() + // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." + val canvas = surface?.lockHardwareCanvas() + canvas?.drawBitmap(image, 0f, 0f, null) + surface?.unlockCanvasAndPost(canvas) + drainCodec(false) + } + + /** + * Extracts all pending data from the encoder. + * + * + * If endOfStream is not set, this returns when there is no more data to drain. If it + * is set, we send EOS to the encoder, and then iterate until we see EOS on the output. + * Calling this with endOfStream set should be done once, right before stopping the muxer. + * + * Borrows heavily from https://bigflake.com/mediacodec/EncodeAndMuxTest.java.txt + */ + private fun drainCodec(endOfStream: Boolean) { + options.logger.log(DEBUG, "[Encoder]: drainCodec($endOfStream)") + if (endOfStream) { + options.logger.log(DEBUG, "[Encoder]: sending EOS to encoder") + mediaCodec.signalEndOfInputStream() + } + var encoderOutputBuffers: Array? = mediaCodec.outputBuffers + while (true) { + val encoderStatus: Int = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC) + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { + // no output available yet + if (!endOfStream) { + break // out of while + } else { + options.logger.log(DEBUG, "[Encoder]: no output available, spinning to await EOS") + } + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + // not expected for an encoder + encoderOutputBuffers = mediaCodec.outputBuffers + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + options.logger.log(DEBUG, "[Encoder]: encoder output format changed: $newFormat") + + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) + } else if (encoderStatus < 0) { + options.logger.log(DEBUG, "[Encoder]: unexpected result from encoder.dequeueOutputBuffer: $encoderStatus") + // let's ignore it + } else { + val encodedData = encoderOutputBuffers?.get(encoderStatus) + ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null") + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + // The codec config data was pulled out and fed to the muxer when we got + // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. + options.logger.log(DEBUG, "[Encoder]: ignoring BUFFER_FLAG_CODEC_CONFIG") + bufferInfo.size = 0 + } + if (bufferInfo.size != 0) { + if (!frameMuxer.isStarted()) { + throw RuntimeException("muxer hasn't started") + } + frameMuxer.muxVideoFrame(encodedData, bufferInfo) + options.logger.log(DEBUG, "[Encoder]: sent ${bufferInfo.size} bytes to muxer") + } + mediaCodec.releaseOutputBuffer(encoderStatus, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + if (!endOfStream) { + options.logger.log(DEBUG, "[Encoder]: reached end of stream unexpectedly") + } else { + options.logger.log(DEBUG, "[Encoder]: end of stream reached") + } + break // out of while + } + } + } + } + + fun release() { + try { + onClose?.invoke() + drainCodec(true) + mediaCodec.stop() + mediaCodec.release() + surface?.release() + + frameMuxer.release() + } catch (e: Throwable) { + options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } + } +} + +@TargetApi(24) +internal data class MuxerConfig( + val file: File, + var recordingWidth: Int, + var recordingHeight: Int, + val frameRate: Int, + val bitRate: Int, + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC +) 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 new file mode 100644 index 00000000000..60014e8f64a --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -0,0 +1,292 @@ +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.graphics.Rect +import android.text.Layout +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.isVisibleToUser + +@TargetApi(26) +sealed class ViewHierarchyNode( + val x: Float, + val y: Float, + val width: Int, + val height: Int, + /* Elevation (in px) */ + val elevation: Float, + /* Distance to the parent (index) */ + val distance: Int, + val parent: ViewHierarchyNode? = null, + val shouldRedact: Boolean = false, + /* Whether the node is important for content capture (=non-empty container) */ + var isImportantForContentCapture: Boolean = false, + val isVisible: Boolean = false, + val visibleRect: Rect? = null +) { + var children: List? = null + + class GenericViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldRedact: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + + class TextViewHierarchyNode( + val layout: Layout? = null, + val dominantColor: Int? = null, + val paddingLeft: Int = 0, + val paddingTop: Int = 0, + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldRedact: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + + class ImageViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldRedact: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + + /** + * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first + * manner. + * + * @param callback a callback that will be called for each node in the hierarchy. If the callback + * returns false, the traversal will stop for the current node and its children. + */ + fun traverse(callback: (ViewHierarchyNode) -> Boolean) { + val traverseChildren = callback(this) + if (traverseChildren) { + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } + } + } + } + + /** + * Checks if the given node is obscured by other nodes in the view hierarchy. A node is considered + * obscured if it's not visible, or if it's not fully visible because it's behind another node + * with a higher elevation or distance from the common parent. + * + * This method should be called on the root node of the view hierarchy. + * + * @param node the node to check if it's obscured by other nodes in the view hierarchy + */ + fun isObscured(node: ViewHierarchyNode): Boolean { + require(this.parent == null) { + "This method should be called on the root node of the view hierarchy." + } + node.visibleRect ?: return false + + var isObscured = false + + traverse { otherNode -> + // if the other node doesn't have a visible rect or the current node is already obscured + // we can skip the traversal + if (otherNode.visibleRect == null || isObscured) { + return@traverse false + } + + // if the other node is not visible, or not important for content capture (empty container) + // or doesn't contain the node's visible rect, we can skip it + if (!otherNode.isVisible || + !otherNode.isImportantForContentCapture || + !otherNode.visibleRect.contains(node.visibleRect) + ) { + return@traverse false + } + + // if otherNode's elevation is higher, we know it's obscuring the node + if (otherNode.elevation > node.elevation) { + isObscured = true + return@traverse false + } else if (otherNode.elevation == node.elevation) { + // if otherNode's elevation is the same, we need to find the lowest common ancestor + // and compare the distances from the common parent + val (lca, nodeAncestor, otherNodeAncestor) = findLCA(node, otherNode) + // if otherNode is the LCA, this means it's a parent of the node, so it's not obscuring it + // otherwise compare the distances from the common parent + if (lca != otherNode && otherNodeAncestor != null && nodeAncestor != null) { + isObscured = otherNodeAncestor.distance > nodeAncestor.distance + return@traverse !isObscured + } + } + return@traverse true + } + return isObscured + } + + /** + * Find the lowest common ancestor of two nodes in the view hierarchy. Given the following view + * hierarchy: + * + * CoordinatorLayout + * -FrameLayout + * --TextView + * -BottomNavigationView + * --NavigationItemView + * --NavigationItemView + * + * We want to know if the TextView is obscured by anything. For that we're searching for the + * lowest common ancestor (common parent) of the TextView and the other node. In this case it'd + * be CoordinatorLayout. + * + * After that we also need to know which subtrees contain both the TextView + * and the obscuring node. In this case it'd be FrameLayout and BottomNavigationView. Once we + * have the subtrees, we can compare their distances (indexes) from the common parent. In this + * case BottomNavigationView will have a higher index than FrameLayout, so we can conclude that + * it obscures the TextView. + * + * This method should be called on the root node of the view hierarchy. + */ + private fun findLCA(node: ViewHierarchyNode, otherNode: ViewHierarchyNode): LCAResult { + var nodeSubtree: ViewHierarchyNode? = null + var otherNodeSubtree: ViewHierarchyNode? = null + var lca: ViewHierarchyNode? = null + + // Check if the current node is node or otherNode + if (this == node) { + nodeSubtree = this + } + if (this == otherNode) { + otherNodeSubtree = this + } + + // Search for nodes node and otherNode in the children subtrees + if (children != null) { + for (child in children!!) { + val result = child.findLCA(node, otherNode) + + if (result.lca != null) { + return result // If LCA is found, propagate it up + } + if (result.nodeSubtree != null) { + nodeSubtree = child + } + if (result.otherNodeSubtree != null) { + otherNodeSubtree = child + } + } + } + + // If both node and otherNode are found, and LCA is not already determined, the current node + // is the LCA + if (nodeSubtree != null && otherNodeSubtree != null) { + lca = this + } + + return LCAResult(lca, nodeSubtree, otherNodeSubtree) + } + + private data class LCAResult( + val lca: ViewHierarchyNode?, + var nodeSubtree: ViewHierarchyNode?, + var otherNodeSubtree: ViewHierarchyNode? + ) + + companion object { + + private fun Int.toOpaque() = this or 0xFF000000.toInt() + + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + private fun ViewHierarchyNode?.setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this?.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } + + fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { + val (isVisible, visibleRect) = view.isVisibleToUser() + when { + view is TextView && options.experimental.sessionReplay.redactAllText -> { + parent.setImportantForCaptureToAncestors(true) + return TextViewHierarchyNode( + layout = view.layout, + dominantColor = view.currentTextColor.toOpaque(), + paddingLeft = view.totalPaddingLeft, + paddingTop = view.totalPaddingTop, + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + shouldRedact = isVisible, + distance = distance, + parent = parent, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + + view is ImageView && options.experimental.sessionReplay.redactAllImages -> { + parent.setImportantForCaptureToAncestors(true) + return ImageViewHierarchyNode( + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldRedact = isVisible && view.drawable?.isRedactable() == true, + visibleRect = visibleRect + ) + } + } + + return GenericViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldRedact = false, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } +} diff --git a/sentry-android-replay/src/main/res/public.xml b/sentry-android-replay/src/main/res/public.xml new file mode 100644 index 00000000000..379be515be2 --- /dev/null +++ b/sentry-android-replay/src/main/res/public.xml @@ -0,0 +1,4 @@ + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt new file mode 100644 index 00000000000..0dfb3d39c8b --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -0,0 +1,288 @@ +package io.sentry.android.replay + +import io.sentry.Breadcrumb +import io.sentry.SentryLevel +import io.sentry.SpanDataConvention +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebSpanEvent +import junit.framework.TestCase.assertEquals +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertNull + +class DefaultReplayBreadcrumbConverterTest { + class Fixture { + fun getSut(): DefaultReplayBreadcrumbConverter { + return DefaultReplayBreadcrumbConverter() + } + } + + private val fixture = Fixture() + + @Test + fun `returns null when no category`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + message = "message" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `convert RRWebSpanEvent`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["url"] = "http://example.com" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234L + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals("resource.http", rrwebEvent.op) + assertEquals("http://example.com", rrwebEvent.description) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(1.234, rrwebEvent.startTimestamp) + assertEquals(2.234, rrwebEvent.endTimestamp) + assertEquals(404, rrwebEvent.data!!["statusCode"]) + assertEquals("GET", rrwebEvent.data!!["method"]) + assertEquals(300, rrwebEvent.data!!["responseBodySize"]) + assertEquals(400, rrwebEvent.data!!["requestBodySize"]) + } + + @Test + fun `returns null if not eligible for RRWebSpanEvent`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234L + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts app lifecycle breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "app.lifecycle" + type = "navigation" + data["state"] = "background" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("app.background", rrwebEvent.category) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) + assertEquals("default", rrwebEvent.breadcrumbType) + } + + @Test + fun `converts device orientation breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.orientation" + type = "navigation" + data["position"] = "landscape" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.orientation", rrwebEvent.category) + assertEquals("landscape", rrwebEvent.data!!["position"]) + } + + @Test + fun `returns null if no position for orientation breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.orientation" + type = "navigation" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts navigation breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "navigation" + type = "navigation" + data["state"] = "resumed" + data["screen"] = "io.sentry.MainActivity" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("navigation", rrwebEvent.category) + assertEquals("MainActivity", rrwebEvent.data!!["to"]) + } + + @Test + fun `converts navigation breadcrumbs with destination`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "navigation" + type = "navigation" + data["to"] = "/github" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("navigation", rrwebEvent.category) + assertEquals("/github", rrwebEvent.data!!["to"]) + } + + @Test + fun `returns null when lifecycle state is not 'resumed'`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "navigation" + type = "navigation" + data["state"] = "started" + data["screen"] = "io.sentry.MainActivity" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts ui click breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "ui.click" + type = "user" + data["view.id"] = "button_login" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("ui.tap", rrwebEvent.category) + assertEquals("button_login", rrwebEvent.message) + } + + @Test + fun `returns null if no view identifier in data`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "ui.click" + type = "user" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts network connectivity breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "network.event" + type = "system" + data["network_type"] = "cellular" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.connectivity", rrwebEvent.category) + assertEquals("cellular", rrwebEvent.data!!["state"]) + } + + @Test + fun `returns null if no network connectivity state`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "network.event" + type = "system" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts battery status breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.event" + type = "system" + data["action"] = "BATTERY_CHANGED" + data["level"] = 85.0f + data["charging"] = true + data["stuff"] = "shiet" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.battery", rrwebEvent.category) + assertEquals(85.0f, rrwebEvent.data!!["level"]) + assertEquals(true, rrwebEvent.data!!["charging"]) + assertNull(rrwebEvent.data!!["stuff"]) + } + + @Test + fun `converts generic breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.event" + type = "system" + message = "message" + level = SentryLevel.ERROR + data["stuff"] = "shiet" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.event", rrwebEvent.category) + assertEquals("message", rrwebEvent.message) + assertEquals(SentryLevel.ERROR, rrwebEvent.level) + assertEquals("shiet", rrwebEvent.data!!["stuff"]) + } +} 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 new file mode 100644 index 00000000000..3608b77ccb9 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -0,0 +1,248 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import android.media.MediaCodec +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SentryId +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayCacheTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + var encoder: SimpleVideoEncoder? = null + fun getSut( + dir: TemporaryFolder?, + replayId: SentryId = SentryId(), + frameRate: Int, + framesToEncode: Int = 0 + ): ReplayCache { + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) + options.run { + cacheDirPath = dir?.newFolder()?.absolutePath + } + return ReplayCache(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ), + onClose = { + encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, frameRate) } + + encoder!! + }) + } + + fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + encoder!!.mediaCodec.dequeueInputBuffer(0) + encoder!!.mediaCodec.queueInputBuffer(index, index * size, size, presentationTime, flags) + } + } + + private val fixture = Fixture() + + @Test + fun `when no cacheDirPath specified, does not store screenshots`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + null, + replayId, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + assertTrue(replayCache.frames.isEmpty()) + } + + @Test + fun `stores screenshots with timestamp as name`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val expectedScreenshotFile = File(replayCache.replayCacheDir, "1.jpg") + assertTrue(expectedScreenshotFile.exists()) + assertEquals(replayCache.frames.first().timestamp, 1) + assertEquals(replayCache.frames.first().screenshot, expectedScreenshotFile) + } + + @Test + fun `when no frames are provided, returns nothing`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + + assertNull(video) + } + + @Test + fun `deletes frames after creating a video`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 3 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + + assertTrue(replayCache.frames.isEmpty()) + assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) + } + + @Test + fun `repeats last known frame for the segment duration`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `repeats last known frame for the segment duration for each timespan`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + 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) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `repeats last known frame for each segment`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + 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) + 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) + assertEquals(5, segment1!!.frameCount) + assertEquals(5000, segment1.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "1.mp4"), segment1.video) + } + + @Test + fun `respects frameRate`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 2, + framesToEncode = 6 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 1501) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + assertEquals(6, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `addFrame with File path works`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val flutterCacheDir = + File(fixture.options.cacheDirPath!!, "flutter_replay").also { it.mkdirs() } + val screenshot = File(flutterCacheDir, "1.jpg").also { it.createNewFile() } + val video = File(flutterCacheDir, "flutter_0.mp4") + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + replayCache.addFrame(screenshot, frameTimestamp = 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(flutterCacheDir, "flutter_0.mp4"), segment0.video) + } +} 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 new file mode 100644 index 00000000000..b4994cdb21c --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -0,0 +1,231 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import android.media.MediaCodec +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.CLOSED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.INITALIZED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.PAUSED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.RESUMED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STARTED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STOPPED +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayIntegrationWithRecorderTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + val hub = mock() + var encoder: SimpleVideoEncoder? = null + + fun getSut( + context: Context, + recorder: Recorder, + recorderConfig: ScreenshotRecorderConfig, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + framesToEncode: Int = 0 + ): ReplayIntegration { + return ReplayIntegration( + context, + dateProvider, + recorderProvider = { recorder }, + recorderConfigProvider = { recorderConfig }, + // this is just needed for testing to encode a fake video + replayCacheProvider = { replayId -> + ReplayCache( + options, + replayId, + recorderConfig, + encoderProvider = { videoFile, height, width -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ), + onClose = { + encodeFrame( + framesToEncode, + recorderConfig.frameRate, + size = 0, + flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, recorderConfig.frameRate) } + + encoder!! + } + ) + } + ) + } + + private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + encoder!!.mediaCodec.dequeueInputBuffer(0) + encoder!!.mediaCodec.queueInputBuffer( + index, + index * size, + size, + presentationTime, + flags + ) + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `works with different recorder`() { + val captured = AtomicBoolean(false) + whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + // fake current time to trigger segment creation, CurrentDateProvider.getInstance() should + // be used in prod + val dateProvider = ICurrentDateProvider { + System.currentTimeMillis() + fixture.options.experimental.sessionReplay.sessionSegmentDuration + } + + fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay: ReplayIntegration + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + val recorder = object : Recorder { + var state: LifecycleState = INITALIZED + + override fun start(recorderConfig: ScreenshotRecorderConfig) { + state = STARTED + } + + override fun resume() { + state = RESUMED + } + + override fun pause() { + state = PAUSED + } + + override fun stop() { + state = STOPPED + } + + override fun close() { + state = CLOSED + } + } + + replay = fixture.getSut(context, recorder, recorderConfig, dateProvider, framesToEncode = 5) + replay.register(fixture.hub, fixture.options) + + assertEquals(INITALIZED, recorder.state) + + replay.start() + assertEquals(STARTED, recorder.state) + + replay.resume() + assertEquals(RESUMED, recorder.state) + + replay.pause() + assertEquals(PAUSED, recorder.state) + + replay.stop() + assertEquals(STOPPED, recorder.state) + + replay.close() + assertEquals(CLOSED, recorder.state) + + // start again and capture some frames + replay.start() + + // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed + // inside recorder.start() + val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) + + // verify + await.untilTrue(captured) + + verify(fixture.hub).captureReplay( + check { + assertEquals(replay.replayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + assertEquals("replay_${replay.replayId}", it.videoFile?.parentFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(200, metaEvents?.first()?.height) + assertEquals(100, metaEvents?.first()?.width) + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(200, videoEvents?.first()?.height) + assertEquals(100, videoEvents?.first()?.width) + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + } + ) + } + + enum class LifecycleState { + INITALIZED, + STARTED, + RESUMED, + PAUSED, + STOPPED, + CLOSED + } +} diff --git a/sentry-android/build.gradle.kts b/sentry-android/build.gradle.kts index 47b873ac490..81619b736f2 100644 --- a/sentry-android/build.gradle.kts +++ b/sentry-android/build.gradle.kts @@ -35,4 +35,5 @@ android { dependencies { api(projects.sentryAndroidCore) api(projects.sentryAndroidNdk) + api(projects.sentryAndroidReplay) } diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index 21a3329a149..6bceb81a195 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -15,6 +15,7 @@ import io.sentry.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEADERS_EVEN import io.sentry.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_BODY_EVENT import io.sentry.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_HEADERS_EVENT import io.sentry.okhttp.SentryOkHttpEventListener.Companion.SECURE_CONNECT_EVENT +import io.sentry.transport.CurrentDateProvider import io.sentry.util.Platform import io.sentry.util.UrlUtils import okhttp3.Request @@ -58,6 +59,8 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req breadcrumb = Breadcrumb.http(url, method) breadcrumb.setData("host", host) breadcrumb.setData("path", encodedPath) + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) // We add the same data to the root call span callRootSpan?.setData("url", url) @@ -150,6 +153,8 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req hint.set(TypeCheckHint.OKHTTP_REQUEST, request) response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_END_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) // We send the breadcrumb even without spans. hub.addBreadcrumb(breadcrumb, hint) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index efa472963dd..5bf93be060d 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -14,6 +14,7 @@ import io.sentry.SpanStatus import io.sentry.TypeCheckHint.OKHTTP_REQUEST import io.sentry.TypeCheckHint.OKHTTP_RESPONSE import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback +import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils @@ -79,6 +80,7 @@ public open class SentryOkHttpInterceptor( val parentSpan = if (Platform.isAndroid()) hub.transaction else hub.span span = parentSpan?.startChild("http.client", "$method $url") } + val startTimestamp = CurrentDateProvider.getInstance().currentTimeMillis span?.spanContext?.origin = TRACE_ORIGIN @@ -137,12 +139,17 @@ public open class SentryOkHttpInterceptor( // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call if (!isFromEventListener) { - sendBreadcrumb(request, code, response) + sendBreadcrumb(request, code, response, startTimestamp) } } } - private fun sendBreadcrumb(request: Request, code: Int?, response: Response?) { + private fun sendBreadcrumb( + request: Request, + code: Int?, + response: Response?, + startTimestamp: Long + ) { val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) request.body?.contentLength().ifHasValidLength { breadcrumb.setData("http.request_content_length", it) @@ -156,6 +163,9 @@ public open class SentryOkHttpInterceptor( hint[OKHTTP_RESPONSE] = it } + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) + breadcrumb.setData(SpanDataConvention.HTTP_END_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) hub.addBreadcrumb(breadcrumb, hint) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 6d4b96bdca8..8876efd66de 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -165,5 +165,8 @@ + + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8c25105d82e..f662b29efb2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -45,6 +45,7 @@ public final class io/sentry/Baggage { public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Ljava/lang/String; public fun getSampleRate ()Ljava/lang/String; public fun getSampleRateDouble ()Ljava/lang/Double; public fun getSampled ()Ljava/lang/String; @@ -59,6 +60,7 @@ public final class io/sentry/Baggage { public fun setEnvironment (Ljava/lang/String;)V public fun setPublicKey (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayId (Ljava/lang/String;)V public fun setSampleRate (Ljava/lang/String;)V public fun setSampled (Ljava/lang/String;)V public fun setTraceId (Ljava/lang/String;)V @@ -66,7 +68,7 @@ public final class io/sentry/Baggage { public fun setUserId (Ljava/lang/String;)V public fun setUserSegment (Ljava/lang/String;)V public fun setValuesFromScope (Lio/sentry/IScope;Lio/sentry/SentryOptions;)V - public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V + public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/protocol/SentryId;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V public fun toHeaderString (Ljava/lang/String;)Ljava/lang/String; public fun toTraceContext ()Lio/sentry/TraceContext; } @@ -76,6 +78,7 @@ public final class io/sentry/Baggage$DSCKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; @@ -136,8 +139,8 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public final class io/sentry/Breadcrumb$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/Breadcrumb; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/Breadcrumb; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/Breadcrumb$JsonKeys { @@ -181,8 +184,8 @@ public final class io/sentry/CheckIn : io/sentry/JsonSerializable, io/sentry/Jso public final class io/sentry/CheckIn$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/CheckIn$JsonKeys { @@ -227,6 +230,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static final field MetricBucket Lio/sentry/DataCategory; public static final field Monitor Lio/sentry/DataCategory; public static final field Profile Lio/sentry/DataCategory; + public static final field Replay Lio/sentry/DataCategory; public static final field Security Lio/sentry/DataCategory; public static final field Session Lio/sentry/DataCategory; public static final field Span Lio/sentry/DataCategory; @@ -302,9 +306,16 @@ public final class io/sentry/EnvelopeSender : io/sentry/IEnvelopeSender { public abstract interface class io/sentry/EventProcessor { public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } +public final class io/sentry/ExperimentalOptions { + public fun ()V + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V +} + public final class io/sentry/ExternalOptions { public fun ()V public fun addBundleId (Ljava/lang/String;)V @@ -391,12 +402,14 @@ public final class io/sentry/Hint { public fun get (Ljava/lang/String;)Ljava/lang/Object; public fun getAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public fun getAttachments ()Ljava/util/List; + public fun getReplayRecording ()Lio/sentry/ReplayRecording; public fun getScreenshot ()Lio/sentry/Attachment; public fun getThreadDump ()Lio/sentry/Attachment; public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V public fun set (Ljava/lang/String;Ljava/lang/Object;)V + public fun setReplayRecording (Lio/sentry/ReplayRecording;)V public fun setScreenshot (Lio/sentry/Attachment;)V public fun setThreadDump (Lio/sentry/Attachment;)V public fun setViewHierarchy (Lio/sentry/Attachment;)V @@ -425,6 +438,7 @@ public final class io/sentry/Hub : io/sentry/IHub, io/sentry/metrics/MetricsApi$ public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -481,6 +495,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -571,6 +586,7 @@ public abstract interface class io/sentry/IHub { public fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public abstract fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public abstract fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public abstract fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -681,6 +697,7 @@ public abstract interface class io/sentry/IScope { public abstract fun getLevel ()Lio/sentry/SentryLevel; public abstract fun getOptions ()Lio/sentry/SentryOptions; public abstract fun getPropagationContext ()Lio/sentry/PropagationContext; + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun getRequest ()Lio/sentry/protocol/Request; public abstract fun getScreen ()Ljava/lang/String; public abstract fun getSession ()Lio/sentry/Session; @@ -703,6 +720,7 @@ public abstract interface class io/sentry/IScope { public abstract fun setFingerprint (Ljava/util/List;)V public abstract fun setLevel (Lio/sentry/SentryLevel;)V public abstract fun setPropagationContext (Lio/sentry/PropagationContext;)V + public abstract fun setReplayId (Lio/sentry/protocol/SentryId;)V public abstract fun setRequest (Lio/sentry/protocol/Request;)V public abstract fun setScreen (Ljava/lang/String;)V public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -747,6 +765,7 @@ public abstract interface class io/sentry/ISentryClient { public fun captureException (Ljava/lang/Throwable;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; + public abstract fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;)V public abstract fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;)Lio/sentry/protocol/SentryId; @@ -870,7 +889,7 @@ public final class io/sentry/JavaMemoryCollector : io/sentry/IPerformanceSnapsho } public abstract interface class io/sentry/JsonDeserializer { - public abstract fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public abstract fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/JsonObjectDeserializer { @@ -878,24 +897,39 @@ public final class io/sentry/JsonObjectDeserializer { public fun deserialize (Lio/sentry/JsonObjectReader;)Ljava/lang/Object; } -public final class io/sentry/JsonObjectReader : io/sentry/vendor/gson/stream/JsonReader { +public final class io/sentry/JsonObjectReader : io/sentry/ObjectReader { public fun (Ljava/io/Reader;)V - public static fun dateOrNull (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/util/Date; + public fun beginArray ()V + public fun beginObject ()V + public fun close ()V + public fun endArray ()V + public fun endObject ()V + public fun hasNext ()Z + public fun nextBoolean ()Z public fun nextBooleanOrNull ()Ljava/lang/Boolean; public fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public fun nextDouble ()D public fun nextDoubleOrNull ()Ljava/lang/Double; - public fun nextFloat ()Ljava/lang/Float; + public fun nextFloat ()F public fun nextFloatOrNull ()Ljava/lang/Float; + public fun nextInt ()I public fun nextIntegerOrNull ()Ljava/lang/Integer; public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public fun nextLong ()J public fun nextLongOrNull ()Ljava/lang/Long; public fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextName ()Ljava/lang/String; + public fun nextNull ()V public fun nextObjectOrNull ()Ljava/lang/Object; public fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun nextString ()Ljava/lang/String; public fun nextStringOrNull ()Ljava/lang/String; public fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; public fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public fun setLenient (Z)V + public fun skipValue ()V } public final class io/sentry/JsonObjectSerializer { @@ -915,11 +949,13 @@ public final class io/sentry/JsonObjectWriter : io/sentry/ObjectWriter { public synthetic fun endArray ()Lio/sentry/ObjectWriter; public fun endObject ()Lio/sentry/JsonObjectWriter; public synthetic fun endObject ()Lio/sentry/ObjectWriter; + public fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun name (Ljava/lang/String;)Lio/sentry/JsonObjectWriter; public synthetic fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun nullValue ()Lio/sentry/JsonObjectWriter; public synthetic fun nullValue ()Lio/sentry/ObjectWriter; public fun setIndent (Ljava/lang/String;)V + public fun setLenient (Z)V public fun value (D)Lio/sentry/JsonObjectWriter; public synthetic fun value (D)Lio/sentry/ObjectWriter; public fun value (J)Lio/sentry/JsonObjectWriter; @@ -964,6 +1000,7 @@ public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java public fun (Lio/sentry/SentryOptions;)V public fun close ()V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -1063,8 +1100,8 @@ public final class io/sentry/MonitorConfig : io/sentry/JsonSerializable, io/sent public final class io/sentry/MonitorConfig$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorConfig$JsonKeys { @@ -1087,8 +1124,8 @@ public final class io/sentry/MonitorContexts : java/util/concurrent/ConcurrentHa public final class io/sentry/MonitorContexts$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -1110,8 +1147,8 @@ public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/se public final class io/sentry/MonitorSchedule$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorSchedule$JsonKeys { @@ -1166,6 +1203,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -1215,6 +1253,25 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V } +public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; + public static fun getInstance ()Lio/sentry/NoOpReplayBreadcrumbConverter; +} + +public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; + public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun isRecording ()Z + public fun pause ()V + public fun resume ()V + public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public fun start ()V + public fun stop ()V +} + public final class io/sentry/NoOpScope : io/sentry/IScope { public fun addAttachment (Lio/sentry/Attachment;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V @@ -1238,6 +1295,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun getLevel ()Lio/sentry/SentryLevel; public fun getOptions ()Lio/sentry/SentryOptions; public fun getPropagationContext ()Lio/sentry/PropagationContext; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getRequest ()Lio/sentry/protocol/Request; public fun getScreen ()Ljava/lang/String; public fun getSession ()Lio/sentry/Session; @@ -1260,6 +1318,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun setFingerprint (Ljava/util/List;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setPropagationContext (Lio/sentry/PropagationContext;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setScreen (Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -1383,13 +1442,49 @@ public final class io/sentry/NoOpTransportFactory : io/sentry/ITransportFactory public static fun getInstance ()Lio/sentry/NoOpTransportFactory; } +public abstract interface class io/sentry/ObjectReader : java/io/Closeable { + public abstract fun beginArray ()V + public abstract fun beginObject ()V + public static fun dateOrNull (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/util/Date; + public abstract fun endArray ()V + public abstract fun endObject ()V + public abstract fun hasNext ()Z + public abstract fun nextBoolean ()Z + public abstract fun nextBooleanOrNull ()Ljava/lang/Boolean; + public abstract fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public abstract fun nextDouble ()D + public abstract fun nextDoubleOrNull ()Ljava/lang/Double; + public abstract fun nextFloat ()F + public abstract fun nextFloatOrNull ()Ljava/lang/Float; + public abstract fun nextInt ()I + public abstract fun nextIntegerOrNull ()Ljava/lang/Integer; + public abstract fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public abstract fun nextLong ()J + public abstract fun nextLongOrNull ()Ljava/lang/Long; + public abstract fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public abstract fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public abstract fun nextName ()Ljava/lang/String; + public abstract fun nextNull ()V + public abstract fun nextObjectOrNull ()Ljava/lang/Object; + public abstract fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public abstract fun nextString ()Ljava/lang/String; + public abstract fun nextStringOrNull ()Ljava/lang/String; + public abstract fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; + public abstract fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public abstract fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public abstract fun setLenient (Z)V + public abstract fun skipValue ()V +} + public abstract interface class io/sentry/ObjectWriter { public abstract fun beginArray ()Lio/sentry/ObjectWriter; public abstract fun beginObject ()Lio/sentry/ObjectWriter; public abstract fun endArray ()Lio/sentry/ObjectWriter; public abstract fun endObject ()Lio/sentry/ObjectWriter; + public abstract fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public abstract fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public abstract fun nullValue ()Lio/sentry/ObjectWriter; + public abstract fun setLenient (Z)V public abstract fun value (D)Lio/sentry/ObjectWriter; public abstract fun value (J)Lio/sentry/ObjectWriter; public abstract fun value (Lio/sentry/ILogger;Ljava/lang/Object;)Lio/sentry/ObjectWriter; @@ -1480,8 +1575,8 @@ public final class io/sentry/ProfilingTraceData : io/sentry/JsonSerializable, io public final class io/sentry/ProfilingTraceData$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTraceData; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTraceData; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/ProfilingTraceData$JsonKeys { @@ -1539,8 +1634,8 @@ public final class io/sentry/ProfilingTransactionData : io/sentry/JsonSerializab public final class io/sentry/ProfilingTransactionData$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTransactionData; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTransactionData; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/ProfilingTransactionData$JsonKeys { @@ -1574,6 +1669,47 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public abstract interface class io/sentry/ReplayBreadcrumbConverter { + public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; +} + +public abstract interface class io/sentry/ReplayController { + public abstract fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; + public abstract fun isRecording ()Z + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public abstract fun start ()V + public abstract fun stop ()V +} + +public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getPayload ()Ljava/util/List; + public fun getSegmentId ()Ljava/lang/Integer; + public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setPayload (Ljava/util/List;)V + public fun setSegmentId (Ljava/lang/Integer;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/ReplayRecording$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ReplayRecording; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/ReplayRecording$JsonKeys { + public static final field SEGMENT_ID Ljava/lang/String; + public fun ()V +} + public final class io/sentry/RequestDetails { public fun (Ljava/lang/String;Ljava/util/Map;)V public fun getHeaders ()Ljava/util/Map; @@ -1609,6 +1745,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun getLevel ()Lio/sentry/SentryLevel; public fun getOptions ()Lio/sentry/SentryOptions; public fun getPropagationContext ()Lio/sentry/PropagationContext; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getRequest ()Lio/sentry/protocol/Request; public fun getScreen ()Ljava/lang/String; public fun getSession ()Lio/sentry/Session; @@ -1631,6 +1768,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun setFingerprint (Ljava/util/List;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setPropagationContext (Lio/sentry/PropagationContext;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setScreen (Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -1799,8 +1937,8 @@ public final class io/sentry/SentryAppStartProfilingOptions : io/sentry/JsonSeri public final class io/sentry/SentryAppStartProfilingOptions$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryAppStartProfilingOptions; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryAppStartProfilingOptions; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryAppStartProfilingOptions$JsonKeys { @@ -1866,7 +2004,7 @@ public abstract class io/sentry/SentryBaseEvent { public final class io/sentry/SentryBaseEvent$Deserializer { public fun ()V - public fun deserializeValue (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Z + public fun deserializeValue (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z } public final class io/sentry/SentryBaseEvent$JsonKeys { @@ -1897,6 +2035,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient, io/sentry/m public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureMetrics (Lio/sentry/metrics/EncodedMetrics;)Lio/sentry/protocol/SentryId; + public fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/IScope;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V @@ -1959,8 +2098,8 @@ public final class io/sentry/SentryEnvelopeHeader : io/sentry/JsonSerializable, public final class io/sentry/SentryEnvelopeHeader$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeHeader; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeHeader; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEnvelopeHeader$JsonKeys { @@ -1978,6 +2117,7 @@ public final class io/sentry/SentryEnvelopeItem { public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromMetrics (Lio/sentry/metrics/EncodedMetrics;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; + public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;)Lio/sentry/SentryEnvelopeItem; public static fun fromSession (Lio/sentry/ISerializer;Lio/sentry/Session;)Lio/sentry/SentryEnvelopeItem; public static fun fromUserFeedback (Lio/sentry/ISerializer;Lio/sentry/UserFeedback;)Lio/sentry/SentryEnvelopeItem; public fun getClientReport (Lio/sentry/ISerializer;)Lio/sentry/clientreport/ClientReport; @@ -2001,8 +2141,8 @@ public final class io/sentry/SentryEnvelopeItemHeader : io/sentry/JsonSerializab public final class io/sentry/SentryEnvelopeItemHeader$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeItemHeader; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeItemHeader; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEnvelopeItemHeader$JsonKeys { @@ -2048,8 +2188,8 @@ public final class io/sentry/SentryEvent : io/sentry/SentryBaseEvent, io/sentry/ public final class io/sentry/SentryEvent$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEvent; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEvent$JsonKeys { @@ -2108,6 +2248,7 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static final field Profile Lio/sentry/SentryItemType; public static final field ReplayEvent Lio/sentry/SentryItemType; public static final field ReplayRecording Lio/sentry/SentryItemType; + public static final field ReplayVideo Lio/sentry/SentryItemType; public static final field Session Lio/sentry/SentryItemType; public static final field Statsd Lio/sentry/SentryItemType; public static final field Transaction Lio/sentry/SentryItemType; @@ -2132,6 +2273,12 @@ public final class io/sentry/SentryLevel : java/lang/Enum, io/sentry/JsonSeriali public static fun values ()[Lio/sentry/SentryLevel; } +public final class io/sentry/SentryLevel$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLevel; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + public final class io/sentry/SentryLockReason : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field ANY I public static final field BLOCKED I @@ -2159,8 +2306,8 @@ public final class io/sentry/SentryLockReason : io/sentry/JsonSerializable, io/s public final class io/sentry/SentryLockReason$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLockReason; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLockReason; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryLockReason$JsonKeys { @@ -2232,6 +2379,7 @@ public class io/sentry/SentryOptions { public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getExecutorService ()Lio/sentry/ISentryExecutorService; + public fun getExperimental ()Lio/sentry/ExperimentalOptions; public fun getFlushTimeoutMillis ()J public fun getFullyDisplayedReporter ()Lio/sentry/FullyDisplayedReporter; public fun getGestureTargetLocators ()Ljava/util/List; @@ -2264,6 +2412,7 @@ public class io/sentry/SentryOptions { public fun getProxy ()Lio/sentry/SentryOptions$Proxy; public fun getReadTimeoutMillis ()I public fun getRelease ()Ljava/lang/String; + public fun getReplayController ()Lio/sentry/ReplayController; public fun getSampleRate ()Ljava/lang/Double; public fun getScopeObservers ()Ljava/util/List; public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; @@ -2299,6 +2448,7 @@ public class io/sentry/SentryOptions { public fun isEnableMetrics ()Z public fun isEnablePrettySerializationOutput ()Z public fun isEnableScopePersistence ()Z + public fun isEnableScreenTracking ()Z public fun isEnableShutdownHook ()Z public fun isEnableSpanLocalMetricAggregation ()Z public fun isEnableSpotlight ()Z @@ -2345,6 +2495,7 @@ public class io/sentry/SentryOptions { public fun setEnableMetrics (Z)V public fun setEnablePrettySerializationOutput (Z)V public fun setEnableScopePersistence (Z)V + public fun setEnableScreenTracking (Z)V public fun setEnableShutdownHook (Z)V public fun setEnableSpanLocalMetricAggregation (Z)V public fun setEnableSpotlight (Z)V @@ -2383,6 +2534,7 @@ public class io/sentry/SentryOptions { public fun setProxy (Lio/sentry/SentryOptions$Proxy;)V public fun setReadTimeoutMillis (I)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayController (Lio/sentry/ReplayController;)V public fun setSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSendClientReports (Z)V @@ -2480,6 +2632,101 @@ public abstract interface class io/sentry/SentryOptions$TracesSamplerCallback { public abstract fun sample (Lio/sentry/SamplingContext;)Ljava/lang/Double; } +public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field REPLAY_EVENT_TYPE Ljava/lang/String; + public static final field REPLAY_VIDEO_MAX_SIZE J + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getErrorIds ()Ljava/util/List; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun getReplayStartTimestamp ()Ljava/util/Date; + public fun getReplayType ()Lio/sentry/SentryReplayEvent$ReplayType; + public fun getSegmentId ()I + public fun getTimestamp ()Ljava/util/Date; + public fun getTraceIds ()Ljava/util/List; + public fun getType ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getUrls ()Ljava/util/List; + public fun getVideoFile ()Ljava/io/File; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setErrorIds (Ljava/util/List;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V + public fun setReplayStartTimestamp (Ljava/util/Date;)V + public fun setReplayType (Lio/sentry/SentryReplayEvent$ReplayType;)V + public fun setSegmentId (I)V + public fun setTimestamp (Ljava/util/Date;)V + public fun setTraceIds (Ljava/util/List;)V + public fun setType (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setUrls (Ljava/util/List;)V + public fun setVideoFile (Ljava/io/File;)V +} + +public final class io/sentry/SentryReplayEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryReplayEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryReplayEvent$JsonKeys { + public static final field ERROR_IDS Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; + public static final field REPLAY_START_TIMESTAMP Ljava/lang/String; + public static final field REPLAY_TYPE Ljava/lang/String; + public static final field SEGMENT_ID Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TRACE_IDS Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field URLS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/SentryReplayEvent$ReplayType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field BUFFER Lio/sentry/SentryReplayEvent$ReplayType; + public static final field SESSION Lio/sentry/SentryReplayEvent$ReplayType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayEvent$ReplayType; + public static fun values ()[Lio/sentry/SentryReplayEvent$ReplayType; +} + +public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryReplayEvent$ReplayType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryReplayOptions { + public fun ()V + public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun getErrorReplayDuration ()J + public fun getErrorSampleRate ()Ljava/lang/Double; + public fun getFrameRate ()I + public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public fun getRedactAllImages ()Z + public fun getRedactAllText ()Z + public fun getSessionDuration ()J + public fun getSessionSampleRate ()Ljava/lang/Double; + public fun getSessionSegmentDuration ()J + public fun isSessionReplayEnabled ()Z + public fun isSessionReplayForErrorsEnabled ()Z + public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V + public fun setRedactAllImages (Z)V + public fun setRedactAllText (Z)V + public fun setSessionSampleRate (Ljava/lang/Double;)V +} + +public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { + public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; + 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 sizeScale F + public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public static fun values ()[Lio/sentry/SentryReplayOptions$SentryReplayQuality; +} + public final class io/sentry/SentrySpanStorage { public fun get (Ljava/lang/String;)Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/SentrySpanStorage; @@ -2604,8 +2851,8 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public final class io/sentry/Session$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/Session; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/Session; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/Session$JsonKeys { @@ -2728,8 +2975,8 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public final class io/sentry/SpanContext$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanContext; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SpanContext$JsonKeys { @@ -2755,10 +3002,12 @@ public abstract interface class io/sentry/SpanDataConvention { public static final field FRAMES_FROZEN Ljava/lang/String; public static final field FRAMES_SLOW Ljava/lang/String; public static final field FRAMES_TOTAL Ljava/lang/String; + public static final field HTTP_END_TIMESTAMP Ljava/lang/String; public static final field HTTP_FRAGMENT_KEY Ljava/lang/String; public static final field HTTP_METHOD_KEY Ljava/lang/String; public static final field HTTP_QUERY_KEY Ljava/lang/String; public static final field HTTP_RESPONSE_CONTENT_LENGTH_KEY Ljava/lang/String; + public static final field HTTP_START_TIMESTAMP Ljava/lang/String; public static final field HTTP_STATUS_CODE_KEY Ljava/lang/String; public static final field THREAD_ID Ljava/lang/String; public static final field THREAD_NAME Ljava/lang/String; @@ -2776,8 +3025,8 @@ public final class io/sentry/SpanId : io/sentry/JsonSerializable { public final class io/sentry/SpanId$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanId; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanId; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public class io/sentry/SpanOptions { @@ -2818,8 +3067,8 @@ public final class io/sentry/SpanStatus : java/lang/Enum, io/sentry/JsonSerializ public final class io/sentry/SpanStatus$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanStatus; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanStatus; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SpotlightIntegration : io/sentry/Integration, io/sentry/SentryOptions$BeforeEnvelopeCallback, java/io/Closeable { @@ -2842,6 +3091,7 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getSampleRate ()Ljava/lang/String; public fun getSampled ()Ljava/lang/String; public fun getTraceId ()Lio/sentry/protocol/SentryId; @@ -2855,14 +3105,15 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public final class io/sentry/TraceContext$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/TraceContext; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/TraceContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/TraceContext$JsonKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; @@ -3015,8 +3266,8 @@ public final class io/sentry/UserFeedback : io/sentry/JsonSerializable, io/sentr public final class io/sentry/UserFeedback$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/UserFeedback; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/UserFeedback; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/UserFeedback$JsonKeys { @@ -3127,8 +3378,8 @@ public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializa public final class io/sentry/clientreport/ClientReport$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/ClientReport; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/ClientReport; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/clientreport/ClientReport$JsonKeys { @@ -3173,8 +3424,8 @@ public final class io/sentry/clientreport/DiscardedEvent : io/sentry/JsonSeriali public final class io/sentry/clientreport/DiscardedEvent$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/DiscardedEvent; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/DiscardedEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/clientreport/DiscardedEvent$JsonKeys { @@ -3607,8 +3858,8 @@ public final class io/sentry/profilemeasurements/ProfileMeasurement : io/sentry/ public final class io/sentry/profilemeasurements/ProfileMeasurement$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/profilemeasurements/ProfileMeasurement$JsonKeys { @@ -3631,8 +3882,8 @@ public final class io/sentry/profilemeasurements/ProfileMeasurementValue : io/se public final class io/sentry/profilemeasurements/ProfileMeasurementValue$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/profilemeasurements/ProfileMeasurementValue$JsonKeys { @@ -3675,8 +3926,8 @@ public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/App$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/App; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/App; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/App$JsonKeys { @@ -3710,8 +3961,8 @@ public final class io/sentry/protocol/Browser : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Browser$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Browser; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Browser; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Browser$JsonKeys { @@ -3745,8 +3996,8 @@ public final class io/sentry/protocol/Contexts : java/util/concurrent/Concurrent public final class io/sentry/protocol/Contexts$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Contexts; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Contexts; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugImage : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -3779,8 +4030,8 @@ public final class io/sentry/protocol/DebugImage : io/sentry/JsonSerializable, i public final class io/sentry/protocol/DebugImage$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugImage; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugImage; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugImage$JsonKeys { @@ -3809,8 +4060,8 @@ public final class io/sentry/protocol/DebugMeta : io/sentry/JsonSerializable, io public final class io/sentry/protocol/DebugMeta$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugMeta; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugMeta; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugMeta$JsonKeys { @@ -3899,8 +4150,8 @@ public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/se public final class io/sentry/protocol/Device$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Device$DeviceOrientation : java/lang/Enum, io/sentry/JsonSerializable { @@ -3913,8 +4164,8 @@ public final class io/sentry/protocol/Device$DeviceOrientation : java/lang/Enum, public final class io/sentry/protocol/Device$DeviceOrientation$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device$DeviceOrientation; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device$DeviceOrientation; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Device$JsonKeys { @@ -3972,8 +4223,8 @@ public final class io/sentry/protocol/Geo : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/Geo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Geo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Geo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Geo$JsonKeys { @@ -4013,8 +4264,8 @@ public final class io/sentry/protocol/Gpu : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/Gpu$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Gpu; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Gpu; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Gpu$JsonKeys { @@ -4050,8 +4301,8 @@ public final class io/sentry/protocol/MeasurementValue : io/sentry/JsonSerializa public final class io/sentry/protocol/MeasurementValue$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MeasurementValue; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MeasurementValue; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/MeasurementValue$JsonKeys { @@ -4084,8 +4335,8 @@ public final class io/sentry/protocol/Mechanism : io/sentry/JsonSerializable, io public final class io/sentry/protocol/Mechanism$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Mechanism; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Mechanism; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Mechanism$JsonKeys { @@ -4114,8 +4365,8 @@ public final class io/sentry/protocol/Message : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Message$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Message; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Message; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Message$JsonKeys { @@ -4145,8 +4396,8 @@ public final class io/sentry/protocol/MetricSummary : io/sentry/JsonSerializable public final class io/sentry/protocol/MetricSummary$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MetricSummary; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MetricSummary; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/MetricSummary$JsonKeys { @@ -4182,8 +4433,8 @@ public final class io/sentry/protocol/OperatingSystem : io/sentry/JsonSerializab public final class io/sentry/protocol/OperatingSystem$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/OperatingSystem; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/OperatingSystem; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/OperatingSystem$JsonKeys { @@ -4230,8 +4481,8 @@ public final class io/sentry/protocol/Request : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Request$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Request; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Request; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Request$JsonKeys { @@ -4270,8 +4521,8 @@ public final class io/sentry/protocol/Response : io/sentry/JsonSerializable, io/ public final class io/sentry/protocol/Response$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Response; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Response; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Response$JsonKeys { @@ -4300,8 +4551,8 @@ public final class io/sentry/protocol/SdkInfo : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/SdkInfo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkInfo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkInfo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SdkInfo$JsonKeys { @@ -4334,8 +4585,8 @@ public final class io/sentry/protocol/SdkVersion : io/sentry/JsonSerializable, i public final class io/sentry/protocol/SdkVersion$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkVersion; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkVersion; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SdkVersion$JsonKeys { @@ -4367,8 +4618,8 @@ public final class io/sentry/protocol/SentryException : io/sentry/JsonSerializab public final class io/sentry/protocol/SentryException$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryException; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryException; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryException$JsonKeys { @@ -4394,8 +4645,8 @@ public final class io/sentry/protocol/SentryId : io/sentry/JsonSerializable { public final class io/sentry/protocol/SentryId$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryId; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryId; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryPackage : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -4413,8 +4664,8 @@ public final class io/sentry/protocol/SentryPackage : io/sentry/JsonSerializable public final class io/sentry/protocol/SentryPackage$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryPackage; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryPackage; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryPackage$JsonKeys { @@ -4439,8 +4690,8 @@ public final class io/sentry/protocol/SentryRuntime : io/sentry/JsonSerializable public final class io/sentry/protocol/SentryRuntime$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryRuntime; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryRuntime; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryRuntime$JsonKeys { @@ -4476,8 +4727,8 @@ public final class io/sentry/protocol/SentrySpan : io/sentry/JsonSerializable, i public final class io/sentry/protocol/SentrySpan$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentrySpan; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentrySpan; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentrySpan$JsonKeys { @@ -4548,8 +4799,8 @@ public final class io/sentry/protocol/SentryStackFrame : io/sentry/JsonSerializa public final class io/sentry/protocol/SentryStackFrame$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackFrame; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackFrame; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryStackFrame$JsonKeys { @@ -4589,8 +4840,8 @@ public final class io/sentry/protocol/SentryStackTrace : io/sentry/JsonSerializa public final class io/sentry/protocol/SentryStackTrace$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackTrace; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackTrace; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryStackTrace$JsonKeys { @@ -4629,8 +4880,8 @@ public final class io/sentry/protocol/SentryThread : io/sentry/JsonSerializable, public final class io/sentry/protocol/SentryThread$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryThread; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryThread; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryThread$JsonKeys { @@ -4669,8 +4920,8 @@ public final class io/sentry/protocol/SentryTransaction : io/sentry/SentryBaseEv public final class io/sentry/protocol/SentryTransaction$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryTransaction; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryTransaction; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryTransaction$JsonKeys { @@ -4694,8 +4945,8 @@ public final class io/sentry/protocol/TransactionInfo : io/sentry/JsonSerializab public final class io/sentry/protocol/TransactionInfo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/TransactionInfo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/TransactionInfo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/TransactionInfo$JsonKeys { @@ -4746,8 +4997,8 @@ public final class io/sentry/protocol/User : io/sentry/JsonSerializable, io/sent public final class io/sentry/protocol/User$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/User; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/User; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/User$JsonKeys { @@ -4774,8 +5025,8 @@ public final class io/sentry/protocol/ViewHierarchy : io/sentry/JsonSerializable public final class io/sentry/protocol/ViewHierarchy$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/ViewHierarchy$JsonKeys { @@ -4815,8 +5066,8 @@ public final class io/sentry/protocol/ViewHierarchyNode : io/sentry/JsonSerializ public final class io/sentry/protocol/ViewHierarchyNode$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { @@ -4834,6 +5085,401 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public fun ()V } +public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun getBreadcrumbTimestamp ()D + public fun getBreadcrumbType ()Ljava/lang/String; + public fun getCategory ()Ljava/lang/String; + public fun getData ()Ljava/util/Map; + public fun getDataUnknown ()Ljava/util/Map; + public fun getLevel ()Lio/sentry/SentryLevel; + public fun getMessage ()Ljava/lang/String; + public fun getPayloadUnknown ()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 setBreadcrumbTimestamp (D)V + public fun setBreadcrumbType (Ljava/lang/String;)V + public fun setCategory (Ljava/lang/String;)V + public fun setData (Ljava/util/Map;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setMessage (Ljava/lang/String;)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebBreadcrumbEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebBreadcrumbEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebBreadcrumbEvent$JsonKeys { + public static final field CATEGORY Ljava/lang/String; + public static final field DATA Ljava/lang/String; + public static final field LEVEL Ljava/lang/String; + public static final field MESSAGE Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public fun ()V +} + +public abstract class io/sentry/rrweb/RRWebEvent { + protected fun ()V + protected fun (Lio/sentry/rrweb/RRWebEventType;)V + public fun equals (Ljava/lang/Object;)Z + public fun getTimestamp ()J + public fun getType ()Lio/sentry/rrweb/RRWebEventType; + public fun hashCode ()I + public fun setTimestamp (J)V + public fun setType (Lio/sentry/rrweb/RRWebEventType;)V +} + +public final class io/sentry/rrweb/RRWebEvent$Deserializer { + public fun ()V + public fun deserializeValue (Lio/sentry/rrweb/RRWebEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z +} + +public final class io/sentry/rrweb/RRWebEvent$JsonKeys { + public static final field TAG Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebEvent$Serializer { + public fun ()V + public fun serialize (Lio/sentry/rrweb/RRWebEvent;Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V +} + +public final class io/sentry/rrweb/RRWebEventType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field Custom Lio/sentry/rrweb/RRWebEventType; + public static final field DomContentLoaded Lio/sentry/rrweb/RRWebEventType; + public static final field FullSnapshot Lio/sentry/rrweb/RRWebEventType; + public static final field IncrementalSnapshot Lio/sentry/rrweb/RRWebEventType; + public static final field Load Lio/sentry/rrweb/RRWebEventType; + public static final field Meta Lio/sentry/rrweb/RRWebEventType; + public static final field Plugin Lio/sentry/rrweb/RRWebEventType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebEventType; + public static fun values ()[Lio/sentry/rrweb/RRWebEventType; +} + +public final class io/sentry/rrweb/RRWebEventType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebEventType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public abstract class io/sentry/rrweb/RRWebIncrementalSnapshotEvent : io/sentry/rrweb/RRWebEvent { + public fun (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource;)V + public fun getSource ()Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public fun setSource (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource;)V +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$Deserializer { + public fun ()V + public fun deserializeValue (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource : java/lang/Enum, io/sentry/JsonSerializable { + public static final field AdoptedStyleSheet Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field CanvasMutation Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field CustomElement Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Drag Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Font Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Input Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Log Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MediaInteraction Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MouseInteraction Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MouseMove Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Mutation Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Scroll Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Selection Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field StyleDeclaration Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field StyleSheetRule Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field TouchMove Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field ViewportResize Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static fun values ()[Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$JsonKeys { + public static final field SOURCE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$Serializer { + public fun ()V + public fun serialize (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent;Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V +} + +public final class io/sentry/rrweb/RRWebInteractionEvent : io/sentry/rrweb/RRWebIncrementalSnapshotEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getDataUnknown ()Ljava/util/Map; + public fun getId ()I + public fun getInteractionType ()Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public fun getPointerId ()I + public fun getPointerType ()I + public fun getUnknown ()Ljava/util/Map; + public fun getX ()F + public fun getY ()F + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setId (I)V + public fun setInteractionType (Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType;)V + public fun setPointerId (I)V + public fun setPointerType (I)V + public fun setUnknown (Ljava/util/Map;)V + public fun setX (F)V + public fun setY (F)V +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field Blur Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field Click Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field ContextMenu Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field DblClick Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field Focus Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field MouseDown Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field MouseUp Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchCancel Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchEnd Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchMove_Departed Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchStart Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static fun values ()[Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field ID Ljava/lang/String; + public static final field POINTER_ID Ljava/lang/String; + public static final field POINTER_TYPE Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent : io/sentry/rrweb/RRWebIncrementalSnapshotEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getDataUnknown ()Ljava/util/Map; + public fun getPointerId ()I + public fun getPositions ()Ljava/util/List; + 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 setPointerId (I)V + public fun setPositions (Ljava/util/List;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionMoveEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field POINTER_ID Ljava/lang/String; + public static final field POSITIONS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getId ()I + public fun getTimeOffset ()J + public fun getUnknown ()Ljava/util/Map; + public fun getX ()F + public fun getY ()F + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setId (I)V + public fun setTimeOffset (J)V + public fun setUnknown (Ljava/util/Map;)V + public fun setX (F)V + public fun setY (F)V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionMoveEvent$Position; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position$JsonKeys { + public static final field ID Ljava/lang/String; + public static final field TIME_OFFSET Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebMetaEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getDataUnknown ()Ljava/util/Map; + public fun getHeight ()I + public fun getHref ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getWidth ()I + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setHeight (I)V + public fun setHref (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setWidth (I)V +} + +public final class io/sentry/rrweb/RRWebMetaEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebMetaEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field HREF Ljava/lang/String; + public static final field WIDTH 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 + public fun getData ()Ljava/util/Map; + public fun getDataUnknown ()Ljava/util/Map; + public fun getDescription ()Ljava/lang/String; + public fun getEndTimestamp ()D + public fun getOp ()Ljava/lang/String; + public fun getPayloadUnknown ()Ljava/util/Map; + public fun getStartTimestamp ()D + public fun getTag ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setData (Ljava/util/Map;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setDescription (Ljava/lang/String;)V + public fun setEndTimestamp (D)V + public fun setOp (Ljava/lang/String;)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setStartTimestamp (D)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebSpanEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebSpanEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebSpanEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field DESCRIPTION Ljava/lang/String; + public static final field END_TIMESTAMP Ljava/lang/String; + public static final field OP Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field START_TIMESTAMP Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public static final field REPLAY_CONTAINER Ljava/lang/String; + public static final field REPLAY_ENCODING Ljava/lang/String; + public static final field REPLAY_FRAME_RATE_TYPE_CONSTANT Ljava/lang/String; + public static final field REPLAY_FRAME_RATE_TYPE_VARIABLE Ljava/lang/String; + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getContainer ()Ljava/lang/String; + public fun getDataUnknown ()Ljava/util/Map; + public fun getDurationMs ()J + public fun getEncoding ()Ljava/lang/String; + public fun getFrameCount ()I + public fun getFrameRate ()I + public fun getFrameRateType ()Ljava/lang/String; + public fun getHeight ()I + public fun getLeft ()I + public fun getPayloadUnknown ()Ljava/util/Map; + public fun getSegmentId ()I + public fun getSize ()J + public fun getTag ()Ljava/lang/String; + public fun getTop ()I + public fun getUnknown ()Ljava/util/Map; + public fun getWidth ()I + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setContainer (Ljava/lang/String;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setDurationMs (J)V + public fun setEncoding (Ljava/lang/String;)V + public fun setFrameCount (I)V + public fun setFrameRate (I)V + public fun setFrameRateType (Ljava/lang/String;)V + public fun setHeight (I)V + public fun setLeft (I)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setSegmentId (I)V + public fun setSize (J)V + public fun setTag (Ljava/lang/String;)V + public fun setTop (I)V + public fun setUnknown (Ljava/util/Map;)V + public fun setWidth (I)V +} + +public final class io/sentry/rrweb/RRWebVideoEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebVideoEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { + public static final field CONTAINER Ljava/lang/String; + public static final field DATA Ljava/lang/String; + public static final field DURATION Ljava/lang/String; + public static final field ENCODING Ljava/lang/String; + public static final field FRAME_COUNT Ljava/lang/String; + public static final field FRAME_RATE Ljava/lang/String; + public static final field FRAME_RATE_TYPE Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field LEFT Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field SEGMENT_ID Ljava/lang/String; + public static final field SIZE Ljava/lang/String; + public static final field TOP Ljava/lang/String; + public static final field WIDTH Ljava/lang/String; + public fun ()V +} + public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { public fun (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V public fun (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V @@ -5040,6 +5686,41 @@ public final class io/sentry/util/LogUtils { public static fun logNotInstanceOf (Ljava/lang/Class;Ljava/lang/Object;Lio/sentry/ILogger;)V } +public final class io/sentry/util/MapObjectReader : io/sentry/ObjectReader { + public fun (Ljava/util/Map;)V + public fun beginArray ()V + public fun beginObject ()V + public fun close ()V + public fun endArray ()V + public fun endObject ()V + public fun hasNext ()Z + public fun nextBoolean ()Z + public fun nextBooleanOrNull ()Ljava/lang/Boolean; + public fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public fun nextDouble ()D + public fun nextDoubleOrNull ()Ljava/lang/Double; + public fun nextFloat ()F + public fun nextFloatOrNull ()Ljava/lang/Float; + public fun nextInt ()I + public fun nextIntegerOrNull ()Ljava/lang/Integer; + public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public fun nextLong ()J + public fun nextLongOrNull ()Ljava/lang/Long; + public fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextName ()Ljava/lang/String; + public fun nextNull ()V + public fun nextObjectOrNull ()Ljava/lang/Object; + public fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun nextString ()Ljava/lang/String; + public fun nextStringOrNull ()Ljava/lang/String; + public fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; + public fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public fun setLenient (Z)V + public fun skipValue ()V +} + public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun (Ljava/util/Map;)V public synthetic fun beginArray ()Lio/sentry/ObjectWriter; @@ -5050,10 +5731,12 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun endArray ()Lio/sentry/util/MapObjectWriter; public synthetic fun endObject ()Lio/sentry/ObjectWriter; public fun endObject ()Lio/sentry/util/MapObjectWriter; + public fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public synthetic fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun name (Ljava/lang/String;)Lio/sentry/util/MapObjectWriter; public synthetic fun nullValue ()Lio/sentry/ObjectWriter; public fun nullValue ()Lio/sentry/util/MapObjectWriter; + public fun setLenient (Z)V public synthetic fun value (D)Lio/sentry/ObjectWriter; public fun value (D)Lio/sentry/util/MapObjectWriter; public synthetic fun value (J)Lio/sentry/ObjectWriter; diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 2f35cbd4f72..08efc550d5a 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) testImplementation(Config.TestLibs.javaFaker) + testImplementation(Config.TestLibs.msgpack) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 360e1dc7d27..de7cf95a20f 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -141,6 +141,7 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); + // TODO: add replay_id later baggage.freeze(); return baggage; } @@ -355,6 +356,16 @@ public void setSampled(final @Nullable String sampled) { set(DSCKeys.SAMPLED, sampled); } + @ApiStatus.Internal + public @Nullable String getReplayId() { + return get(DSCKeys.REPLAY_ID); + } + + @ApiStatus.Internal + public void setReplayId(final @Nullable String replayId) { + set(DSCKeys.REPLAY_ID, replayId); + } + @ApiStatus.Internal public void set(final @NotNull String key, final @Nullable String value) { if (mutable) { @@ -383,6 +394,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, + final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -394,6 +406,9 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); + if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) { + setReplayId(replayId.toString()); + } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); setSampled(StringUtils.toString(sampled(samplingDecision))); } @@ -403,10 +418,14 @@ public void setValuesFromScope( final @NotNull IScope scope, final @NotNull SentryOptions options) { final @NotNull PropagationContext propagationContext = scope.getPropagationContext(); final @Nullable User user = scope.getUser(); + final @NotNull SentryId replayId = scope.getReplayId(); setTraceId(propagationContext.getTraceId().toString()); setPublicKey(new Dsn(options.getDsn()).getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); + if (!SentryId.EMPTY_ID.equals(replayId)) { + setReplayId(replayId.toString()); + } setUserSegment(user != null ? getSegment(user) : null); setTransaction(null); setSampleRate(null); @@ -482,6 +501,7 @@ private static boolean isHighQualityTransactionName( @Nullable public TraceContext toTraceContext() { final String traceIdString = getTraceId(); + final String replayIdString = getReplayId(); final String publicKey = getPublicKey(); if (traceIdString != null && publicKey != null) { @@ -496,7 +516,8 @@ public TraceContext toTraceContext() { getUserSegment(), getTransaction(), getSampleRate(), - getSampled()); + getSampled(), + replayIdString == null ? null : new SentryId(replayIdString)); traceContext.setUnknown(getUnknown()); return traceContext; } else { @@ -515,6 +536,7 @@ public static final class DSCKeys { public static final String TRANSACTION = "sentry-transaction"; public static final String SAMPLE_RATE = "sentry-sample_rate"; public static final String SAMPLED = "sentry-sampled"; + public static final String REPLAY_ID = "sentry-replay_id"; public static final List ALL = Arrays.asList( @@ -526,6 +548,7 @@ public static final class DSCKeys { USER_SEGMENT, TRANSACTION, SAMPLE_RATE, - SAMPLED); + SAMPLED, + REPLAY_ID); } } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fe2055c336c..da1453bc68b 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -86,8 +86,7 @@ public static Breadcrumb fromMap( switch (entry.getKey()) { case JsonKeys.TIMESTAMP: if (value instanceof String) { - Date deserializedDate = - JsonObjectReader.dateOrNull((String) value, options.getLogger()); + Date deserializedDate = ObjectReader.dateOrNull((String) value, options.getLogger()); if (deserializedDate != null) { timestamp = deserializedDate; } @@ -700,8 +699,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Breadcrumb deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull Breadcrumb deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; diff --git a/sentry/src/main/java/io/sentry/CheckIn.java b/sentry/src/main/java/io/sentry/CheckIn.java index 4c83771324f..e7c6abef3e8 100644 --- a/sentry/src/main/java/io/sentry/CheckIn.java +++ b/sentry/src/main/java/io/sentry/CheckIn.java @@ -170,7 +170,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull CheckIn deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull CheckIn deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SentryId sentryId = null; MonitorConfig monitorConfig = null; diff --git a/sentry/src/main/java/io/sentry/DataCategory.java b/sentry/src/main/java/io/sentry/DataCategory.java index a4eafc2bb5c..d9acdb60cf1 100644 --- a/sentry/src/main/java/io/sentry/DataCategory.java +++ b/sentry/src/main/java/io/sentry/DataCategory.java @@ -14,6 +14,7 @@ public enum DataCategory { Profile("profile"), MetricBucket("metric_bucket"), Transaction("transaction"), + Replay("replay"), Span("span"), Security("security"), UserReport("user_report"), diff --git a/sentry/src/main/java/io/sentry/EventProcessor.java b/sentry/src/main/java/io/sentry/EventProcessor.java index ba675086142..9e52408edbc 100644 --- a/sentry/src/main/java/io/sentry/EventProcessor.java +++ b/sentry/src/main/java/io/sentry/EventProcessor.java @@ -32,4 +32,16 @@ default SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { default SentryTransaction process(@NotNull SentryTransaction transaction, @NotNull Hint hint) { return transaction; } + + /** + * May mutate or drop a SentryEvent + * + * @param event the SentryEvent + * @param hint the Hint + * @return the event itself, a mutated SentryEvent or null + */ + @Nullable + default SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + return event; + } } diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java new file mode 100644 index 00000000000..f587996bd8c --- /dev/null +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -0,0 +1,22 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +/** + * Experimental options for new features, these options are going to be promoted to SentryOptions + * before GA. + * + *

Beware that experimental options can change at any time. + */ +public final class ExperimentalOptions { + private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); + + @NotNull + public SentryReplayOptions getSessionReplay() { + return sessionReplay; + } + + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; + } +} diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index 07dde3cb807..750017d00dd 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -29,8 +29,8 @@ public final class Hint { private final @NotNull List attachments = new ArrayList<>(); private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; - private @Nullable Attachment threadDump = null; + private @Nullable ReplayRecording replayRecording = null; public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @NotNull final Hint hint = new Hint(); @@ -136,6 +136,15 @@ public void setThreadDump(final @Nullable Attachment threadDump) { return threadDump; } + @Nullable + public ReplayRecording getReplayRecording() { + return replayRecording; + } + + public void setReplayRecording(final @Nullable ReplayRecording replayRecording) { + this.replayRecording = replayRecording; + } + private boolean isCastablePrimitive(@Nullable Object hintValue, @NotNull Class clazz) { Class nonPrimitiveClass = PRIMITIVE_MAPPINGS.get(clazz.getCanonicalName()); return hintValue != null diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index b993b84ebc9..f5441c63dfd 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -949,6 +949,28 @@ private IScope buildLocalScope( return sentryId; } + @Override + public @NotNull SentryId captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + SentryId sentryId = SentryId.EMPTY_ID; + if (!isEnabled()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Instance is disabled and this 'captureReplay' call is a no-op."); + } else { + try { + final @NotNull StackItem item = stack.peek(); + sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), hint); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error while capturing replay", e); + } + } + this.lastEventId = sentryId; + return sentryId; + } + @ApiStatus.Internal @Override public @Nullable RateLimiter getRateLimiter() { diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index b31d8531922..d5adc4da806 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -268,6 +268,12 @@ public void reportFullyDisplayed() { return Sentry.captureCheckIn(checkIn); } + @Override + public @NotNull SentryId captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + return Sentry.getCurrentHub().captureReplay(replay, hint); + } + @ApiStatus.Internal @Override public @Nullable RateLimiter getRateLimiter() { diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index 684d8ec5285..6ae5a00925c 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -580,6 +580,9 @@ TransactionContext continueTrace( @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn); + @NotNull + SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint); + @ApiStatus.Internal @Nullable RateLimiter getRateLimiter(); diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 3064df8f79a..a8acb4277fe 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.List; @@ -84,6 +85,23 @@ public interface IScope { @ApiStatus.Internal void setScreen(final @Nullable String screen); + /** + * Returns the Scope's current replay_id, previously set by {@link IScope#setReplayId(SentryId)} + * + * @return the id of the current session replay + */ + @ApiStatus.Internal + @NotNull + SentryId getReplayId(); + + /** + * Sets the Scope's current replay_id + * + * @param replayId the id of the current session replay + */ + @ApiStatus.Internal + void setReplayId(final @NotNull SentryId replayId); + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 8685e1db2ea..8d1815b4c8e 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -154,6 +154,10 @@ public interface ISentryClient { return captureException(throwable, scope, null); } + @NotNull + SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint); + /** * Captures a manually created user feedback and sends it to Sentry. * diff --git a/sentry/src/main/java/io/sentry/JsonDeserializer.java b/sentry/src/main/java/io/sentry/JsonDeserializer.java index 7e62814fe64..390328231b6 100644 --- a/sentry/src/main/java/io/sentry/JsonDeserializer.java +++ b/sentry/src/main/java/io/sentry/JsonDeserializer.java @@ -6,5 +6,5 @@ @ApiStatus.Internal public interface JsonDeserializer { @NotNull - T deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception; + T deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception; } diff --git a/sentry/src/main/java/io/sentry/JsonObjectReader.java b/sentry/src/main/java/io/sentry/JsonObjectReader.java index 533d8cffb6d..f9fe1841847 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectReader.java +++ b/sentry/src/main/java/io/sentry/JsonObjectReader.java @@ -15,64 +15,74 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class JsonObjectReader extends JsonReader { +public final class JsonObjectReader implements ObjectReader { + + private final @NotNull JsonReader jsonReader; public JsonObjectReader(Reader in) { - super(in); + this.jsonReader = new JsonReader(in); } + @Override public @Nullable String nextStringOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextString(); + return jsonReader.nextString(); } + @Override public @Nullable Double nextDoubleOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextDouble(); + return jsonReader.nextDouble(); } + @Override public @Nullable Float nextFloatOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } return nextFloat(); } - public @NotNull Float nextFloat() throws IOException { - return (float) nextDouble(); + @Override + public float nextFloat() throws IOException { + return (float) jsonReader.nextDouble(); } + @Override public @Nullable Long nextLongOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextLong(); + return jsonReader.nextLong(); } + @Override public @Nullable Integer nextIntegerOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextInt(); + return jsonReader.nextInt(); } + @Override public @Nullable Boolean nextBooleanOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextBoolean(); + return jsonReader.nextBoolean(); } + @Override public void nextUnknown(ILogger logger, Map unknown, String name) { try { unknown.put(name, nextObjectOrNull()); @@ -81,50 +91,53 @@ public void nextUnknown(ILogger logger, Map unknown, String name } } + @Override public @Nullable List nextListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - beginArray(); + jsonReader.beginArray(); List list = new ArrayList<>(); - if (hasNext()) { + if (jsonReader.hasNext()) { do { try { list.add(deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); } - } while (peek() == JsonToken.BEGIN_OBJECT); + } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT); } - endArray(); + jsonReader.endArray(); return list; } + @Override public @Nullable Map nextMapOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - beginObject(); + jsonReader.beginObject(); Map map = new HashMap<>(); - if (hasNext()) { + if (jsonReader.hasNext()) { do { try { - String key = nextName(); + String key = jsonReader.nextName(); map.put(key, deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); } - } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT || jsonReader.peek() == JsonToken.NAME); } - endObject(); + jsonReader.endObject(); return map; } + @Override public @Nullable Map> nextMapOfListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { @@ -149,46 +162,33 @@ public void nextUnknown(ILogger logger, Map unknown, String name return result; } + @Override public @Nullable T nextOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws Exception { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } return deserializer.deserialize(this, logger); } + @Override public @Nullable Date nextDateOrNull(ILogger logger) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return JsonObjectReader.dateOrNull(nextString(), logger); - } - - public static @Nullable Date dateOrNull(@Nullable String dateString, ILogger logger) { - if (dateString == null) { - return null; - } - try { - return DateUtils.getDateTime(dateString); - } catch (Exception ignored) { - try { - return DateUtils.getDateTimeWithMillisPrecision(dateString); - } catch (Exception e) { - logger.log(SentryLevel.ERROR, "Error when deserializing millis timestamp format.", e); - } - } - return null; + return ObjectReader.dateOrNull(jsonReader.nextString(), logger); } + @Override public @Nullable TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } try { - return TimeZone.getTimeZone(nextString()); + return TimeZone.getTimeZone(jsonReader.nextString()); } catch (Exception e) { logger.log(SentryLevel.ERROR, "Error when deserializing TimeZone", e); } @@ -201,7 +201,88 @@ public void nextUnknown(ILogger logger, Map unknown, String name * * @return The deserialized object from json. */ + @Override public @Nullable Object nextObjectOrNull() throws IOException { return new JsonObjectDeserializer().deserialize(this); } + + @Override + public @NotNull JsonToken peek() throws IOException { + return jsonReader.peek(); + } + + @Override + public @NotNull String nextName() throws IOException { + return jsonReader.nextName(); + } + + @Override + public void beginObject() throws IOException { + jsonReader.beginObject(); + } + + @Override + public void endObject() throws IOException { + jsonReader.endObject(); + } + + @Override + public void beginArray() throws IOException { + jsonReader.beginArray(); + } + + @Override + public void endArray() throws IOException { + jsonReader.endArray(); + } + + @Override + public boolean hasNext() throws IOException { + return jsonReader.hasNext(); + } + + @Override + public int nextInt() throws IOException { + return jsonReader.nextInt(); + } + + @Override + public long nextLong() throws IOException { + return jsonReader.nextLong(); + } + + @Override + public String nextString() throws IOException { + return jsonReader.nextString(); + } + + @Override + public boolean nextBoolean() throws IOException { + return jsonReader.nextBoolean(); + } + + @Override + public double nextDouble() throws IOException { + return jsonReader.nextDouble(); + } + + @Override + public void nextNull() throws IOException { + jsonReader.nextNull(); + } + + @Override + public void setLenient(boolean lenient) { + jsonReader.setLenient(lenient); + } + + @Override + public void skipValue() throws IOException { + jsonReader.skipValue(); + } + + @Override + public void close() throws IOException { + jsonReader.close(); + } } diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index b174ddb4847..f1e84e6d5a0 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,6 +52,12 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + jsonWriter.jsonValue(value); + return this; + } + @Override public JsonObjectWriter nullValue() throws IOException { jsonWriter.nullValue(); @@ -103,6 +109,11 @@ public JsonObjectWriter value(final @NotNull ILogger logger, final @Nullable Obj return this; } + @Override + public void setLenient(final boolean lenient) { + jsonWriter.setLenient(lenient); + } + public void setIndent(final @NotNull String indent) { jsonWriter.setIndent(indent); } diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 022a3d20448..a70d96123ab 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -30,6 +30,9 @@ import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; import io.sentry.protocol.ViewHierarchyNode; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebMetaEvent; +import io.sentry.rrweb.RRWebVideoEvent; import io.sentry.util.Objects; import java.io.BufferedOutputStream; import java.io.BufferedWriter; @@ -91,6 +94,10 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put( ProfileMeasurementValue.class, new ProfileMeasurementValue.Deserializer()); deserializersByClass.put(Request.class, new Request.Deserializer()); + deserializersByClass.put(ReplayRecording.class, new ReplayRecording.Deserializer()); + deserializersByClass.put(RRWebEventType.class, new RRWebEventType.Deserializer()); + deserializersByClass.put(RRWebMetaEvent.class, new RRWebMetaEvent.Deserializer()); + deserializersByClass.put(RRWebVideoEvent.class, new RRWebVideoEvent.Deserializer()); deserializersByClass.put(SdkInfo.class, new SdkInfo.Deserializer()); deserializersByClass.put(SdkVersion.class, new SdkVersion.Deserializer()); deserializersByClass.put(SentryEnvelopeHeader.class, new SentryEnvelopeHeader.Deserializer()); @@ -103,6 +110,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(SentryLockReason.class, new SentryLockReason.Deserializer()); deserializersByClass.put(SentryPackage.class, new SentryPackage.Deserializer()); deserializersByClass.put(SentryRuntime.class, new SentryRuntime.Deserializer()); + deserializersByClass.put(SentryReplayEvent.class, new SentryReplayEvent.Deserializer()); deserializersByClass.put(SentrySpan.class, new SentrySpan.Deserializer()); deserializersByClass.put(SentryStackFrame.class, new SentryStackFrame.Deserializer()); deserializersByClass.put(SentryStackTrace.class, new SentryStackTrace.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index abbf21c84e5..d6445e3a56d 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -149,6 +149,20 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { return transaction; } + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + setCommons(event); + // TODO: maybe later it's needed to deobfuscate something (e.g. view hierarchy), for now the + // TODO: protocol does not support it + // setDebugMeta(event); + + if (shouldApplyScopeData(event, hint)) { + processNonCachedEvent(event); + } + return event; + } + private void setCommons(final @NotNull SentryBaseEvent event) { setPlatform(event); } diff --git a/sentry/src/main/java/io/sentry/MonitorConfig.java b/sentry/src/main/java/io/sentry/MonitorConfig.java index d954a504660..07e76d856d0 100644 --- a/sentry/src/main/java/io/sentry/MonitorConfig.java +++ b/sentry/src/main/java/io/sentry/MonitorConfig.java @@ -138,8 +138,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull MonitorConfig deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull MonitorConfig deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { MonitorSchedule schedule = null; Long checkinMargin = null; Long maxRuntime = null; diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 3a15aa4113c..00ccb680fc3 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -66,7 +66,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull MonitorSchedule deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { String type = null; String value = null; String unit = null; diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index e51cea8d2da..88488fbda01 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -225,6 +225,11 @@ public void reportFullyDisplayed() {} return SentryId.EMPTY_ID; } + @Override + public @NotNull SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java b/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java new file mode 100644 index 00000000000..d71a57e440f --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java @@ -0,0 +1,21 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpReplayBreadcrumbConverter implements ReplayBreadcrumbConverter { + + private static final NoOpReplayBreadcrumbConverter instance = new NoOpReplayBreadcrumbConverter(); + + public static NoOpReplayBreadcrumbConverter getInstance() { + return instance; + } + + private NoOpReplayBreadcrumbConverter() {} + + @Override + public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) { + return null; + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java new file mode 100644 index 00000000000..d365f650ea6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -0,0 +1,53 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpReplayController implements ReplayController { + + private static final NoOpReplayController instance = new NoOpReplayController(); + + public static NoOpReplayController getInstance() { + return instance; + } + + private NoOpReplayController() {} + + @Override + public void start() {} + + @Override + public void stop() {} + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public boolean isRecording() { + return false; + } + + @Override + public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} + + @Override + public void sendReplay( + @Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint) {} + + @Override + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; + } + + @Override + public void setBreadcrumbConverter(@NotNull ReplayBreadcrumbConverter converter) {} + + @Override + public @NotNull ReplayBreadcrumbConverter getBreadcrumbConverter() { + return NoOpReplayBreadcrumbConverter.getInstance(); + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index ed787b00290..3a554476ae0 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.ArrayDeque; import java.util.ArrayList; @@ -68,6 +69,14 @@ public void setUser(@Nullable User user) {} @Override public void setScreen(@Nullable String screen) {} + @Override + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; + } + + @Override + public void setReplayId(@Nullable SentryId replayId) {} + @Override public @Nullable Request getRequest() { return null; diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 3ae70b4bf5d..f00f309544a 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -66,6 +66,12 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint return SentryId.EMPTY_ID; } + @Override + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java new file mode 100644 index 00000000000..6ea43926b03 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -0,0 +1,105 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface ObjectReader extends Closeable { + static @Nullable Date dateOrNull( + final @Nullable String dateString, final @NotNull ILogger logger) { + if (dateString == null) { + return null; + } + try { + return DateUtils.getDateTime(dateString); + } catch (Exception ignored) { + try { + return DateUtils.getDateTimeWithMillisPrecision(dateString); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error when deserializing millis timestamp format.", e); + } + } + return null; + } + + void nextUnknown(ILogger logger, Map unknown, String name); + + @Nullable List nextListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable Map nextMapOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable T nextOrNull(@NotNull ILogger logger, @NotNull JsonDeserializer deserializer) + throws Exception; + + @Nullable + Date nextDateOrNull(ILogger logger) throws IOException; + + @Nullable + TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException; + + @Nullable + Object nextObjectOrNull() throws IOException; + + @NotNull + JsonToken peek() throws IOException; + + @NotNull + String nextName() throws IOException; + + void beginObject() throws IOException; + + void endObject() throws IOException; + + void beginArray() throws IOException; + + void endArray() throws IOException; + + boolean hasNext() throws IOException; + + int nextInt() throws IOException; + + @Nullable + Integer nextIntegerOrNull() throws IOException; + + long nextLong() throws IOException; + + @Nullable + Long nextLongOrNull() throws IOException; + + String nextString() throws IOException; + + @Nullable + String nextStringOrNull() throws IOException; + + boolean nextBoolean() throws IOException; + + @Nullable + Boolean nextBooleanOrNull() throws IOException; + + double nextDouble() throws IOException; + + @Nullable + Double nextDoubleOrNull() throws IOException; + + float nextFloat() throws IOException; + + @Nullable + Float nextFloatOrNull() throws IOException; + + void nextNull() throws IOException; + + void setLenient(boolean lenient); + + void skipValue() throws IOException; +} diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index ea8d4e83eac..91e64a0c8b5 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -17,6 +17,8 @@ public interface ObjectWriter { ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; + ObjectWriter nullValue() throws IOException; ObjectWriter value(final boolean value) throws IOException; @@ -31,4 +33,6 @@ public interface ObjectWriter { ObjectWriter value(final @NotNull ILogger logger, final @Nullable Object object) throws IOException; + + void setLenient(boolean lenient); } diff --git a/sentry/src/main/java/io/sentry/ProfilingTraceData.java b/sentry/src/main/java/io/sentry/ProfilingTraceData.java index d1410245afb..17332b5931c 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTraceData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTraceData.java @@ -463,7 +463,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java index 46ba9bba444..045b859f05f 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java @@ -179,7 +179,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java b/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java new file mode 100644 index 00000000000..dadd5d9b6fd --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java @@ -0,0 +1,12 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebEvent; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public interface ReplayBreadcrumbConverter { + @Nullable + RRWebEvent convert(@NotNull Breadcrumb breadcrumb); +} diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java new file mode 100644 index 00000000000..caaa847423d --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -0,0 +1,31 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public interface ReplayController { + void start(); + + void stop(); + + void pause(); + + void resume(); + + boolean isRecording(); + + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); + + void sendReplay(@Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint); + + @NotNull + SentryId getReplayId(); + + void setBreadcrumbConverter(@NotNull ReplayBreadcrumbConverter converter); + + @NotNull + ReplayBreadcrumbConverter getBreadcrumbConverter(); +} diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java new file mode 100644 index 00000000000..ca1c676dbd7 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -0,0 +1,237 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebBreadcrumbEvent; +import io.sentry.rrweb.RRWebEvent; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent; +import io.sentry.rrweb.RRWebInteractionEvent; +import io.sentry.rrweb.RRWebInteractionMoveEvent; +import io.sentry.rrweb.RRWebMetaEvent; +import io.sentry.rrweb.RRWebSpanEvent; +import io.sentry.rrweb.RRWebVideoEvent; +import io.sentry.util.MapObjectReader; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ReplayRecording implements JsonUnknown, JsonSerializable { + + public static final class JsonKeys { + public static final String SEGMENT_ID = "segment_id"; + } + + private @Nullable Integer segmentId; + private @Nullable List payload; + private @Nullable Map unknown; + + @Nullable + public Integer getSegmentId() { + return segmentId; + } + + public void setSegmentId(final @Nullable Integer segmentId) { + this.segmentId = segmentId; + } + + @Nullable + public List getPayload() { + return payload; + } + + public void setPayload(final @Nullable List payload) { + this.payload = payload; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReplayRecording that = (ReplayRecording) o; + return Objects.equals(segmentId, that.segmentId) && Objects.equals(payload, that.payload); + } + + @Override + public int hashCode() { + return Objects.hash(segmentId, payload); + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (segmentId != null) { + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + } + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + + writer.setLenient(true); + writer.jsonValue("\n"); + if (payload != null) { + writer.value(logger, payload); + } + writer.setLenient(false); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull ReplayRecording deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + + final ReplayRecording replay = new ReplayRecording(); + + @Nullable Map unknown = null; + @Nullable Integer segmentId = null; + @Nullable List payload = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SEGMENT_ID: + segmentId = reader.nextIntegerOrNull(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + + reader.setLenient(true); + List events = (List) reader.nextObjectOrNull(); + reader.setLenient(false); + + // since we lose the type of an rrweb event at runtime, we have to recover it from a map + if (events != null) { + payload = new ArrayList<>(events.size()); + for (Object event : events) { + if (event instanceof Map) { + final Map eventMap = (Map) event; + final ObjectReader mapReader = new MapObjectReader(eventMap); + for (final Map.Entry entry : eventMap.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (key.equals(RRWebEvent.JsonKeys.TYPE)) { + final RRWebEventType type = RRWebEventType.values()[(int) value]; + switch (type) { + case IncrementalSnapshot: + @Nullable + Map incrementalData = + (Map) eventMap.get("data"); + if (incrementalData == null) { + incrementalData = Collections.emptyMap(); + } + final Integer sourceInt = + (Integer) + incrementalData.get(RRWebIncrementalSnapshotEvent.JsonKeys.SOURCE); + if (sourceInt != null) { + final RRWebIncrementalSnapshotEvent.IncrementalSource source = + RRWebIncrementalSnapshotEvent.IncrementalSource.values()[sourceInt]; + switch (source) { + case MouseInteraction: + final RRWebInteractionEvent interactionEvent = + new RRWebInteractionEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(interactionEvent); + break; + case TouchMove: + final RRWebInteractionMoveEvent interactionMoveEvent = + new RRWebInteractionMoveEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(interactionMoveEvent); + break; + default: + logger.log( + SentryLevel.DEBUG, + "Unsupported rrweb incremental snapshot type %s", + source); + break; + } + } + break; + case Meta: + final RRWebEvent metaEvent = + new RRWebMetaEvent.Deserializer().deserialize(mapReader, logger); + payload.add(metaEvent); + break; + case Custom: + @Nullable + Map customData = (Map) eventMap.get("data"); + if (customData == null) { + customData = Collections.emptyMap(); + } + final String tag = (String) customData.get(RRWebEvent.JsonKeys.TAG); + if (tag != null) { + switch (tag) { + case RRWebVideoEvent.EVENT_TAG: + final RRWebEvent videoEvent = + new RRWebVideoEvent.Deserializer().deserialize(mapReader, logger); + payload.add(videoEvent); + break; + case RRWebBreadcrumbEvent.EVENT_TAG: + final RRWebEvent breadcrumbEvent = + new RRWebBreadcrumbEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(breadcrumbEvent); + break; + case RRWebSpanEvent.EVENT_TAG: + final RRWebEvent spanEvent = + new RRWebSpanEvent.Deserializer().deserialize(mapReader, logger); + payload.add(spanEvent); + break; + default: + logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); + break; + } + } + break; + default: + logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); + break; + } + } + } + } + } + } + + replay.setSegmentId(segmentId); + replay.setPayload(payload); + replay.setUnknown(unknown); + return replay; + } + } +} diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 356ee2b57c5..be24c34dfb6 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -3,6 +3,7 @@ import io.sentry.protocol.App; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.protocol.User; import io.sentry.util.CollectionUtils; @@ -80,6 +81,9 @@ public final class Scope implements IScope { private @NotNull PropagationContext propagationContext; + /** Scope's session replay id */ + private @NotNull SentryId replayId = SentryId.EMPTY_ID; + /** * Scope's ctor * @@ -101,6 +105,7 @@ private Scope(final @NotNull Scope scope) { final User userRef = scope.user; this.user = userRef != null ? new User(userRef) : null; this.screen = scope.screen; + this.replayId = scope.replayId; final Request requestRef = scope.request; this.request = requestRef != null ? new Request(requestRef) : null; @@ -312,6 +317,18 @@ public void setScreen(final @Nullable String screen) { } } + @Override + public @NotNull SentryId getReplayId() { + return replayId; + } + + @Override + public void setReplayId(final @NotNull SentryId replayId) { + this.replayId = replayId; + + // TODO: set to contexts and notify observers to persist this as well + } + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java index d98ec2c32ff..a9828792d77 100644 --- a/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java +++ b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java @@ -151,7 +151,7 @@ public static final class Deserializer @Override public @NotNull SentryAppStartProfilingOptions deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); SentryAppStartProfilingOptions options = new SentryAppStartProfilingOptions(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryBaseEvent.java b/sentry/src/main/java/io/sentry/SentryBaseEvent.java index c247342cc28..58435194a7b 100644 --- a/sentry/src/main/java/io/sentry/SentryBaseEvent.java +++ b/sentry/src/main/java/io/sentry/SentryBaseEvent.java @@ -395,7 +395,7 @@ public static final class Deserializer { public boolean deserializeValue( @NotNull SentryBaseEvent baseEvent, @NotNull String nextName, - @NotNull JsonObjectReader reader, + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { switch (nextName) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 31d4377dbb3..8d27793e1ab 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -199,6 +199,10 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } + if (event != null) { + options.getReplayController().sendReplayForEvent(event, hint); + } + try { @Nullable TraceContext traceContext = null; if (HintUtils.hasType(hint, Backfillable.class)) { @@ -235,20 +239,93 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } // if we encountered a crash/abnormal exit finish tracing in order to persist and send - // any running transaction / profiling data + // any running transaction / profiling data. We also finish session replay, and it has priority + // over transactions as it takes longer to finalize replay than transactions, therefore + // the replay_id will be the trigger for flushing and unblocking the thread in case of a crash if (scope != null) { - final @Nullable ITransaction transaction = scope.getTransaction(); - if (transaction != null) { - if (HintUtils.hasType(hint, TransactionEnd.class)) { - final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); - if (sentrySdkHint instanceof DiskFlushNotification) { - ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); - transaction.forceFinish(SpanStatus.ABORTED, false, hint); - } else { - transaction.forceFinish(SpanStatus.ABORTED, false, null); - } + finalizeTransaction(scope, hint); + finalizeReplay(scope, hint); + } + + return sentryId; + } + + private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); + transaction.forceFinish(SpanStatus.ABORTED, false, hint); + } else { + transaction.forceFinish(SpanStatus.ABORTED, false, null); + } + } + } + } + + private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable SentryId replayId = scope.getReplayId(); + if (!SentryId.EMPTY_ID.equals(replayId)) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(replayId); + } + } + } + } + + @Override + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { + Objects.requireNonNull(event, "SessionReplay is required."); + + if (hint == null) { + hint = new Hint(); + } + + if (shouldApplyScopeData(event, hint)) { + applyScope(event, scope); + } + + options.getLogger().log(SentryLevel.DEBUG, "Capturing session replay: %s", event.getEventId()); + + SentryId sentryId = SentryId.EMPTY_ID; + if (event.getEventId() != null) { + sentryId = event.getEventId(); + } + + event = processReplayEvent(event, hint, options.getEventProcessors()); + + if (event == null) { + options.getLogger().log(SentryLevel.DEBUG, "Replay was dropped by Event processors."); + return SentryId.EMPTY_ID; + } + + try { + @Nullable TraceContext traceContext = null; + if (scope != null) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + traceContext = transaction.traceContext(); + } else { + final @NotNull PropagationContext propagationContext = + TracingUtils.maybeUpdateBaggage(scope, options); + traceContext = propagationContext.traceContext(); } } + + final SentryEnvelope envelope = buildEnvelope(event, hint.getReplayRecording(), traceContext); + + hint.clear(); + transport.send(envelope, hint); + } catch (IOException e) { + options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); + + // if there was an error capturing the event, we return an emptyId + sentryId = SentryId.EMPTY_ID; } return sentryId; @@ -460,6 +537,40 @@ private SentryTransaction processTransaction( return transaction; } + @Nullable + private SentryReplayEvent processReplayEvent( + @NotNull SentryReplayEvent replayEvent, + final @NotNull Hint hint, + final @NotNull List eventProcessors) { + for (final EventProcessor processor : eventProcessors) { + try { + replayEvent = processor.process(replayEvent, hint); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + e, + "An exception occurred while processing replay event by processor: %s", + processor.getClass().getName()); + } + + if (replayEvent == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Replay event was dropped by a processor: %s", + processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Replay); + break; + } + } + return replayEvent; + } + @Override public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { Objects.requireNonNull(userFeedback, "SentryEvent is required."); @@ -513,6 +624,24 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } + private @NotNull SentryEnvelope buildEnvelope( + final @NotNull SentryReplayEvent event, + final @Nullable ReplayRecording replayRecording, + final @Nullable TraceContext traceContext) { + final List envelopeItems = new ArrayList<>(); + + final SentryEnvelopeItem replayItem = + SentryEnvelopeItem.fromReplay( + options.getSerializer(), options.getLogger(), event, replayRecording); + envelopeItems.add(replayItem); + final SentryId sentryId = event.getEventId(); + + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(sentryId, options.getSdkVersion(), traceContext); + + return new SentryEnvelope(envelopeHeader, envelopeItems); + } + /** * Updates the session data based on the event, hint and scope data * @@ -867,6 +996,47 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return checkIn; } + private @NotNull SentryReplayEvent applyScope( + final @NotNull SentryReplayEvent replayEvent, final @Nullable IScope scope) { + // no breadcrumbs and extras for replay events + if (scope != null) { + if (replayEvent.getRequest() == null) { + replayEvent.setRequest(scope.getRequest()); + } + if (replayEvent.getUser() == null) { + replayEvent.setUser(scope.getUser()); + } + if (replayEvent.getTags() == null) { + replayEvent.setTags(new HashMap<>(scope.getTags())); + } else { + for (Map.Entry item : scope.getTags().entrySet()) { + if (!replayEvent.getTags().containsKey(item.getKey())) { + replayEvent.getTags().put(item.getKey(), item.getValue()); + } + } + } + final Contexts contexts = replayEvent.getContexts(); + for (Map.Entry entry : new Contexts(scope.getContexts()).entrySet()) { + if (!contexts.containsKey(entry.getKey())) { + contexts.put(entry.getKey(), entry.getValue()); + } + } + + // Set trace data from active span to connect replays with transactions + final ISpan span = scope.getSpan(); + if (replayEvent.getContexts().getTrace() == null) { + if (span == null) { + replayEvent + .getContexts() + .setTrace(TransactionContext.fromPropagationContext(scope.getPropagationContext())); + } else { + replayEvent.getContexts().setTrace(span.getSpanContext()); + } + } + } + return replayEvent; + } + private @NotNull T applyScope( final @NotNull T sentryBaseEvent, final @Nullable IScope scope) { if (scope != null) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java index ceb7e7bdd55..3e9525d3072 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java @@ -117,7 +117,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryEnvelopeHeader deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); SentryId eventId = null; diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 45efecfc501..856976b5891 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -21,7 +21,11 @@ import java.io.OutputStreamWriter; import java.io.Reader; import java.io.Writer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.Charset; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.Callable; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -103,8 +107,7 @@ public final class SentryEnvelopeItem { } public static @NotNull SentryEnvelopeItem fromEvent( - final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event) - throws IOException { + final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event) { Objects.requireNonNull(serializer, "ISerializer is required."); Objects.requireNonNull(event, "SentryEvent is required."); @@ -365,6 +368,67 @@ public ClientReport getClientReport(final @NotNull ISerializer serializer) throw } } + public static SentryEnvelopeItem fromReplay( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull SentryReplayEvent replayEvent, + final @Nullable ReplayRecording replayRecording) { + + final File replayVideo = replayEvent.getVideoFile(); + + final CachedItem cachedItem = + new CachedItem( + () -> { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + // relay expects the payload to be in this exact order: [event,rrweb,video] + final Map replayPayload = new LinkedHashMap<>(); + // first serialize replay event json bytes + serializer.serialize(replayEvent, writer); + replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); + stream.reset(); + + // next serialize replay recording + if (replayRecording != null) { + serializer.serialize(replayRecording, writer); + replayPayload.put( + SentryItemType.ReplayRecording.getItemType(), stream.toByteArray()); + stream.reset(); + } + + // next serialize replay video bytes from given file + if (replayVideo != null && replayVideo.exists()) { + final byte[] videoBytes = + readBytesFromFile( + replayVideo.getPath(), SentryReplayEvent.REPLAY_VIDEO_MAX_SIZE); + if (videoBytes.length > 0) { + replayPayload.put(SentryItemType.ReplayVideo.getItemType(), videoBytes); + } + } + + return serializeToMsgpack(replayPayload); + } + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); + return null; + } finally { + if (replayVideo != null) { + replayVideo.delete(); + } + } + }); + + final SentryEnvelopeItemHeader itemHeader = + new SentryEnvelopeItemHeader( + SentryItemType.ReplayVideo, () -> cachedItem.getBytes().length, null, null); + + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); + } + private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; @@ -384,4 +448,35 @@ public CachedItem(final @Nullable Callable dataFactory) { return bytes != null ? bytes : new byte[] {}; } } + + @SuppressWarnings({"UnnecessaryParentheses"}) + private static byte[] serializeToMsgpack(final @NotNull Map map) + throws IOException { + try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + + // Write map header + baos.write((byte) (0x80 | map.size())); + + // Iterate over the map and serialize each key-value pair + for (final Map.Entry entry : map.entrySet()) { + // Pack the key as a string + final byte[] keyBytes = entry.getKey().getBytes(UTF_8); + final int keyLength = keyBytes.length; + // string up to 255 chars + baos.write((byte) (0xd9)); + baos.write((byte) (keyLength)); + baos.write(keyBytes); + + // Pack the value as a binary string + final byte[] valueBytes = entry.getValue(); + final int valueLength = valueBytes.length; + // We will always use the 4 bytes data length for simplicity. + baos.write((byte) (0xc6)); + baos.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(valueLength).array()); + baos.write(valueBytes); + } + + return baos.toByteArray(); + } + } } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java index 1ca9a1c8c2f..6903d9b1bb9 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java @@ -130,7 +130,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryEnvelopeItemHeader deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String contentType = null; diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 5bd1cf3877f..d370458acbf 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -311,8 +311,8 @@ public static final class Deserializer implements JsonDeserializer @SuppressWarnings("unchecked") @Override - public @NotNull SentryEvent deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryEvent deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryEvent event = new SentryEvent(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index db299a12da6..f37b972454f 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -18,6 +18,7 @@ public enum SentryItemType implements JsonSerializable { ClientReport("client_report"), ReplayEvent("replay_event"), ReplayRecording("replay_recording"), + ReplayVideo("replay_video"), CheckIn("check_in"), Statsd("statsd"), Unknown("__unknown__"); // DataCategory.Unknown @@ -65,7 +66,7 @@ static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryItemType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return SentryItemType.valueOfLabel(reader.nextString().toLowerCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/SentryLevel.java b/sentry/src/main/java/io/sentry/SentryLevel.java index ac179c9831b..76b07c6b378 100644 --- a/sentry/src/main/java/io/sentry/SentryLevel.java +++ b/sentry/src/main/java/io/sentry/SentryLevel.java @@ -18,11 +18,11 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.value(name().toLowerCase(Locale.ROOT)); } - static final class Deserializer implements JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryLevel deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryLevel deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return SentryLevel.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/SentryLockReason.java b/sentry/src/main/java/io/sentry/SentryLockReason.java index f376317f6b5..bd04f48ab0c 100644 --- a/sentry/src/main/java/io/sentry/SentryLockReason.java +++ b/sentry/src/main/java/io/sentry/SentryLockReason.java @@ -147,7 +147,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index fe0dad01448..3ff84c48de2 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -479,6 +479,16 @@ public class SentryOptions { @ApiStatus.Experimental private @Nullable Cron cron = null; + private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); + + private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); + + /** + * Controls whether to enable screen tracking. When enabled, the SDK will automatically capture + * screen transitions as context for events. + */ + @ApiStatus.Experimental private boolean enableScreenTracking = true; + /** * Adds an event processor * @@ -2385,6 +2395,30 @@ public void setCron(@Nullable Cron cron) { this.cron = cron; } + @NotNull + public ExperimentalOptions getExperimental() { + return experimental; + } + + public @NotNull ReplayController getReplayController() { + return replayController; + } + + public void setReplayController(final @Nullable ReplayController replayController) { + this.replayController = + replayController != null ? replayController : NoOpReplayController.getInstance(); + } + + @ApiStatus.Experimental + public boolean isEnableScreenTracking() { + return enableScreenTracking; + } + + @ApiStatus.Experimental + public void setEnableScreenTracking(final boolean enableScreenTracking) { + this.enableScreenTracking = enableScreenTracking; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java new file mode 100644 index 00000000000..95623d2ff62 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -0,0 +1,319 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryReplayEvent extends SentryBaseEvent + implements JsonUnknown, JsonSerializable { + + public enum ReplayType implements JsonSerializable { + SESSION, + BUFFER; + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.value(name().toLowerCase(Locale.ROOT)); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull ReplayType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); + } + } + } + + public static final long REPLAY_VIDEO_MAX_SIZE = 10 * 1024 * 1024; + public static final String REPLAY_EVENT_TYPE = "replay_event"; + + private @Nullable File videoFile; + private @NotNull String type; + private @NotNull ReplayType replayType; + private @Nullable SentryId replayId; + private int segmentId; + private @NotNull Date timestamp; + private @Nullable Date replayStartTimestamp; + private @Nullable List urls; + private @Nullable List errorIds; + private @Nullable List traceIds; + private @Nullable Map unknown; + + public SentryReplayEvent() { + super(); + this.replayId = new SentryId(); + this.type = REPLAY_EVENT_TYPE; + this.replayType = ReplayType.SESSION; + this.errorIds = new ArrayList<>(); + this.traceIds = new ArrayList<>(); + this.urls = new ArrayList<>(); + timestamp = DateUtils.getCurrentDateTime(); + } + + @Nullable + public File getVideoFile() { + return videoFile; + } + + public void setVideoFile(final @Nullable File videoFile) { + this.videoFile = videoFile; + } + + @NotNull + public String getType() { + return type; + } + + public void setType(final @NotNull String type) { + this.type = type; + } + + @Nullable + public SentryId getReplayId() { + return replayId; + } + + public void setReplayId(final @Nullable SentryId replayId) { + this.replayId = replayId; + } + + public int getSegmentId() { + return segmentId; + } + + public void setSegmentId(final int segmentId) { + this.segmentId = segmentId; + } + + @NotNull + public Date getTimestamp() { + return timestamp; + } + + public void setTimestamp(final @NotNull Date timestamp) { + this.timestamp = timestamp; + } + + @Nullable + public Date getReplayStartTimestamp() { + return replayStartTimestamp; + } + + public void setReplayStartTimestamp(final @Nullable Date replayStartTimestamp) { + this.replayStartTimestamp = replayStartTimestamp; + } + + @Nullable + public List getUrls() { + return urls; + } + + public void setUrls(final @Nullable List urls) { + this.urls = urls; + } + + @Nullable + public List getErrorIds() { + return errorIds; + } + + public void setErrorIds(final @Nullable List errorIds) { + this.errorIds = errorIds; + } + + @Nullable + public List getTraceIds() { + return traceIds; + } + + public void setTraceIds(final @Nullable List traceIds) { + this.traceIds = traceIds; + } + + @NotNull + public ReplayType getReplayType() { + return replayType; + } + + public void setReplayType(final @NotNull ReplayType replayType) { + this.replayType = replayType; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SentryReplayEvent that = (SentryReplayEvent) o; + return segmentId == that.segmentId + && Objects.equals(type, that.type) + && replayType == that.replayType + && Objects.equals(replayId, that.replayId) + && Objects.equals(urls, that.urls) + && Objects.equals(errorIds, that.errorIds) + && Objects.equals(traceIds, that.traceIds); + } + + @Override + public int hashCode() { + return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds); + } + + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String REPLAY_TYPE = "replay_type"; + public static final String REPLAY_ID = "replay_id"; + public static final String SEGMENT_ID = "segment_id"; + public static final String TIMESTAMP = "timestamp"; + public static final String REPLAY_START_TIMESTAMP = "replay_start_timestamp"; + public static final String URLS = "urls"; + public static final String ERROR_IDS = "error_ids"; + public static final String TRACE_IDS = "trace_ids"; + } + + @Override + @SuppressWarnings("JdkObsolete") + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.TYPE).value(type); + writer.name(JsonKeys.REPLAY_TYPE).value(logger, replayType); + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + if (replayId != null) { + writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); + } + if (replayStartTimestamp != null) { + writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); + } + if (urls != null) { + writer.name(JsonKeys.URLS).value(logger, urls); + } + if (errorIds != null) { + writer.name(JsonKeys.ERROR_IDS).value(logger, errorIds); + } + if (traceIds != null) { + writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds); + } + + new SentryBaseEvent.Serializer().serialize(this, writer, logger); + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull SentryReplayEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + + final SentryBaseEvent.Deserializer baseEventDeserializer = new SentryBaseEvent.Deserializer(); + + final SentryReplayEvent replay = new SentryReplayEvent(); + + @Nullable Map unknown = null; + @Nullable String type = null; + @Nullable ReplayType replayType = null; + @Nullable SentryId replayId = null; + @Nullable Integer segmentId = null; + @Nullable Date timestamp = null; + @Nullable Date replayStartTimestamp = null; + @Nullable List urls = null; + @Nullable List errorIds = null; + @Nullable List traceIds = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + type = reader.nextStringOrNull(); + break; + case JsonKeys.REPLAY_TYPE: + replayType = reader.nextOrNull(logger, new ReplayType.Deserializer()); + break; + case JsonKeys.REPLAY_ID: + replayId = reader.nextOrNull(logger, new SentryId.Deserializer()); + break; + case JsonKeys.SEGMENT_ID: + segmentId = reader.nextIntegerOrNull(); + break; + case JsonKeys.TIMESTAMP: + timestamp = reader.nextDateOrNull(logger); + break; + case JsonKeys.REPLAY_START_TIMESTAMP: + replayStartTimestamp = reader.nextDateOrNull(logger); + break; + case JsonKeys.URLS: + urls = (List) reader.nextObjectOrNull(); + break; + case JsonKeys.ERROR_IDS: + errorIds = (List) reader.nextObjectOrNull(); + break; + case JsonKeys.TRACE_IDS: + traceIds = (List) reader.nextObjectOrNull(); + break; + default: + if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + reader.endObject(); + + if (type != null) { + replay.setType(type); + } + if (replayType != null) { + replay.setReplayType(replayType); + } + if (segmentId != null) { + replay.setSegmentId(segmentId); + } + if (timestamp != null) { + replay.setTimestamp(timestamp); + } + replay.setReplayId(replayId); + replay.setReplayStartTimestamp(replayStartTimestamp); + replay.setUrls(urls); + replay.setErrorIds(errorIds); + replay.setTraceIds(traceIds); + replay.setUnknown(unknown); + return replay; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java new file mode 100644 index 00000000000..54cabeaef6b --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -0,0 +1,178 @@ +package io.sentry; + +import io.sentry.util.SampleRateUtils; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryReplayOptions { + + public enum SentryReplayQuality { + /** Video Scale: 80% Bit Rate: 50.000 */ + LOW(0.8f, 50_000), + + /** Video Scale: 100% Bit Rate: 75.000 */ + MEDIUM(1.0f, 75_000), + + /** Video Scale: 100% Bit Rate: 100.000 */ + HIGH(1.0f, 100_000); + + /** The scale related to the window size (in dp) at which the replay will be created. */ + public final float sizeScale; + + /** + * Defines the quality of the session replay. Higher bit rates have better replay quality, but + * also affect the final payload size to transfer, defaults to 40kbps. + */ + public final int bitRate; + + SentryReplayQuality(final float sizeScale, final int bitRate) { + this.sizeScale = sizeScale; + this.bitRate = bitRate; + } + } + + /** + * Indicates the percentage in which the replay for the session will be created. Specifying 0 + * means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0 The default is null + * (disabled). + */ + private @Nullable Double sessionSampleRate; + + /** + * Indicates the percentage in which a 30 seconds replay will be send with error events. + * Specifying 0 means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0. The + * default is null (disabled). + */ + private @Nullable Double errorSampleRate; + + /** + * Redact all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are redacted. + * + *

Default is enabled. + */ + private boolean redactAllText = true; + + /** + * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * By default only views extending ImageView with BitmapDrawable or custom Drawable type are + * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * from the apk. + * + *

Default is enabled. + */ + private boolean redactAllImages = true; + + /** + * Defines the quality of the session replay. The higher the quality, the more accurate the replay + * will be, but also more data to transfer and more CPU load, defaults to MEDIUM. + */ + private SentryReplayQuality quality = SentryReplayQuality.MEDIUM; + + /** + * Number of frames per second of the replay. The bigger the number, the more accurate the replay + * will be, but also more data to transfer and more CPU load, defaults to 1fps. + */ + private int frameRate = 1; + + /** The maximum duration of replays for error events, defaults to 30s. */ + private long errorReplayDuration = 30_000L; + + /** The maximum duration of the segment of a session replay, defaults to 5s. */ + private long sessionSegmentDuration = 5000L; + + /** The maximum duration of a full session replay, defaults to 1h. */ + private long sessionDuration = 60 * 60 * 1000L; + + public SentryReplayOptions() {} + + public SentryReplayOptions( + final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { + this.sessionSampleRate = sessionSampleRate; + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getErrorSampleRate() { + return errorSampleRate; + } + + public boolean isSessionReplayEnabled() { + return (getSessionSampleRate() != null && getSessionSampleRate() > 0); + } + + public void setErrorSampleRate(final @Nullable Double errorSampleRate) { + if (!SampleRateUtils.isValidSampleRate(errorSampleRate)) { + throw new IllegalArgumentException( + "The value " + + errorSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getSessionSampleRate() { + return sessionSampleRate; + } + + public boolean isSessionReplayForErrorsEnabled() { + return (getErrorSampleRate() != null && getErrorSampleRate() > 0); + } + + public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { + if (!SampleRateUtils.isValidSampleRate(sessionSampleRate)) { + throw new IllegalArgumentException( + "The value " + + sessionSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } + this.sessionSampleRate = sessionSampleRate; + } + + public boolean getRedactAllText() { + return redactAllText; + } + + public void setRedactAllText(final boolean redactAllText) { + this.redactAllText = redactAllText; + } + + public boolean getRedactAllImages() { + return redactAllImages; + } + + public void setRedactAllImages(final boolean redactAllImages) { + this.redactAllImages = redactAllImages; + } + + @ApiStatus.Internal + public @NotNull SentryReplayQuality getQuality() { + return quality; + } + + public void setQuality(final @NotNull SentryReplayQuality quality) { + this.quality = quality; + } + + @ApiStatus.Internal + public int getFrameRate() { + return frameRate; + } + + @ApiStatus.Internal + public long getErrorReplayDuration() { + return errorReplayDuration; + } + + @ApiStatus.Internal + public long getSessionSegmentDuration() { + return sessionSegmentDuration; + } + + @ApiStatus.Internal + public long getSessionDuration() { + return sessionDuration; + } +} diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8086acd02e0..99418d5c8bc 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -593,12 +593,18 @@ private void updateBaggageValues() { synchronized (this) { if (baggage.isMutable()) { final AtomicReference userAtomicReference = new AtomicReference<>(); + final AtomicReference replayId = new AtomicReference<>(); hub.configureScope( scope -> { userAtomicReference.set(scope.getUser()); + replayId.set(scope.getReplayId()); }); baggage.setValuesFromTransaction( - this, userAtomicReference.get(), hub.getOptions(), this.getSamplingDecision()); + this, + userAtomicReference.get(), + replayId.get(), + hub.getOptions(), + this.getSamplingDecision()); baggage.freeze(); } } diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 500da919fe2..482b055b676 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -426,7 +426,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Session deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Session deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index be428708cb1..5a43ff845e0 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -292,8 +292,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SpanContext deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SpanContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryId traceId = null; SpanId spanId = null; diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index f8fb82c3c86..ffe2414af39 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -23,4 +23,6 @@ public interface SpanDataConvention { String FRAMES_DELAY = "frames.delay"; String CONTRIBUTES_TTID = "ui.contributes_to_ttid"; String CONTRIBUTES_TTFD = "ui.contributes_to_ttfd"; + String HTTP_START_TIMESTAMP = "http.start_timestamp"; + String HTTP_END_TIMESTAMP = "http.end_timestamp"; } diff --git a/sentry/src/main/java/io/sentry/SpanId.java b/sentry/src/main/java/io/sentry/SpanId.java index 7e221775ced..70608fb7cbb 100644 --- a/sentry/src/main/java/io/sentry/SpanId.java +++ b/sentry/src/main/java/io/sentry/SpanId.java @@ -53,7 +53,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SpanId deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SpanId deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return new SpanId(reader.nextString()); } diff --git a/sentry/src/main/java/io/sentry/SpanStatus.java b/sentry/src/main/java/io/sentry/SpanStatus.java index b0b1bf78c8c..5185d27e058 100644 --- a/sentry/src/main/java/io/sentry/SpanStatus.java +++ b/sentry/src/main/java/io/sentry/SpanStatus.java @@ -114,8 +114,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SpanStatus deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SpanStatus deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return SpanStatus.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index 56c9ee586f3..f3d603b7c01 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -21,12 +21,13 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { private final @Nullable String transaction; private final @Nullable String sampleRate; private final @Nullable String sampled; + private final @Nullable SentryId replayId; @SuppressWarnings("unused") private @Nullable Map unknown; TraceContext(@NotNull SentryId traceId, @NotNull String publicKey) { - this(traceId, publicKey, null, null, null, null, null, null); + this(traceId, publicKey, null, null, null, null, null, null, null); } TraceContext( @@ -37,8 +38,19 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String userId, @Nullable String transaction, @Nullable String sampleRate, - @Nullable String sampled) { - this(traceId, publicKey, release, environment, userId, null, transaction, sampleRate, sampled); + @Nullable String sampled, + @Nullable SentryId replayId) { + this( + traceId, + publicKey, + release, + environment, + userId, + null, + transaction, + sampleRate, + sampled, + replayId); } /** @@ -54,7 +66,8 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String userSegment, @Nullable String transaction, @Nullable String sampleRate, - @Nullable String sampled) { + @Nullable String sampled, + @Nullable SentryId replayId) { this.traceId = traceId; this.publicKey = publicKey; this.release = release; @@ -64,6 +77,7 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.transaction = transaction; this.sampleRate = sampleRate; this.sampled = sampled; + this.replayId = replayId; } @SuppressWarnings("UnusedMethod") @@ -116,6 +130,10 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { return sampled; } + public @Nullable SentryId getReplayId() { + return replayId; + } + /** * @deprecated only here to support parsing legacy JSON with non flattened user */ @@ -165,7 +183,7 @@ public static final class JsonKeys { public static final class Deserializer implements JsonDeserializer { @Override public @NotNull TraceContextUser deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String id = null; @@ -222,6 +240,7 @@ public static final class JsonKeys { public static final String TRANSACTION = "transaction"; public static final String SAMPLE_RATE = "sample_rate"; public static final String SAMPLED = "sampled"; + public static final String REPLAY_ID = "replay_id"; } @Override @@ -251,6 +270,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (sampled != null) { writer.name(TraceContext.JsonKeys.SAMPLED).value(sampled); } + if (replayId != null) { + writer.name(TraceContext.JsonKeys.REPLAY_ID).value(logger, replayId); + } if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -263,8 +285,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull TraceContext deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull TraceContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryId traceId = null; @@ -277,6 +299,7 @@ public static final class Deserializer implements JsonDeserializer String transaction = null; String sampleRate = null; String sampled = null; + SentryId replayId = null; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -312,6 +335,9 @@ public static final class Deserializer implements JsonDeserializer case TraceContext.JsonKeys.SAMPLED: sampled = reader.nextStringOrNull(); break; + case TraceContext.JsonKeys.REPLAY_ID: + replayId = new SentryId.Deserializer().deserialize(reader, logger); + break; default: if (unknown == null) { unknown = new ConcurrentHashMap<>(); @@ -344,7 +370,8 @@ public static final class Deserializer implements JsonDeserializer userSegment, transaction, sampleRate, - sampled); + sampled, + replayId); traceContext.setUnknown(unknown); reader.endObject(); return traceContext; diff --git a/sentry/src/main/java/io/sentry/UserFeedback.java b/sentry/src/main/java/io/sentry/UserFeedback.java index 27086188fe9..b580744ee77 100644 --- a/sentry/src/main/java/io/sentry/UserFeedback.java +++ b/sentry/src/main/java/io/sentry/UserFeedback.java @@ -174,8 +174,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull UserFeedback deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull UserFeedback deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { SentryId sentryId = null; String name = null; String email = null; diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReport.java b/sentry/src/main/java/io/sentry/clientreport/ClientReport.java index 66c3188116a..e1b8abcaea3 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReport.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReport.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -74,8 +74,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ClientReport deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ClientReport deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { Date timestamp = null; List discardedEvents = new ArrayList<>(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java b/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java index 8fb5da3165f..10b12b0fed5 100644 --- a/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java +++ b/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -93,7 +93,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull DiscardedEvent deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { String reason = null; String category = null; Long quanity = null; diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java index 94e77edbfb7..1e6ff5fb41c 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -118,7 +118,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java index 9639ba892ff..b0cebf5439d 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -92,7 +92,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index bec57d22f33..b949f93c1e6 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -273,7 +273,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull App deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull App deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); App app = new App(); diff --git a/sentry/src/main/java/io/sentry/protocol/Browser.java b/sentry/src/main/java/io/sentry/protocol/Browser.java index 99fe427c278..ed32be5ea2e 100644 --- a/sentry/src/main/java/io/sentry/protocol/Browser.java +++ b/sentry/src/main/java/io/sentry/protocol/Browser.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -102,7 +102,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Browser deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Browser deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Browser browser = new Browser(); diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 21be9fd8a58..28d2e8d2a44 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -2,8 +2,8 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SpanContext; import io.sentry.util.HintUtils; @@ -160,7 +160,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull Contexts deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { final Contexts contexts = new Contexts(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/protocol/DebugImage.java b/sentry/src/main/java/io/sentry/protocol/DebugImage.java index d26432033e4..e769e2c2ca3 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugImage.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugImage.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -314,8 +314,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull DebugImage deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull DebugImage deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { DebugImage debugImage = new DebugImage(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java index 134947507ae..458c4de6311 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -95,7 +95,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull DebugMeta deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull DebugMeta deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { DebugMeta debugMeta = new DebugMeta(); diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index 4f06f749953..25cfa41fd13 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -544,7 +544,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull DeviceOrientation deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return DeviceOrientation.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -726,7 +726,7 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Device deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Device deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Device device = new Device(); diff --git a/sentry/src/main/java/io/sentry/protocol/Geo.java b/sentry/src/main/java/io/sentry/protocol/Geo.java index fefc340e1be..c9094223abd 100644 --- a/sentry/src/main/java/io/sentry/protocol/Geo.java +++ b/sentry/src/main/java/io/sentry/protocol/Geo.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -161,7 +161,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public Geo deserialize(JsonObjectReader reader, ILogger logger) throws Exception { + public @NotNull Geo deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); final Geo geo = new Geo(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Gpu.java b/sentry/src/main/java/io/sentry/protocol/Gpu.java index 0dfe85f68f9..b4a8344e2d7 100644 --- a/sentry/src/main/java/io/sentry/protocol/Gpu.java +++ b/sentry/src/main/java/io/sentry/protocol/Gpu.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -229,7 +229,7 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Gpu deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Gpu deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Gpu gpu = new Gpu(); diff --git a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java index f7fa7277a1e..aca5b40c092 100644 --- a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java +++ b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -102,7 +102,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull MeasurementValue deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String unit = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Mechanism.java b/sentry/src/main/java/io/sentry/protocol/Mechanism.java index 648aed39c2b..fac8808f2db 100644 --- a/sentry/src/main/java/io/sentry/protocol/Mechanism.java +++ b/sentry/src/main/java/io/sentry/protocol/Mechanism.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -205,7 +205,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Mechanism deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Mechanism deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { Mechanism mechanism = new Mechanism(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Message.java b/sentry/src/main/java/io/sentry/protocol/Message.java index a1c79e21986..9aceea56a65 100644 --- a/sentry/src/main/java/io/sentry/protocol/Message.java +++ b/sentry/src/main/java/io/sentry/protocol/Message.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -131,7 +131,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Message deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Message deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Message message = new Message(); diff --git a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java index db4f0b6ba50..f4a8b6de53a 100644 --- a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java +++ b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -121,7 +121,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java index 796a4ea1a0f..ecfb59542b3 100644 --- a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java +++ b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -180,7 +180,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Request.java b/sentry/src/main/java/io/sentry/protocol/Request.java index 14f54038444..44e205a3901 100644 --- a/sentry/src/main/java/io/sentry/protocol/Request.java +++ b/sentry/src/main/java/io/sentry/protocol/Request.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -326,7 +326,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger @SuppressWarnings("unchecked") public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Request deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Request deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Request request = new Request(); diff --git a/sentry/src/main/java/io/sentry/protocol/Response.java b/sentry/src/main/java/io/sentry/protocol/Response.java index 23a16c78f8c..f1a93037109 100644 --- a/sentry/src/main/java/io/sentry/protocol/Response.java +++ b/sentry/src/main/java/io/sentry/protocol/Response.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -154,7 +154,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull Response deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { reader.beginObject(); final Response response = new Response(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SdkInfo.java b/sentry/src/main/java/io/sentry/protocol/SdkInfo.java index ee3ac1eb165..928a8b522dc 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkInfo.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkInfo.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -116,7 +116,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SdkInfo deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SdkInfo deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SdkInfo sdkInfo = new SdkInfo(); diff --git a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java index f7ba230463b..aa997910be7 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; @@ -224,8 +224,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger @SuppressWarnings("unchecked") public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SdkVersion deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SdkVersion deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { String name = null; String version = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryException.java b/sentry/src/main/java/io/sentry/protocol/SentryException.java index 5ee9464a3c4..4d56e127474 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryException.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryException.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -223,7 +223,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryId.java b/sentry/src/main/java/io/sentry/protocol/SentryId.java index c1e5ea1819e..109655fdf2b 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryId.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryId.java @@ -2,8 +2,8 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.StringUtils; import java.io.IOException; @@ -82,7 +82,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryId deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SentryId deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return new SentryId(reader.nextString()); } diff --git a/sentry/src/main/java/io/sentry/protocol/SentryPackage.java b/sentry/src/main/java/io/sentry/protocol/SentryPackage.java index cea6bb84974..aa2358d8dfb 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryPackage.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryPackage.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.util.Objects; @@ -100,8 +100,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryPackage deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryPackage deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { String name = null; String version = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java b/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java index 751e664ae64..7d2ed8fa1ef 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -110,8 +110,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryRuntime deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryRuntime deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryRuntime runtime = new SentryRuntime(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index 2be4411d446..f4c8d20efa1 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.Span; @@ -257,8 +257,8 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SentrySpan deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentrySpan deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); Double startTimestamp = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java index fcb93eb2e8f..03d64e2172f 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLockReason; import io.sentry.vendor.gson.stream.JsonToken; @@ -398,7 +398,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryStackFrame deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SentryStackFrame sentryStackFrame = new SentryStackFrame(); Map unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java index 90b42666c8f..e79e8e7ec05 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -154,7 +154,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryThread.java b/sentry/src/main/java/io/sentry/protocol/SentryThread.java index 1d57e35b10d..accb05968e1 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryThread.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryThread.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLockReason; import io.sentry.vendor.gson.stream.JsonToken; @@ -303,8 +303,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SentryThread deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryThread deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { SentryThread sentryThread = new SentryThread(); Map unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index 0ca789270e0..3bc42e42084 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryBaseEvent; import io.sentry.SentryTracer; @@ -259,7 +259,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull User deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull User deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); User user = new User(); diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java index 69e51560402..791c9bbbd69 100644 --- a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -73,8 +73,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ViewHierarchy deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ViewHierarchy deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { @Nullable String renderingSystem = null; @Nullable List windows = null; diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java index 923eb95877e..525d644fdc5 100644 --- a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -205,7 +205,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; @NotNull final ViewHierarchyNode node = new ViewHierarchyNode(); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java new file mode 100644 index 00000000000..6fb269c405c --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -0,0 +1,317 @@ +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.SentryLevel; +import io.sentry.util.CollectionUtils; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.math.BigDecimal; +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 RRWebBreadcrumbEvent extends RRWebEvent + implements JsonUnknown, JsonSerializable { + public static final String EVENT_TAG = "breadcrumb"; + + private @NotNull String tag; + private double breadcrumbTimestamp; + private @Nullable String breadcrumbType; + private @Nullable String category; + private @Nullable String message; + private @Nullable SentryLevel level; + private @Nullable Map data; + // 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 payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebBreadcrumbEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public double getBreadcrumbTimestamp() { + return breadcrumbTimestamp; + } + + public void setBreadcrumbTimestamp(final double breadcrumbTimestamp) { + this.breadcrumbTimestamp = breadcrumbTimestamp; + } + + @Nullable + public String getBreadcrumbType() { + return breadcrumbType; + } + + public void setBreadcrumbType(final @Nullable String breadcrumbType) { + this.breadcrumbType = breadcrumbType; + } + + @Nullable + public String getCategory() { + return category; + } + + public void setCategory(final @Nullable String category) { + this.category = category; + } + + @Nullable + public String getMessage() { + return message; + } + + public void setMessage(final @Nullable String message) { + this.message = message; + } + + @Nullable + public SentryLevel getLevel() { + return level; + } + + public void setLevel(final @Nullable SentryLevel level) { + this.level = level; + } + + @Nullable + public Map getData() { + return data; + } + + public void setData(final @Nullable Map data) { + this.data = data == null ? null : new ConcurrentHashMap<>(data); + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + 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 + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + public static final String TIMESTAMP = "timestamp"; + public static final String TYPE = "type"; + public static final String CATEGORY = "category"; + public static final String MESSAGE = "message"; + public static final String LEVEL = "level"; + } + + @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 (breadcrumbType != null) { + writer.name(JsonKeys.TYPE).value(breadcrumbType); + } + writer.name(JsonKeys.TIMESTAMP).value(logger, BigDecimal.valueOf(breadcrumbTimestamp)); + if (category != null) { + writer.name(JsonKeys.CATEGORY).value(category); + } + if (message != null) { + writer.name(JsonKeys.MESSAGE).value(message); + } + if (level != null) { + writer.name(JsonKeys.LEVEL).value(logger, level); + } + if (data != null) { + writer.name(JsonKeys.DATA).value(logger, data); + } + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebBreadcrumbEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebBreadcrumbEvent event = new RRWebBreadcrumbEvent(); + 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 RRWebBreadcrumbEvent 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 RRWebBreadcrumbEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + event.breadcrumbType = reader.nextStringOrNull(); + break; + case JsonKeys.TIMESTAMP: + event.breadcrumbTimestamp = reader.nextDouble(); + break; + case JsonKeys.CATEGORY: + event.category = reader.nextStringOrNull(); + break; + case JsonKeys.MESSAGE: + event.message = reader.nextStringOrNull(); + break; + case JsonKeys.LEVEL: + try { + event.level = new SentryLevel.Deserializer().deserialize(reader, logger); + } catch (Exception exception) { + logger.log(SentryLevel.DEBUG, exception, "Error when deserializing SentryLevel"); + } + break; + case JsonKeys.DATA: + Map deserializedData = + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); + if (deserializedData != null) { + event.data = deserializedData; + } + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java new file mode 100644 index 00000000000..07b2b9a70fe --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java @@ -0,0 +1,94 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public abstract class RRWebEvent { + + private @NotNull RRWebEventType type; + private long timestamp; + + protected RRWebEvent(final @NotNull RRWebEventType type) { + this.type = type; + this.timestamp = System.currentTimeMillis(); + } + + protected RRWebEvent() { + this(RRWebEventType.Custom); + } + + @NotNull + public RRWebEventType getType() { + return type; + } + + public void setType(final @NotNull RRWebEventType type) { + this.type = type; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(final long timestamp) { + this.timestamp = timestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RRWebEvent)) return false; + RRWebEvent that = (RRWebEvent) o; + return timestamp == that.timestamp && type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(type, timestamp); + } + + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String TIMESTAMP = "timestamp"; + public static final String TAG = "tag"; + } + + public static final class Serializer { + public void serialize( + final @NotNull RRWebEvent baseEvent, + final @NotNull ObjectWriter writer, + final @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.TYPE).value(logger, baseEvent.type); + writer.name(JsonKeys.TIMESTAMP).value(baseEvent.timestamp); + } + } + + public static final class Deserializer { + @SuppressWarnings("unchecked") + public boolean deserializeValue( + final @NotNull RRWebEvent baseEvent, + final @NotNull String nextName, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + switch (nextName) { + case JsonKeys.TYPE: + baseEvent.type = + Objects.requireNonNull( + reader.nextOrNull(logger, new RRWebEventType.Deserializer()), ""); + return true; + case JsonKeys.TIMESTAMP: + baseEvent.timestamp = reader.nextLong(); + return true; + } + return false; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java new file mode 100644 index 00000000000..fc9c8c7e690 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -0,0 +1,33 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public enum RRWebEventType implements JsonSerializable { + DomContentLoaded, + Load, + FullSnapshot, + IncrementalSnapshot, + Meta, + Custom, + Plugin; + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull RRWebEventType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return RRWebEventType.values()[reader.nextInt()]; + } + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java new file mode 100644 index 00000000000..aff3c55ac37 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -0,0 +1,95 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public abstract class RRWebIncrementalSnapshotEvent extends RRWebEvent { + + public enum IncrementalSource implements JsonSerializable { + Mutation, + MouseMove, + MouseInteraction, + Scroll, + ViewportResize, + Input, + TouchMove, + MediaInteraction, + StyleSheetRule, + CanvasMutation, + Font, + Log, + Drag, + StyleDeclaration, + Selection, + AdoptedStyleSheet, + CustomElement; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull IncrementalSource deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return IncrementalSource.values()[reader.nextInt()]; + } + } + } + + private IncrementalSource source; + + public RRWebIncrementalSnapshotEvent(final @NotNull IncrementalSource source) { + super(RRWebEventType.IncrementalSnapshot); + this.source = source; + } + + public IncrementalSource getSource() { + return source; + } + + public void setSource(final IncrementalSource source) { + this.source = source; + } + + // region json + public static final class JsonKeys { + public static final String SOURCE = "source"; + } + + public static final class Serializer { + public void serialize( + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull ObjectWriter writer, + final @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.SOURCE).value(logger, baseEvent.source); + } + } + + public static final class Deserializer { + public boolean deserializeValue( + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull String nextName, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + if (nextName.equals(JsonKeys.SOURCE)) { + baseEvent.source = + Objects.requireNonNull( + reader.nextOrNull(logger, new IncrementalSource.Deserializer()), ""); + return true; + } + return false; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java new file mode 100644 index 00000000000..c7bd613c1b6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -0,0 +1,268 @@ +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.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("SameNameButDifferent") +public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { + + public enum InteractionType implements JsonSerializable { + MouseUp, + MouseDown, + Click, + ContextMenu, + DblClick, + Focus, + Blur, + TouchStart, + TouchMove_Departed, + TouchEnd, + TouchCancel; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull InteractionType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return InteractionType.values()[reader.nextInt()]; + } + } + } + + private static final int POINTER_TYPE_TOUCH = 2; + + private @Nullable InteractionType interactionType; + + private int id; + + private float x; + + private float y; + + private int pointerType = POINTER_TYPE_TOUCH; + + private int pointerId; + + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebInteractionEvent() { + super(IncrementalSource.MouseInteraction); + } + + @Nullable + public InteractionType getInteractionType() { + return interactionType; + } + + public void setInteractionType(final @Nullable InteractionType type) { + this.interactionType = type; + } + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public float getX() { + return x; + } + + public void setX(final float x) { + this.x = x; + } + + public float getY() { + return y; + } + + public void setY(final float y) { + this.y = y; + } + + public int getPointerType() { + return pointerType; + } + + public void setPointerType(final int pointerType) { + this.pointerType = pointerType; + } + + public int getPointerId() { + return pointerId; + } + + public void setPointerId(final int pointerId) { + this.pointerId = pointerId; + } + + @Nullable + public 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 + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String TYPE = "type"; + public static final String ID = "id"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String POINTER_TYPE = "pointerType"; + public static final String POINTER_ID = "pointerId"; + } + + @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(); + new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.TYPE).value(logger, interactionType); + writer.name(JsonKeys.ID).value(id); + writer.name(JsonKeys.X).value(x); + writer.name(JsonKeys.Y).value(y); + writer.name(JsonKeys.POINTER_TYPE).value(pointerType); + writer.name(JsonKeys.POINTER_ID).value(pointerId); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebInteractionEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebInteractionEvent event = new RRWebInteractionEvent(); + 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 RRWebInteractionEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + event.interactionType = reader.nextOrNull(logger, new InteractionType.Deserializer()); + break; + case JsonKeys.ID: + event.id = reader.nextInt(); + break; + case JsonKeys.X: + event.x = reader.nextFloat(); + break; + case JsonKeys.Y: + event.y = reader.nextFloat(); + break; + case JsonKeys.POINTER_TYPE: + event.pointerType = reader.nextInt(); + break; + case JsonKeys.POINTER_ID: + event.pointerId = reader.nextInt(); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (dataUnknown == null) { + dataUnknown = new HashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + break; + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java new file mode 100644 index 00000000000..d3acf9a882a --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -0,0 +1,303 @@ +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.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("SameNameButDifferent") +public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { + + public static final class Position implements JsonSerializable, JsonUnknown { + + private int id; + + private float x; + + private float y; + + private long timeOffset; + + private @Nullable Map unknown; + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public float getX() { + return x; + } + + public void setX(final float x) { + this.x = x; + } + + public float getY() { + return y; + } + + public void setY(final float y) { + this.y = y; + } + + public long getTimeOffset() { + return timeOffset; + } + + public void setTimeOffset(final long timeOffset) { + this.timeOffset = timeOffset; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String ID = "id"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String TIME_OFFSET = "timeOffset"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.ID).value(id); + writer.name(JsonKeys.X).value(x); + writer.name(JsonKeys.Y).value(y); + writer.name(JsonKeys.TIME_OFFSET).value(timeOffset); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final Position position = new Position(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.ID: + position.id = reader.nextInt(); + break; + case JsonKeys.X: + position.x = reader.nextFloat(); + break; + case JsonKeys.Y: + position.y = reader.nextFloat(); + break; + case JsonKeys.TIME_OFFSET: + position.timeOffset = reader.nextLong(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + + position.setUnknown(unknown); + reader.endObject(); + return position; + } + } + // endregion json + } + + private int pointerId; + private @Nullable List positions; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebInteractionMoveEvent() { + super(IncrementalSource.TouchMove); + } + + @Nullable + public 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; + } + + @Nullable + public List getPositions() { + return positions; + } + + public void setPositions(final @Nullable List positions) { + this.positions = positions; + } + + public int getPointerId() { + return pointerId; + } + + public void setPointerId(final int pointerId) { + this.pointerId = pointerId; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String POSITIONS = "positions"; + public static final String POINTER_ID = "pointerId"; + } + + @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(); + new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); + if (positions != null && !positions.isEmpty()) { + writer.name(JsonKeys.POSITIONS).value(logger, positions); + } + writer.name(JsonKeys.POINTER_ID).value(pointerId); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebInteractionMoveEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebInteractionMoveEvent event = new RRWebInteractionMoveEvent(); + 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 RRWebInteractionMoveEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.POSITIONS: + event.positions = reader.nextListOrNull(logger, new Position.Deserializer()); + break; + case JsonKeys.POINTER_ID: + event.pointerId = reader.nextInt(); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (dataUnknown == null) { + dataUnknown = new HashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + break; + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java new file mode 100644 index 00000000000..b0aca2f3374 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -0,0 +1,191 @@ +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.util.Objects; +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 RRWebMetaEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + private @NotNull String href; + private int height; + private int width; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebMetaEvent() { + super(RRWebEventType.Meta); + this.href = ""; + } + + @NotNull + public String getHref() { + return href; + } + + public void setHref(final @NotNull String href) { + this.href = href; + } + + public int getHeight() { + return height; + } + + public void setHeight(final int height) { + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(final int width) { + this.width = width; + } + + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RRWebMetaEvent metaEvent = (RRWebMetaEvent) o; + return height == metaEvent.height + && width == metaEvent.width + && Objects.equals(href, metaEvent.href); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), href, height, width); + } + + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String HREF = "href"; + public static final String HEIGHT = "height"; + public static final String WIDTH = "width"; + } + + @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); + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.HREF).value(href); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull RRWebMetaEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + final RRWebMetaEvent event = new RRWebMetaEvent(); + 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 RRWebMetaEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.HREF: + final String href = reader.nextStringOrNull(); + event.href = href == null ? "" : href; + break; + case JsonKeys.HEIGHT: + final Integer height = reader.nextIntegerOrNull(); + event.height = height == null ? 0 : height; + break; + case JsonKeys.WIDTH: + final Integer width = reader.nextIntegerOrNull(); + event.width = width == null ? 0 : width; + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + } + event.setDataUnknown(unknown); + reader.endObject(); + } + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java new file mode 100644 index 00000000000..5bdc667f408 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java @@ -0,0 +1,289 @@ +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.util.CollectionUtils; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.math.BigDecimal; +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 RRWebSpanEvent extends RRWebEvent implements JsonSerializable, JsonUnknown { + public static final String EVENT_TAG = "performanceSpan"; + + private @NotNull String tag; + private @Nullable String op; + private @Nullable String description; + private double startTimestamp; + private double endTimestamp; + private @Nullable Map data; + // 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 payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebSpanEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + @Nullable + public String getOp() { + return op; + } + + public void setOp(final @Nullable String op) { + this.op = op; + } + + @Nullable + public String getDescription() { + return description; + } + + public void setDescription(final @Nullable String description) { + this.description = description; + } + + public double getStartTimestamp() { + return startTimestamp; + } + + public void setStartTimestamp(final double startTimestamp) { + this.startTimestamp = startTimestamp; + } + + public double getEndTimestamp() { + return endTimestamp; + } + + public void setEndTimestamp(final double endTimestamp) { + this.endTimestamp = endTimestamp; + } + + @Nullable + public Map getData() { + return data; + } + + public void setData(final @Nullable Map data) { + this.data = data == null ? null : new ConcurrentHashMap<>(data); + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + 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"; + public static final String OP = "op"; + public static final String DESCRIPTION = "description"; + public static final String START_TIMESTAMP = "startTimestamp"; + public static final String END_TIMESTAMP = "endTimestamp"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(RRWebBreadcrumbEvent.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(RRWebBreadcrumbEvent.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 (op != null) { + writer.name(JsonKeys.OP).value(op); + } + if (description != null) { + writer.name(JsonKeys.DESCRIPTION).value(description); + } + writer.name(JsonKeys.START_TIMESTAMP).value(logger, BigDecimal.valueOf(startTimestamp)); + writer.name(JsonKeys.END_TIMESTAMP).value(logger, BigDecimal.valueOf(endTimestamp)); + if (data != null) { + writer.name(JsonKeys.DATA).value(logger, data); + } + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebSpanEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebSpanEvent event = new RRWebSpanEvent(); + 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 RRWebSpanEvent 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 RRWebSpanEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.OP: + event.op = reader.nextStringOrNull(); + break; + case JsonKeys.DESCRIPTION: + event.description = reader.nextStringOrNull(); + break; + case JsonKeys.START_TIMESTAMP: + event.startTimestamp = reader.nextDouble(); + break; + case JsonKeys.END_TIMESTAMP: + event.endTimestamp = reader.nextDouble(); + break; + case JsonKeys.DATA: + Map deserializedData = + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); + if (deserializedData != null) { + event.data = deserializedData; + } + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java new file mode 100644 index 00000000000..1ba9f19c728 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -0,0 +1,433 @@ +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.util.Objects; +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 RRWebVideoEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + public static final String EVENT_TAG = "video"; + public static final String REPLAY_ENCODING = "h264"; + public static final String REPLAY_CONTAINER = "mp4"; + public static final String REPLAY_FRAME_RATE_TYPE_CONSTANT = "constant"; + public static final String REPLAY_FRAME_RATE_TYPE_VARIABLE = "variable"; + + private @NotNull String tag; + private int segmentId; + private long size; + private long durationMs; + private @NotNull String encoding = REPLAY_ENCODING; + private @NotNull String container = REPLAY_CONTAINER; + private int height; + private int width; + private int frameCount; + private @NotNull String frameRateType = REPLAY_FRAME_RATE_TYPE_CONSTANT; + private int frameRate; + private int left; + private int top; + // 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 payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebVideoEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public int getSegmentId() { + return segmentId; + } + + public void setSegmentId(final int segmentId) { + this.segmentId = segmentId; + } + + public long getSize() { + return size; + } + + public void setSize(final long size) { + this.size = size; + } + + public long getDurationMs() { + return durationMs; + } + + public void setDurationMs(final long durationMs) { + this.durationMs = durationMs; + } + + @NotNull + public String getEncoding() { + return encoding; + } + + public void setEncoding(final @NotNull String encoding) { + this.encoding = encoding; + } + + @NotNull + public String getContainer() { + return container; + } + + public void setContainer(final @NotNull String container) { + this.container = container; + } + + public int getHeight() { + return height; + } + + public void setHeight(final int height) { + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(final int width) { + this.width = width; + } + + public int getFrameCount() { + return frameCount; + } + + public void setFrameCount(final int frameCount) { + this.frameCount = frameCount; + } + + @NotNull + public String getFrameRateType() { + return frameRateType; + } + + public void setFrameRateType(final @NotNull String frameRateType) { + this.frameRateType = frameRateType; + } + + public int getFrameRate() { + return frameRate; + } + + public void setFrameRate(final int frameRate) { + this.frameRate = frameRate; + } + + public int getLeft() { + return left; + } + + public void setLeft(final int left) { + this.left = left; + } + + public int getTop() { + return top; + } + + public void setTop(final int top) { + this.top = top; + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RRWebVideoEvent that = (RRWebVideoEvent) o; + return segmentId == that.segmentId + && size == that.size + && durationMs == that.durationMs + && height == that.height + && width == that.width + && frameCount == that.frameCount + && frameRate == that.frameRate + && left == that.left + && top == that.top + && Objects.equals(tag, that.tag) + && Objects.equals(encoding, that.encoding) + && Objects.equals(container, that.container) + && Objects.equals(frameRateType, that.frameRateType); + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), + tag, + segmentId, + size, + durationMs, + encoding, + container, + height, + width, + frameCount, + frameRateType, + frameRate, + left, + top); + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + public static final String SEGMENT_ID = "segmentId"; + public static final String SIZE = "size"; + public static final String DURATION = "duration"; + public static final String ENCODING = "encoding"; + public static final String CONTAINER = "container"; + public static final String HEIGHT = "height"; + public static final String WIDTH = "width"; + public static final String FRAME_COUNT = "frameCount"; + public static final String FRAME_RATE_TYPE = "frameRateType"; + public static final String FRAME_RATE = "frameRate"; + public static final String LEFT = "left"; + public static final String TOP = "top"; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @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(); + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + writer.name(JsonKeys.SIZE).value(size); + writer.name(JsonKeys.DURATION).value(durationMs); + writer.name(JsonKeys.ENCODING).value(encoding); + writer.name(JsonKeys.CONTAINER).value(container); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + writer.name(JsonKeys.FRAME_COUNT).value(frameCount); + writer.name(JsonKeys.FRAME_RATE).value(frameRate); + writer.name(JsonKeys.FRAME_RATE_TYPE).value(frameRateType); + writer.name(JsonKeys.LEFT).value(left); + writer.name(JsonKeys.TOP).value(top); + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull RRWebVideoEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebVideoEvent event = new RRWebVideoEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebMetaEvent.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 RRWebVideoEvent 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(); + } + + private void deserializePayload( + final @NotNull RRWebVideoEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SEGMENT_ID: + event.segmentId = reader.nextInt(); + break; + case JsonKeys.SIZE: + final Long size = reader.nextLongOrNull(); + event.size = size == null ? 0 : size; + break; + case JsonKeys.DURATION: + event.durationMs = reader.nextLong(); + break; + case JsonKeys.CONTAINER: + final String container = reader.nextStringOrNull(); + event.container = container == null ? "" : container; + break; + case JsonKeys.ENCODING: + final String encoding = reader.nextStringOrNull(); + event.encoding = encoding == null ? "" : encoding; + break; + case JsonKeys.HEIGHT: + final Integer height = reader.nextIntegerOrNull(); + event.height = height == null ? 0 : height; + break; + case JsonKeys.WIDTH: + final Integer width = reader.nextIntegerOrNull(); + event.width = width == null ? 0 : width; + break; + case JsonKeys.FRAME_COUNT: + final Integer frameCount = reader.nextIntegerOrNull(); + event.frameCount = frameCount == null ? 0 : frameCount; + break; + case JsonKeys.FRAME_RATE: + final Integer frameRate = reader.nextIntegerOrNull(); + event.frameRate = frameRate == null ? 0 : frameRate; + break; + case JsonKeys.FRAME_RATE_TYPE: + final String frameRateType = reader.nextStringOrNull(); + event.frameRateType = frameRateType == null ? "" : frameRateType; + break; + case JsonKeys.LEFT: + final Integer left = reader.nextIntegerOrNull(); + event.left = left == null ? 0 : left; + break; + case JsonKeys.TOP: + final Integer top = reader.nextIntegerOrNull(); + event.top = top == null ? 0 : top; + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java new file mode 100644 index 00000000000..b04fbb96751 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -0,0 +1,413 @@ +package io.sentry.util; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.ObjectReader; +import io.sentry.SentryLevel; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.AbstractMap; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Date; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("unchecked") +public final class MapObjectReader implements ObjectReader { + + private final Deque> stack; + + public MapObjectReader(final Map root) { + stack = new ArrayDeque<>(); + stack.addLast(new AbstractMap.SimpleEntry<>(null, root)); + } + + @Override + public void nextUnknown( + final @NotNull ILogger logger, final Map unknown, final String name) { + try { + unknown.put(name, nextObjectOrNull()); + } catch (Exception exception) { + logger.log(SentryLevel.ERROR, exception, "Error deserializing unknown key: %s", name); + } + } + + @Nullable + @Override + public List nextListOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + try { + beginArray(); + List list = new ArrayList<>(); + if (hasNext()) { + do { + try { + list.add(deserializer.deserialize(this, logger)); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); + } + } while (peek() == JsonToken.BEGIN_OBJECT); + } + endArray(); + return list; + } catch (Exception e) { + throw new IOException(e); + } + } + + @Nullable + @Override + public Map nextMapOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + try { + beginObject(); + Map map = new HashMap<>(); + if (hasNext()) { + do { + try { + String key = nextName(); + map.put(key, deserializer.deserialize(this, logger)); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); + } + } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } + endObject(); + return map; + } catch (Exception e) { + throw new IOException(e); + } + } + + @Override + public @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + final @NotNull Map> result = new HashMap<>(); + + try { + beginObject(); + if (hasNext()) { + do { + final @NotNull String key = nextName(); + final @Nullable List list = nextListOrNull(logger, deserializer); + if (list != null) { + result.put(key, list); + } + } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } + endObject(); + return result; + } catch (Exception e) { + throw new IOException(e); + } + } + + @Nullable + @Override + public T nextOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws Exception { + return nextValueOrNull(logger, deserializer); + } + + @Nullable + @Override + public Date nextDateOrNull(final @NotNull ILogger logger) throws IOException { + final String dateString = nextStringOrNull(); + return ObjectReader.dateOrNull(dateString, logger); + } + + @Nullable + @Override + public TimeZone nextTimeZoneOrNull(final @NotNull ILogger logger) throws IOException { + final String timeZoneId = nextStringOrNull(); + return timeZoneId != null ? TimeZone.getTimeZone(timeZoneId) : null; + } + + @Nullable + @Override + public Object nextObjectOrNull() throws IOException { + return nextValueOrNull(); + } + + @NotNull + @Override + public JsonToken peek() throws IOException { + if (stack.isEmpty()) { + return JsonToken.END_DOCUMENT; + } + + final Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return JsonToken.END_DOCUMENT; + } + + if (currentEntry.getKey() != null) { + return JsonToken.NAME; + } + + final Object value = currentEntry.getValue(); + + if (value instanceof Map) { + return JsonToken.BEGIN_OBJECT; + } else if (value instanceof List) { + return JsonToken.BEGIN_ARRAY; + } else if (value instanceof String) { + return JsonToken.STRING; + } else if (value instanceof Number) { + return JsonToken.NUMBER; + } else if (value instanceof Boolean) { + return JsonToken.BOOLEAN; + } else if (value instanceof JsonToken) { + return (JsonToken) value; + } else { + return JsonToken.END_DOCUMENT; + } + } + + @NotNull + @Override + public String nextName() throws IOException { + final Map.Entry currentEntry = stack.peekLast(); + if (currentEntry != null && currentEntry.getKey() != null) { + return currentEntry.getKey(); + } + throw new IOException("Expected a name but was " + peek()); + } + + @Override + public void beginObject() throws IOException { + final Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + final Object value = currentEntry.getValue(); + if (value instanceof Map) { + // insert a dummy entry to indicate end of an object + stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_OBJECT)); + // extract map entries onto the stack + for (Map.Entry entry : ((Map) value).entrySet()) { + stack.addLast(entry); + } + } else { + throw new IOException("Current token is not an object"); + } + } + + @Override + public void endObject() throws IOException { + if (stack.size() > 1) { + stack.removeLast(); // Pop the current map from stack + } + } + + @Override + public void beginArray() throws IOException { + final Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + final Object value = currentEntry.getValue(); + if (value instanceof List) { + // insert a dummy entry to indicate end of an object + stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_ARRAY)); + // extract map entries onto the stack + for (int i = ((List) value).size() - 1; i >= 0; i--) { + final Object entry = ((List) value).get(i); + stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); + } + } else { + throw new IOException("Current token is not an object"); + } + } + + @Override + public void endArray() throws IOException { + if (stack.size() > 1) { + stack.removeLast(); // Pop the current array from stack + } + } + + @Override + public boolean hasNext() throws IOException { + return !stack.isEmpty(); + } + + @Override + public int nextInt() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } else { + throw new IOException("Expected int"); + } + } + + @Nullable + @Override + public Integer nextIntegerOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + @Override + public long nextLong() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } else { + throw new IOException("Expected long"); + } + } + + @Nullable + @Override + public Long nextLongOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return null; + } + + @Override + public String nextString() throws IOException { + final String value = nextValueOrNull(); + if (value != null) { + return value; + } else { + throw new IOException("Expected string"); + } + } + + @Nullable + @Override + public String nextStringOrNull() throws IOException { + return nextValueOrNull(); + } + + @Override + public boolean nextBoolean() throws IOException { + final Boolean value = nextValueOrNull(); + if (value != null) { + return value; + } else { + throw new IOException("Expected boolean"); + } + } + + @Nullable + @Override + public Boolean nextBooleanOrNull() throws IOException { + return nextValueOrNull(); + } + + @Override + public double nextDouble() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } else { + throw new IOException("Expected double"); + } + } + + @Nullable + @Override + public Double nextDoubleOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + @Nullable + @Override + public Float nextFloatOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } + return null; + } + + @Override + public float nextFloat() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } else { + throw new IOException("Expected float"); + } + } + + @Override + public void nextNull() throws IOException { + final Object value = nextValueOrNull(); + if (value != null) { + throw new IOException("Expected null but was " + peek()); + } + } + + @Override + public void setLenient(final boolean lenient) {} + + @Override + public void skipValue() throws IOException {} + + @SuppressWarnings("TypeParameterUnusedInFormals") + @Nullable + private T nextValueOrNull() throws IOException { + try { + return nextValueOrNull(null, null); + } catch (Exception e) { + throw new IOException(e); + } + } + + @SuppressWarnings("TypeParameterUnusedInFormals") + @Nullable + private T nextValueOrNull( + final @Nullable ILogger logger, final @Nullable JsonDeserializer deserializer) + throws Exception { + final Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return null; + } + final T value = (T) currentEntry.getValue(); + if (deserializer != null && logger != null) { + return deserializer.deserialize(this, logger); + } + stack.removeLast(); + return value; + } + + @Override + public void close() throws IOException { + stack.clear(); + } +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc29..0bbc70a779d 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -120,6 +120,11 @@ public MapObjectWriter value(final @NotNull ILogger logger, final @Nullable Obje return this; } + @Override + public void setLenient(boolean lenient) { + // no-op + } + @Override public MapObjectWriter beginArray() throws IOException { stack.add(new ArrayList<>()); @@ -151,6 +156,12 @@ public MapObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + // no-op + return this; + } + @Override public MapObjectWriter nullValue() throws IOException { postValue((Object) null); diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index eb1cfa0383e..c24731e92a7 100644 --- a/sentry/src/test/java/io/sentry/BaggageTest.kt +++ b/sentry/src/test/java/io/sentry/BaggageTest.kt @@ -527,15 +527,13 @@ class BaggageTest { @Test fun `unknown returns sentry- prefixed keys that are not known and passes them on to TraceContext`() { - val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=def", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) + val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=${SentryId()}", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) val unknown = baggage.unknown - assertEquals(2, unknown.size) - assertEquals("def", unknown["replay_id"]) + assertEquals(1, unknown.size) assertEquals("abc", unknown["anewkey"]) val traceContext = baggage.toTraceContext()!! - assertEquals(2, traceContext.unknown!!.size) - assertEquals("def", traceContext.unknown!!["replay_id"]) + assertEquals(1, traceContext.unknown!!.size) assertEquals("abc", traceContext.unknown!!["anewkey"]) } diff --git a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt index 276c0d986e5..b28efd2fc4d 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt @@ -327,7 +327,7 @@ class JsonObjectReaderTest { var bar: String? = null ) { class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Deserializable { + override fun deserialize(reader: ObjectReader, logger: ILogger): Deserializable { return Deserializable().apply { reader.beginObject() reader.nextName() diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index 30f337dce4e..fddf68f59eb 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -443,16 +443,16 @@ class JsonSerializerTest { @Test fun `serializes trace context`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } @Test fun `serializes trace context with user having null id and segment`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index f70d7e05841..9b883d5ef2c 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1,11 +1,11 @@ package io.sentry import io.sentry.Scope.IWithPropagationContext +import io.sentry.SentryLevel.WARNING import io.sentry.Session.State.Crashed import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent -import io.sentry.clientreport.DropEverythingEventProcessor import io.sentry.exception.SentryEnvelopeException import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData @@ -42,6 +42,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -2359,6 +2360,41 @@ class SentryClientTest { @Test fun `when event has DiskFlushNotification, TransactionEnds set transaction id as flushable`() { val sut = fixture.getSut() + val replayId = SentryId() + val scope = mock { + whenever(it.replayId).thenReturn(replayId) + whenever(it.breadcrumbs).thenReturn(LinkedList()) + whenever(it.extras).thenReturn(emptyMap()) + whenever(it.contexts).thenReturn(Contexts()) + } + val scopePropagationContext = PropagationContext() + whenever(scope.propagationContext).thenReturn(scopePropagationContext) + doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) + + var capturedEventId: SentryId? = null + val transactionEnd = object : TransactionEnd, DiskFlushNotification { + override fun markFlushed() {} + override fun isFlushable(eventId: SentryId?): Boolean = true + override fun setFlushable(eventId: SentryId) { + capturedEventId = eventId + } + } + val transactionEndHint = HintUtils.createWithTypeCheckHint(transactionEnd) + + sut.captureEvent(SentryEvent(), scope, transactionEndHint) + + assertEquals(replayId, capturedEventId) + verify(fixture.transport).send( + check { + assertEquals(1, it.items.count()) + }, + any() + ) + } + + @Test + fun `when event has DiskFlushNotification, TransactionEnds set replay id as flushable`() { + val sut = fixture.getSut() // build up a running transaction val spanContext = SpanContext("op.load") @@ -2373,6 +2409,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId.EMPTY_ID) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2445,6 +2482,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId()) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2513,6 +2551,8 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + val replayId = SentryId() + whenever(scope.replayId).thenReturn(replayId) val scopePropagationContext = PropagationContext() doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) whenever(scope.propagationContext).thenReturn(scopePropagationContext) @@ -2525,6 +2565,7 @@ class SentryClientTest { check { assertNotNull(it.header.traceContext) assertEquals(scopePropagationContext.traceId, it.header.traceContext!!.traceId) + assertEquals(replayId, it.header.traceContext!!.replayId) }, any() ) @@ -2609,6 +2650,120 @@ class SentryClientTest { assertNotSame(NoopMetricsAggregator.getInstance(), sut.metricsAggregator) } + @Test + fun `when captureReplayEvent, envelope is sent`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, null, null) + + verify(fixture.transport).send( + check { actual -> + assertEquals(replayEvent.eventId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.ReplayVideo, item.header.type) + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + assertEquals(1, mapSize) + }, + any() + ) + } + + @Test + fun `when captureReplayEvent with recording, adds it to payload`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + val hint = Hint().apply { replayRecording = createReplayRecording() } + sut.captureReplayEvent(replayEvent, null, hint) + + verify(fixture.transport).send( + check { actual -> + assertEquals(replayEvent.eventId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.ReplayVideo, item.header.type) + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + assertEquals(2, mapSize) + }, + any() + ) + } + + @Test + fun `when captureReplayEvent, omits breadcrumbs and extras from scope`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, createScope(), null) + + verify(fixture.transport).send( + check { actual -> + val item = actual.items.first() + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.sentryOptions.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + // sanity check + assertEquals("id", actualReplayEvent!!.user!!.id) + + assertNull(actualReplayEvent.breadcrumbs) + assertNull(actualReplayEvent.extras) + } + } + } + }, + any() + ) + } + + @Test + fun `when replay event is dropped, captures client report with datacategory replay`() { + fixture.sentryOptions.addEventProcessor(DropEverythingEventProcessor()) + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, createScope(), null) + + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Replay.category, 1)) + ) + } + + @Test + fun `calls sendReplayForEvent on replay controller for error events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + assertEquals("Test", event.message?.formatted) + called = true + } + }) + val sut = fixture.getSut() + + sut.captureMessage("Test", WARNING) + assertTrue(called) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() @@ -2667,6 +2822,21 @@ class SentryClientTest { } } + private fun createReplayEvent(): SentryReplayEvent = SentryReplayEvent().apply { + replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") + segmentId = 0 + timestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + } + + private fun createReplayRecording(): ReplayRecording = ReplayRecording().apply { + segmentId = 0 + payload = emptyList() + } + private fun createScope(options: SentryOptions = SentryOptions()): IScope { return Scope(options).apply { addBreadcrumb( @@ -2850,4 +3020,8 @@ class DropEverythingEventProcessor : EventProcessor { ): SentryTransaction? { return null } + + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent? { + return null + } } diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 98178976510..efc5e5cadfe 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -1,6 +1,8 @@ package io.sentry import io.sentry.exception.SentryEnvelopeException +import io.sentry.protocol.ReplayRecordingSerializationTest +import io.sentry.protocol.SentryReplayEventSerializationTest import io.sentry.protocol.User import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField @@ -10,12 +12,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.BufferedWriter import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset +import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -66,7 +71,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes`() { val attachment = Attachment(fixture.bytesAllowed, fixture.filename) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -78,7 +88,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(viewHierarchy, fixture.filename, "text/plain", null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, viewHierarchySerialized, item) } @@ -87,7 +102,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertEquals("event.minidump", item.header.attachmentType) } @@ -98,7 +118,12 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytesAllowed) val attachment = Attachment(file.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -110,7 +135,12 @@ class SentryEnvelopeItemTest { file.writeBytes(twoMB) val attachment = Attachment(file.absolutePath) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, twoMB, item) } @@ -119,7 +149,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with non existent file`() { val attachment = Attachment("I don't exist", "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, because the file located at " + @@ -139,7 +174,12 @@ class SentryEnvelopeItemTest { if (changedFileReadPermission) { val attachment = Attachment(file.path, "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, " + @@ -162,7 +202,12 @@ class SentryEnvelopeItemTest { val securityManager = DenyReadFileSecurityManager(fixture.pathname) System.setSecurityManager(securityManager) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith("Reading the attachment ${attachment.pathname} failed.") { item.data @@ -181,7 +226,12 @@ class SentryEnvelopeItemTest { // reflection instead. attachment.injectForField("pathname", null) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -196,7 +246,12 @@ class SentryEnvelopeItemTest { val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! val attachment = Attachment(image.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, image.readBytes(), item) } @@ -204,7 +259,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes too big`() { val attachment = Attachment(fixture.bytesTooBig, fixture.filename) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -227,7 +287,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(serializable, fixture.filename, "text/plain", null, false) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -246,7 +311,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(file.path) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -261,7 +331,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytesFrom serializable are null`() { val attachment = Attachment(mock(), "mock-file-name", null, null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.errorSerializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.errorSerializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -279,8 +354,13 @@ class SentryEnvelopeItemTest { } file.writeBytes(fixture.bytes) - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, fixture.serializer).data - verify(profilingTraceData).sampledProfile = Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + fixture.serializer + ).data + verify(profilingTraceData).sampledProfile = + Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) } @Test @@ -292,7 +372,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assert(file.exists()) traceData.data assertFalse(file.exists()) @@ -306,7 +390,11 @@ class SentryEnvelopeItemTest { } assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -319,7 +407,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) file.setReadable(false) assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -331,7 +423,11 @@ class SentryEnvelopeItemTest { whenever(it.traceFile).thenReturn(file) } - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assertFailsWith("Profiling trace file is empty") { traceData.data } @@ -346,7 +442,11 @@ class SentryEnvelopeItemTest { } val exception = assertFailsWith { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } assertEquals( @@ -357,6 +457,58 @@ class SentryEnvelopeItemTest { ) } + @Test + fun `fromReplay encodes payload into msgpack`() { + val file = Files.createTempFile("replay", "").toFile() + val videoBytes = + this::class.java.classLoader.getResource("Tongariro.jpg")!!.readBytes() + file.writeBytes(videoBytes) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + + assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) + + assertPayload(replayItem, replayEvent, replayRecording, videoBytes) + } + + @Test + fun `fromReplay does not add video item when no bytes`() { + val file = File(fixture.pathname) + file.writeBytes(ByteArray(0)) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + replayItem.data + assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> + assertEquals(1, mapSize) + } + } + + @Test + fun `fromReplay deletes file only after reading data`() { + val file = File(fixture.pathname) + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + file.writeBytes(fixture.bytes) + assert(file.exists()) + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + private fun createSession(): Session { return Session("dis", User(), "env", "rel") } @@ -379,4 +531,45 @@ class SentryEnvelopeItemTest { } } } + + private fun assertPayload( + replayItem: SentryEnvelopeItem, + replayEvent: SentryReplayEvent, + replayRecording: ReplayRecording?, + videoBytes: ByteArray, + mapSizeAsserter: (mapSize: Int) -> Unit = {} + ) { + val unpacker = MessagePack.newDefaultUnpacker(replayItem.data) + val mapSize = unpacker.unpackMapHeader() + mapSizeAsserter(mapSize) + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + assertEquals(replayEvent, actualReplayEvent) + } + SentryItemType.ReplayRecording.itemType -> { + val replayRecordingLength = unpacker.unpackBinaryHeader() + val replayRecordingBytes = unpacker.readPayload(replayRecordingLength) + val actualReplayRecording = fixture.serializer.deserialize( + InputStreamReader(replayRecordingBytes.inputStream()), + ReplayRecording::class.java + ) + assertEquals(replayRecording, actualReplayRecording) + } + SentryItemType.ReplayVideo.itemType -> { + val videoLength = unpacker.unpackBinaryHeader() + val actualBytes = unpacker.readPayload(videoLength) + assertArrayEquals(videoBytes, actualBytes) + } + } + } + unpacker.close() + } } diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt new file mode 100644 index 00000000000..01843dfc90a --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -0,0 +1,32 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SentryReplayOptionsTest { + + @Test + fun `uses medium quality as default`() { + val replayOptions = SentryReplayOptions() + + assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) + assertEquals(75_000, replayOptions.quality.bitRate) + assertEquals(1.0f, replayOptions.quality.sizeScale) + } + + @Test + fun `low quality`() { + val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + + assertEquals(50_000, replayOptions.quality.bitRate) + assertEquals(0.8f, replayOptions.quality.sizeScale) + } + + @Test + fun `high quality`() { + val replayOptions = SentryReplayOptions().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/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 6bd835716e6..b22f585f6dd 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.protocol.User import io.sentry.util.thread.IMainThreadChecker @@ -581,6 +582,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val trace = transaction.traceContext() assertNotNull(trace) { assertEquals(transaction.spanContext.traceId, it.traceId) @@ -588,6 +591,7 @@ class SentryTracerTest { assertEquals("environment", it.environment) assertEquals("release@3.0.0", it.release) assertEquals(transaction.name, it.transaction) + assertEquals(replayId, it.replayId) } } @@ -656,6 +660,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val header = transaction.toBaggageHeader(null) assertNotNull(header) { @@ -669,6 +675,7 @@ class SentryTracerTest { assertTrue(it.value.contains("sentry-transaction=name,")) // assertTrue(it.value.contains("sentry-user_id=userId12345,")) assertTrue(it.value.contains("sentry-user_segment=pro$".toRegex())) + assertTrue(it.value.contains("sentry-replay_id=$replayId")) } } diff --git a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index e79e5ebf8c5..876ec128315 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -24,7 +24,8 @@ class TraceContextSerializationTest { "f7d8662b-5551-4ef8-b6a8-090f0561a530", "0252ec25-cd0a-4230-bd2f-936a4585637e", "0.00000021", - "true" + "true", + SentryId("3367f5196c494acaae85bbbd535379aa") ) } private val fixture = Fixture() @@ -62,6 +63,7 @@ class TraceContextSerializationTest { id = "user-id" others = mapOf("segment" to "pro") }, + SentryId(), SentryOptions().apply { dsn = dsnString environment = "prod" diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt new file mode 100644 index 00000000000..cff08ee2ab1 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -0,0 +1,53 @@ +package io.sentry.protocol + +import io.sentry.FileFromResources +import io.sentry.ILogger +import io.sentry.ReplayRecording +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebBreadcrumbEventSerializationTest +import io.sentry.rrweb.RRWebInteractionEventSerializationTest +import io.sentry.rrweb.RRWebInteractionMoveEventSerializationTest +import io.sentry.rrweb.RRWebMetaEventSerializationTest +import io.sentry.rrweb.RRWebSpanEventSerializationTest +import io.sentry.rrweb.RRWebVideoEventSerializationTest +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class ReplayRecordingSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = ReplayRecording().apply { + segmentId = 0 + payload = listOf( + RRWebMetaEventSerializationTest.Fixture().getSut(), + RRWebVideoEventSerializationTest.Fixture().getSut(), + RRWebBreadcrumbEventSerializationTest.Fixture().getSut(), + RRWebSpanEventSerializationTest.Fixture().getSut(), + RRWebInteractionEventSerializationTest.Fixture().getSut(), + RRWebInteractionMoveEventSerializationTest.Fixture().getSut() + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = FileFromResources.invoke("json/replay_recording.json") + .substringBeforeLast("\n") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = FileFromResources.invoke("json/replay_recording.json") + .substringBeforeLast("\n") + val actual = deserializeJson(expectedJson, ReplayRecording.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4bc13559dae..3da517ef56f 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt @@ -2,8 +2,8 @@ package io.sentry.protocol import io.sentry.ILogger import io.sentry.JsonDeserializer -import io.sentry.JsonObjectReader import io.sentry.JsonSerializable +import io.sentry.ObjectReader import io.sentry.ObjectWriter import io.sentry.SentryBaseEvent import io.sentry.SentryIntegrationPackageStorage @@ -27,7 +27,7 @@ class SentryBaseEventSerializationTest { } class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Sut { + override fun deserialize(reader: ObjectReader, logger: ILogger): Sut { val sut = Sut() reader.beginObject() diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt new file mode 100644 index 00000000000..6ecd6800767 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -0,0 +1,62 @@ +package io.sentry.protocol + +import io.sentry.DateUtils +import io.sentry.ILogger +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryReplayEvent +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class SentryReplayEventSerializationTest { + + class Fixture { + val logger = mock() + + fun getSut() = SentryReplayEvent().apply { + replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") + segmentId = 0 + timestamp = DateUtils.getDateTime("1942-07-09T12:55:34.000Z") + replayStartTimestamp = DateUtils.getDateTime("1942-07-09T12:55:34.000Z") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + SentryBaseEventSerializationTest.Fixture().update(this) + // irrelevant for replay + serverName = null + breadcrumbs = null + extras = null + } + } + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @After + fun teardown() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun serialize() { + val expected = sanitizedFile("json/sentry_replay_event.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/sentry_replay_event.json") + val actual = deserializeJson(expectedJson, SentryReplayEvent.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt new file mode 100644 index 00000000000..9dfffef8d24 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.SentryLevel.INFO +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebBreadcrumbEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebBreadcrumbEvent().apply { + timestamp = 12345678901 + breadcrumbType = "default" + breadcrumbTimestamp = 12345678.901 + category = "navigation" + message = "message" + level = INFO + data = mapOf( + "screen" to "MainActivity", + "state" to "resumed" + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_breadcrumb_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_breadcrumb_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebBreadcrumbEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt new file mode 100644 index 00000000000..2c2b60cd28d --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt @@ -0,0 +1,78 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.JsonDeserializer +import io.sentry.JsonSerializable +import io.sentry.ObjectReader +import io.sentry.ObjectWriter +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebEventType.Custom +import io.sentry.vendor.gson.stream.JsonToken +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebEventSerializationTest { + + /** + * Make subclass, as `RRWebEvent` initializers are protected. + */ + class Sut : RRWebEvent(), JsonSerializable { + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + Serializer().serialize(this, writer, logger) + writer.endObject() + } + + class Deserializer : JsonDeserializer { + override fun deserialize(reader: ObjectReader, logger: ILogger): Sut { + val sut = Sut() + reader.beginObject() + + val baseEventDeserializer = RRWebEvent.Deserializer() + do { + val nextName = reader.nextName() + baseEventDeserializer.deserializeValue(sut, nextName, reader, logger) + } while (reader.hasNext() && reader.peek() == JsonToken.NAME) + reader.endObject() + return sut + } + } + } + + class Fixture { + val logger = mock() + + fun update(rrWebEvent: RRWebEvent) { + rrWebEvent.apply { + type = Custom + timestamp = 9999999 + } + } + } + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/rrweb_event.json") + val sut = Sut().apply { fixture.update(this) } + val actual = serializeToString(sut, fixture.logger) + + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/rrweb_event.json") + val actual = deserializeJson( + expectedJson, + Sut.Deserializer(), + fixture.logger + ) + val actualJson = serializeToString(actual, fixture.logger) + + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt new file mode 100644 index 00000000000..21ec522d51b --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt @@ -0,0 +1,41 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebInteractionEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebInteractionEvent().apply { + timestamp = 12345678901 + id = 1 + x = 1.0f + y = 2.0f + interactionType = TouchStart + pointerId = 1 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_interaction_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_interaction_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebInteractionEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt new file mode 100644 index 00000000000..b114a4e092a --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebInteractionMoveEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebInteractionMoveEvent().apply { + timestamp = 12345678901 + positions = listOf( + Position().apply { + id = 1 + x = 1.0f + y = 2.0f + timeOffset = 100 + } + ) + pointerId = 1 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_interaction_move_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_interaction_move_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebInteractionMoveEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt new file mode 100644 index 00000000000..29ec354333e --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt @@ -0,0 +1,42 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebEventType.Meta +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebMetaEventSerializationTest { + + class Fixture { + val logger = mock() + + fun getSut() = RRWebMetaEvent().apply { + href = "https://sentry.io" + height = 1920 + width = 1080 + type = Meta + timestamp = 1234567890 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/rrweb_meta_event.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/rrweb_meta_event.json") + val actual = deserializeJson(expectedJson, RRWebMetaEvent.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt new file mode 100644 index 00000000000..034a1ded99a --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt @@ -0,0 +1,43 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebSpanEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebSpanEvent().apply { + timestamp = 12345678901 + op = "resource.http" + description = "https://api.github.com/users/getsentry/repos" + startTimestamp = 12345678.901 + endTimestamp = 12345679.901 + data = mapOf( + "method" to "POST", + "status_code" to 200 + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_span_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_span_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebSpanEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt new file mode 100644 index 00000000000..17a790b5cde --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebEventType.Custom +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebVideoEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebVideoEvent().apply { + type = Custom + timestamp = 12345678901 + tag = "video" + segmentId = 0 + size = 4_000_000L + durationMs = 5000 + height = 1920 + width = 1080 + frameCount = 5 + frameRate = 1 + left = 100 + top = 100 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_video_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_video_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebVideoEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt new file mode 100644 index 00000000000..a335fc71f82 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -0,0 +1,151 @@ +package io.sentry.util + +import io.sentry.ILogger +import io.sentry.JsonDeserializer +import io.sentry.JsonSerializable +import io.sentry.NoOpLogger +import io.sentry.ObjectReader +import io.sentry.ObjectWriter +import io.sentry.vendor.gson.stream.JsonToken +import java.math.BigDecimal +import java.net.URI +import java.util.Currency +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapObjectReaderTest { + + enum class BasicEnum { + A + } + + data class BasicSerializable(var test: String = "string") : JsonSerializable { + + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + .name("test") + .value(test) + .endObject() + } + + class Deserializer : JsonDeserializer { + override fun deserialize(reader: ObjectReader, logger: ILogger): BasicSerializable { + val basicSerializable = BasicSerializable() + reader.beginObject() + if (reader.nextName() == "test") { + basicSerializable.test = reader.nextString() + } + reader.endObject() + return basicSerializable + } + } + } + + @Test + fun `deserializes data correctly`() { + val logger = NoOpLogger.getInstance() + val data = mutableMapOf() + val writer = MapObjectWriter(data) + + writer.name("null").nullValue() + writer.name("int").value(1) + writer.name("boolean").value(true) + writer.name("long").value(Long.MAX_VALUE) + writer.name("double").value(Double.MAX_VALUE) + writer.name("number").value(BigDecimal(123)) + writer.name("date").value(logger, Date(0)) + writer.name("string").value("string") + + writer.name("TimeZone").value(logger, TimeZone.getTimeZone("Vienna")) + writer.name("JsonSerializable").value( + logger, + BasicSerializable() + ) + writer.name("Collection").value(logger, listOf("a", "b")) + writer.name("Arrays").value(logger, arrayOf("b", "c")) + writer.name("Map").value(logger, mapOf(kotlin.Pair("key", "value"))) + writer.name("MapOfLists").value(logger, mapOf("metric_a" to listOf("foo"))) + writer.name("Locale").value(logger, Locale.US) + writer.name("URI").value(logger, URI.create("http://www.example.com")) + writer.name("UUID").value(logger, UUID.fromString("00000000-1111-2222-3333-444444444444")) + writer.name("Currency").value(logger, Currency.getInstance("EUR")) + writer.name("Enum").value(logger, MapObjectWriterTest.BasicEnum.A) + writer.name("data").value(logger, mapOf("screen" to "MainActivity")) + writer.name("ListOfObjects").value(logger, listOf(BasicSerializable())) + writer.name("MapOfObjects").value(logger, mapOf("key" to BasicSerializable())) + writer.name("MapOfListsObjects").value(logger, mapOf("key" to listOf(BasicSerializable()))) + + val reader = MapObjectReader(data) + reader.beginObject() + assertEquals(JsonToken.NAME, reader.peek()) + assertEquals("MapOfListsObjects", reader.nextName()) + assertEquals(mapOf("key" to listOf(BasicSerializable())), reader.nextMapOfListOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("MapOfObjects", reader.nextName()) + assertEquals(mapOf("key" to BasicSerializable()), reader.nextMapOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("ListOfObjects", reader.nextName()) + assertEquals(listOf(BasicSerializable()), reader.nextListOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("data", reader.nextName()) + assertEquals(mapOf("screen" to "MainActivity"), reader.nextObjectOrNull()) + assertEquals("Enum", reader.nextName()) + assertEquals(BasicEnum.A, BasicEnum.valueOf(reader.nextString())) + assertEquals("Currency", reader.nextName()) + assertEquals(Currency.getInstance("EUR"), Currency.getInstance(reader.nextString())) + assertEquals("UUID", reader.nextName()) + assertEquals( + UUID.fromString("00000000-1111-2222-3333-444444444444"), + UUID.fromString(reader.nextString()) + ) + assertEquals("URI", reader.nextName()) + assertEquals(URI.create("http://www.example.com"), URI.create(reader.nextString())) + assertEquals("Locale", reader.nextName()) + assertEquals(Locale.US.toString(), reader.nextString()) + assertEquals("MapOfLists", reader.nextName()) + reader.beginObject() + assertEquals("metric_a", reader.nextName()) + reader.beginArray() + assertEquals("foo", reader.nextStringOrNull()) + reader.endArray() + reader.endObject() + assertEquals("Map", reader.nextName()) + // nested object + reader.beginObject() + assertEquals("key", reader.nextName()) + assertEquals("value", reader.nextStringOrNull()) + reader.endObject() + assertEquals("Arrays", reader.nextName()) + reader.beginArray() + assertEquals("b", reader.nextString()) + assertEquals("c", reader.nextString()) + reader.endArray() + assertEquals("Collection", reader.nextName()) + reader.beginArray() + assertEquals("a", reader.nextString()) + assertEquals("b", reader.nextString()) + reader.endArray() + assertEquals("JsonSerializable", reader.nextName()) + assertEquals(BasicSerializable(), reader.nextOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("TimeZone", reader.nextName()) + assertEquals(TimeZone.getTimeZone("Vienna"), reader.nextTimeZoneOrNull(logger)) + assertEquals("string", reader.nextName()) + assertEquals("string", reader.nextString()) + assertEquals("date", reader.nextName()) + assertEquals(Date(0), reader.nextDateOrNull(logger)) + assertEquals("number", reader.nextName()) + assertEquals(BigDecimal(123), reader.nextObjectOrNull()) + assertEquals("double", reader.nextName()) + assertEquals(Double.MAX_VALUE, reader.nextDoubleOrNull()) + assertEquals("long", reader.nextName()) + assertEquals(Long.MAX_VALUE, reader.nextLongOrNull()) + assertEquals("boolean", reader.nextName()) + assertEquals(true, reader.nextBoolean()) + assertEquals("int", reader.nextName()) + assertEquals(1, reader.nextInt()) + assertEquals("null", reader.nextName()) + reader.nextNull() + reader.endObject() + } +} diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json new file mode 100644 index 00000000000..021c78b0206 --- /dev/null +++ b/sentry/src/test/resources/json/replay_recording.json @@ -0,0 +1,2 @@ +{"segment_id":0} +[{"type":4,"timestamp":1234567890,"data":{"href":"https://sentry.io","height":1920,"width":1080}},{"type":5,"timestamp":12345678901,"data":{"tag":"video","payload":{"segmentId":0,"size":4000000,"duration":5000,"encoding":"h264","container":"mp4","height":1920,"width":1080,"frameCount":5,"frameRate":1,"frameRateType":"constant","left":100,"top":100}}},{"type":5,"timestamp":12345678901,"data":{"tag":"breadcrumb","payload":{"type":"default","timestamp":12345678.901,"category":"navigation","message":"message","level":"info","data":{"screen":"MainActivity","state":"resumed"}}}},{"type":5,"timestamp":12345678901,"data":{"tag":"performanceSpan","payload":{"op":"resource.http","description":"https://api.github.com/users/getsentry/repos","startTimestamp":12345678.901,"endTimestamp":12345679.901,"data":{"status_code":200,"method":"POST"}}}},{"type":3,"timestamp":12345678901,"data":{"source":2,"type":7,"id":1,"x":1.0,"y":2.0,"pointerType":2,"pointerId":1}},{"type":3,"timestamp":12345678901,"data":{"source":6,"positions":[{"id":1,"x":1.0,"y":2.0,"timeOffset":100}],"pointerId":1}}] diff --git a/sentry/src/test/resources/json/rrweb_breadcrumb_event.json b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json new file mode 100644 index 00000000000..e1fbe676fa6 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json @@ -0,0 +1,18 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "breadcrumb", + "payload": { + "type": "default", + "timestamp": 12345678.901, + "category": "navigation", + "message": "message", + "level": "info", + "data": { + "screen": "MainActivity", + "state": "resumed" + } + } + } +} diff --git a/sentry/src/test/resources/json/rrweb_event.json b/sentry/src/test/resources/json/rrweb_event.json new file mode 100644 index 00000000000..d5610238e97 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_event.json @@ -0,0 +1,4 @@ +{ + "type": 5, + "timestamp": 9999999 +} diff --git a/sentry/src/test/resources/json/rrweb_interaction_event.json b/sentry/src/test/resources/json/rrweb_interaction_event.json new file mode 100644 index 00000000000..1af66d4afd9 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_event.json @@ -0,0 +1,13 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 2, + "type": 7, + "id": 1, + "x": 1.0, + "y": 2.0, + "pointerType": 2, + "pointerId": 1 + } +} diff --git a/sentry/src/test/resources/json/rrweb_interaction_move_event.json b/sentry/src/test/resources/json/rrweb_interaction_move_event.json new file mode 100644 index 00000000000..0a815067ce2 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_move_event.json @@ -0,0 +1,16 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 6, + "positions": [ + { + "id": 1, + "x": 1.0, + "y": 2.0, + "timeOffset": 100 + } + ], + "pointerId": 1 + } +} diff --git a/sentry/src/test/resources/json/rrweb_meta_event.json b/sentry/src/test/resources/json/rrweb_meta_event.json new file mode 100644 index 00000000000..5eb561a78d1 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_meta_event.json @@ -0,0 +1,9 @@ +{ + "type": 4, + "timestamp": 1234567890, + "data": { + "href": "https://sentry.io", + "height": 1920, + "width": 1080 + } +} diff --git a/sentry/src/test/resources/json/rrweb_span_event.json b/sentry/src/test/resources/json/rrweb_span_event.json new file mode 100644 index 00000000000..6ec906a3e36 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_span_event.json @@ -0,0 +1,17 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "performanceSpan", + "payload": { + "op": "resource.http", + "description": "https://api.github.com/users/getsentry/repos", + "startTimestamp": 12345678.901, + "endTimestamp": 12345679.901, + "data": { + "status_code": 200, + "method": "POST" + } + } + } +} diff --git a/sentry/src/test/resources/json/rrweb_video_event.json b/sentry/src/test/resources/json/rrweb_video_event.json new file mode 100644 index 00000000000..692dafe879e --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_video_event.json @@ -0,0 +1,21 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "video", + "payload": { + "segmentId": 0, + "size": 4000000, + "duration": 5000, + "encoding":"h264", + "container":"mp4", + "height": 1920, + "width": 1080, + "frameCount": 5, + "frameRate": 1, + "frameRateType": "constant", + "left": 100, + "top": 100 + } + } +} diff --git a/sentry/src/test/resources/json/sentry_envelope_header.json b/sentry/src/test/resources/json/sentry_envelope_header.json index 14c144f8203..5f6b3b25e78 100644 --- a/sentry/src/test/resources/json/sentry_envelope_header.json +++ b/sentry/src/test/resources/json/sentry_envelope_header.json @@ -27,7 +27,8 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" }, "sent_at": "2020-02-07T14:16:00.000Z" } diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json new file mode 100644 index 00000000000..f026c9fee47 --- /dev/null +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -0,0 +1,240 @@ +{ + "type": "replay_event", + "replay_type": "session", + "segment_id": 0, + "timestamp": "1942-07-09T12:55:34.000Z", + "replay_id": "f715e1d64ef64ea3ad7744b5230813c3", + "replay_start_timestamp": "1942-07-09T12:55:34.000Z", + "urls": + [ + "ScreenOne" + ], + "error_ids": + [ + "ab3a347a4cc14fd4b4cf1dc56b670c5b" + ], + "trace_ids": + [ + "340cfef948204549ac07c3b353c81c50" + ], + "event_id": "afcb46b1140ade5187c4bbb5daa804df", + "contexts": + { + "app": + { + "app_identifier": "3b7a3313-53b4-43f4-a6a1-7a7c36a9b0db", + "app_start_time": "1918-11-17T07:46:04.000Z", + "device_app_hash": "3d1fcf36-2c25-4378-bdf8-1e65239f1df4", + "build_type": "d78c56cd-eb0f-4213-8899-cd10ddf20763", + "app_name": "873656fd-f620-4edf-bb7a-a0d13325dba0", + "app_version": "801aab22-ad4b-44fb-995c-bacb5387e20c", + "app_build": "660f0cde-eedb-49dc-a973-8aa1c04f4a28", + "permissions": + { + "WRITE_EXTERNAL_STORAGE": "not_granted", + "CAMERA": "granted" + }, + "in_foreground": true, + "view_names": ["MainActivity", "SidebarActivity"], + "start_type": "cold" + }, + "browser": + { + "name": "e1c723db-7408-4043-baa7-f4e96234e5dc", + "version": "724a48e9-2d35-416b-9f79-132beba2473a" + }, + "device": + { + "name": "83f1de77-fdb0-470e-8249-8f5c5d894ec4", + "manufacturer": "e21b2405-e378-4a0b-ad2c-4822d97cd38c", + "brand": "1abbd13e-d1ca-4d81-bd1b-24aa2c339cf9", + "family": "67a4b8ea-6c38-4c33-8579-7697f538685c", + "model": "d6ca2f35-bcc5-4dd3-ad64-7c3b585e02fd", + "model_id": "d3f133bd-b0a2-4aa4-9eed-875eba93652e", + "archs": + [ + "856e5da3-774c-4663-a830-d19f0b7dbb5b", + "b345bd5a-90a5-4301-a5a2-6c102d7589b6", + "fd7ed64e-a591-49e0-8dc1-578234356d23", + "8cec4101-0305-480b-91ee-f3c007f668c3", + "22583b9b-195e-49bf-bfe8-825ae3a346f2", + "8675b7aa-5b94-42d0-bc14-72ea1bb7112e" + ], + "battery_level": 0.45770407, + "charging": false, + "online": true, + "orientation": "portrait", + "simulator": true, + "memory_size": -6712323365568152393, + "free_memory": -953384122080236886, + "usable_memory": -8999512249221323968, + "low_memory": false, + "storage_size": -3227905175393990709, + "free_storage": -3749039933924297357, + "external_storage_size": -7739608324159255302, + "external_free_storage": -1562576688560812557, + "screen_width_pixels": 1101873181, + "screen_height_pixels": 1902392170, + "screen_density": 0.9829039, + "screen_dpi": -2092079070, + "boot_time": "2004-11-04T08:38:00.000Z", + "timezone": "Europe/Vienna", + "id": "e0fa5c8d-83f5-4e70-bc60-1e82ad30e196", + "language": "6dd45f60-111d-42d8-9204-0452cc836ad8", + "connection_type": "9ceb3a6c-5292-4ed9-8665-5732495e8ed4", + "battery_temperature": 0.14775127, + "processor_count": 4, + "processor_frequency": 800.0, + "cpu_description": "cpu0" + }, + "gpu": + { + "name": "d623a6b5-e1ab-4402-931b-c06f5a43a5ae", + "id": -596576280, + "vendor_id": "1874778041", + "vendor_name": "d732cf76-07dc-48e2-8920-96d6bfc2439d", + "memory_size": -1484004451, + "api_type": "95dfc8bc-88ae-4d66-b85f-6c88ad45b80f", + "multi_threaded_rendering": true, + "version": "3f3f73c3-83a2-423a-8a6f-bb3de0d4a6ae", + "npot_support": "e06b074a-463c-45de-a959-cbabd461d99d" + }, + "os": + { + "name": "686a11a8-eae7-4393-aa10-a1368d523cb2", + "version": "3033f32d-6a27-4715-80c8-b232ce84ca61", + "raw_description": "eb2d0c5e-f5d4-49c7-b876-d8a654ee87cf", + "build": "bd197b97-eb68-49c3-9d07-ef789caf3069", + "kernel_version": "1df24aec-3a6f-49a9-8b50-69ae5f9dde08", + "rooted": true + }, + "response": + { + "cookies": "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;", + "headers": { + "content-type": "text/html" + }, + "status_code": 500, + "body_size": 1000, + "data": + { + "d9d709db-b666-40cc-bcbb-093bb12aad26": "1631d0e6-96b7-4632-85f8-ef69e8bcfb16" + }, + "arbitrary_field": "arbitrary" + }, + "runtime": + { + "name": "4ed019c4-9af9-43e0-830e-bfde9fe4461c", + "version": "16534f6b-1670-4bb8-aec2-647a1b97669b", + "raw_description": "773b5b05-a0f9-4ee6-9f3b-13155c37ad6e" + }, + "trace": + { + "trace_id": "afcb46b1140ade5187c4bbb5daa804df", + "span_id": "bf6b582d-8ce3-412b-a334-f4c5539b9602", + "parent_span_id": "c7500f2a-d4e6-4f5f-a0f4-6bb67e98d5a2", + "op": "e481581d-35a4-4e97-8a1c-b554bf49f23e", + "description": "c204b6c7-9753-4d45-927d-b19789bfc9a5", + "status": "resource_exhausted", + "origin": "auto.test.unit.spancontext", + "tags": + { + "2a5fa3f5-7b87-487f-aaa5-84567aa73642": "4781d51a-c5af-47f2-a4ed-f030c9b3e194", + "29106d7d-7fa4-444f-9d34-b9d7510c69ab": "218c23ea-694a-497e-bf6d-e5f26f1ad7bd", + "ba9ce913-269f-4c03-882d-8ca5e6991b14": "35a74e90-8db8-4610-a411-872cbc1030ac" + } + } + }, + "sdk": + { + "name": "3e934135-3f2b-49bc-8756-9f025b55143e", + "version": "3e31738e-4106-42d0-8be2-4a3a1bc648d3", + "packages": + [ + { + "name": "b59a1949-9950-4203-b394-ddd8d02c9633", + "version": "3d7790f3-7f32-43f7-b82f-9f5bc85205a8" + } + ], + "integrations": + [ + "daec50ae-8729-49b5-82f7-991446745cd5", + "8fc94968-3499-4a2c-b4d7-ecc058d9c1b0" + ] + }, + "request": + { + "url": "67369bc9-64d3-4d31-bfba-37393b145682", + "method": "8185abc3-5411-4041-a0d9-374180081044", + "query_string": "e3dc7659-f42e-413c-a07c-52b24bf9d60d", + "data": + { + "d9d709db-b666-40cc-bcbb-093bb12aad26": "1631d0e6-96b7-4632-85f8-ef69e8bcfb16" + }, + "cookies": "d84f4cfc-5310-4818-ad4f-3f8d22ceaca8", + "headers": + { + "c4991f66-9af9-4914-ac5e-e4854a5a4822": "37714d22-25a7-469b-b762-289b456fbec3" + }, + "env": + { + "6d569c89-5d5e-40e0-a4fc-109b20a53778": "ccadf763-44e4-475c-830c-de6ba0dbd202" + }, + "other": + { + "669ff1c1-517b-46dc-a889-131555364a56": "89043294-f6e1-4e2e-b152-1fdf9b1102fc" + }, + "fragment": "fragment", + "body_size": 1000, + "api_target": "graphql" + }, + "tags": + { + "79ba41db-8dc6-4156-b53e-6cf6d742eb88": "690ce82f-4d5d-4d81-b467-461a41dd9419" + }, + "release": "be9b8133-72f5-497b-adeb-b0a245eebad6", + "environment": "89204175-e462-4628-8acb-3a7fa8d8da7d", + "platform": "38decc78-2711-4a6a-a0be-abb61bfa5a6e", + "user": + { + "email": "c4d61c1b-c144-431e-868f-37a46be5e5f2", + "id": "efb2084b-1871-4b59-8897-b4bd9f196a01", + "username": "60c05dff-7140-4d94-9a61-c9cdd9ca9b96", + "ip_address": "51d22b77-f663-4dbe-8103-8b749d1d9a48", + "name": "c8c60762-b1cf-11ed-afa1-0242ac120002", + "geo": { + "city": "0e6ed0b0-b1c5-11ed-afa1-0242ac120002", + "country_code": "JP", + "region": "273a3d0a-b1c5-11ed-afa1-0242ac120002" + }, + "data": + { + "dc2813d0-0f66-4a3f-a995-71268f61a8fa": "991659ad-7c59-4dd3-bb89-0bd5c74014bd" + } + }, + "dist": "27022a08-aace-40c6-8d0a-358a27fcaa7a", + "debug_meta": + { + "sdk_info": + { + "sdk_name": "182c4407-c1e1-4427-9b5a-ad2e22b1046a", + "version_major": 2045114005, + "version_minor": 1436566288, + "version_patchlevel": 1637914973 + }, + "images": + [ + { + "uuid": "8994027e-1cd9-4be8-b611-88ce08cf16e6", + "type": "fd6e053b-a7fe-4754-916e-bfb3ab77177d", + "debug_id": "8c653f5a-3418-4823-ba91-29a84c9c1235", + "debug_file": "55cc15dd-51f3-4cad-803c-6fd90eac21f6", + "code_id": "01230ece-f729-4af4-8b48-df74700aa4bf", + "code_file": "415c8995-1cb4-4bed-ba5c-5b3d6ba1ad47", + "image_addr": "8a258c81-641d-4e54-b06e-a0f56b1ee2ef", + "image_size": -7905338721846826571, + "arch": "d00d5bea-fb5c-43c9-85f0-dc1350d957a4" + } + ] + } +} diff --git a/sentry/src/test/resources/json/trace_state.json b/sentry/src/test/resources/json/trace_state.json index 17a95fdc334..6ca0e48e616 100644 --- a/sentry/src/test/resources/json/trace_state.json +++ b/sentry/src/test/resources/json/trace_state.json @@ -7,5 +7,6 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 028037372d5..760c6e69054 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include( "sentry-android-fragment", "sentry-android-navigation", "sentry-android-sqlite", + "sentry-android-replay", "sentry-compose", "sentry-compose-helper", "sentry-apollo",