From 2dbe2ce5cff19b796b959667e09a03b906fd4210 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 1 Feb 2024 16:40:27 +0100 Subject: [PATCH 001/184] Add new sentry-android-replay module --- build.gradle.kts | 5 +- buildSrc/src/main/java/Config.kt | 1 + sentry-android-replay/.gitignore | 1 + sentry-android-replay/build.gradle.kts | 78 +++++++++++++++++++ sentry-android-replay/proguard-rules.pro | 3 + sentry-android-replay/src/main/res/public.xml | 4 + settings.gradle.kts | 1 + 7 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 sentry-android-replay/.gitignore create mode 100644 sentry-android-replay/build.gradle.kts create mode 100644 sentry-android-replay/proguard-rules.pro create mode 100644 sentry-android-replay/src/main/res/public.xml diff --git a/build.gradle.kts b/build.gradle.kts index 3f255d4c40..ee983ce5ae 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)) { @@ -305,7 +306,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 7a0081d5f4..8a02ce0e65 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 = 26 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion diff --git a/sentry-android-replay/.gitignore b/sentry-android-replay/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/sentry-android-replay/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts new file mode 100644 index 0000000000..69fa4ef2b4 --- /dev/null +++ b/sentry-android-replay/build.gradle.kts @@ -0,0 +1,78 @@ +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) + id(Config.QualityPlugins.detektPlugin) +} + +android { + compileSdk = Config.Android.compileSdkVersion + namespace = "io.sentry.android.replay" + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersionReplay + + // 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.kotlinTestJunit) + testImplementation(Config.TestLibs.androidxJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) +} + +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 0000000000..738204b4c8 --- /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/res/public.xml b/sentry-android-replay/src/main/res/public.xml new file mode 100644 index 0000000000..379be515be --- /dev/null +++ b/sentry-android-replay/src/main/res/public.xml @@ -0,0 +1,4 @@ + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 028037372d..760c6e6905 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", From d702876c1aa333f23fcd8b1f71d0c365f2f587cc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 1 Feb 2024 16:46:30 +0100 Subject: [PATCH 002/184] Add screenshot recorder --- .../android/replay/ScreenshotRecorder.kt | 150 +++++++++++++ .../sentry/android/replay/WindowRecorder.kt | 89 ++++++++ .../java/io/sentry/android/replay/Windows.kt | 211 ++++++++++++++++++ .../android/replay/video/SimpleFrameMuxer.kt | 20 ++ .../replay/video/SimpleMp4FrameMuxer.kt | 53 +++++ .../replay/video/SimpleVideoEncoder.kt | 166 ++++++++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 157 +++++++++++++ 7 files changed, 846 insertions(+) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt 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 0000000000..93a416291f --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -0,0 +1,150 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.PixelCopy +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import java.lang.ref.WeakReference +import java.util.WeakHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.system.measureTimeMillis + +class ScreenshotRecorder( + val rootView: WeakReference, + val encoder: SimpleVideoEncoder +) : ViewTreeObserver.OnDrawListener { + + private val thread = HandlerThread("SentryReplay").also { it.start() } + private val handler = Handler(thread.looper) + private val bitmapToVH = WeakHashMap() + + companion object { + const val TAG = "ScreenshotRecorder" + } + + private var lastCapturedAtMs: Long? = null + override fun onDraw() { + // cheap debounce for testing + val now = SystemClock.uptimeMillis() + if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { + return + } + lastCapturedAtMs = now + + val root = rootView.get() + if (root == null || root.width <= 0 || root.height <= 0) { + return + } + + val window = root.phoneWindow ?: return + val bitmap = Bitmap.createBitmap( + root.width, + root.height, + Bitmap.Config.ARGB_8888 + ) + Log.e("BITMAP CREATED", bitmap.toString()) + + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + bitmapToVH[bitmap] = rootNode + } + Log.e("TIME", time.toString()) + +// val latch = CountDownLatch(1) + + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + Log.d(TAG, "PixelCopy result: $copyResult") + if (copyResult != PixelCopy.SUCCESS) { + Log.e(TAG, "Failed to capture screenshot") + return@request + } + + Log.e("BITMAP CAPTURED", bitmap.toString()) + val viewHierarchy = bitmapToVH[bitmap] + + if (viewHierarchy != null) { + val canvas = Canvas(bitmap) + viewHierarchy.traverse { + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + val paint = Paint().apply { + color = it.dominantColor ?: Color.BLACK + } + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) + } + } + } + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) +// val baos = ByteArrayOutputStream() +// scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) +// val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) + encoder.encode(scaledBitmap) +// bmp.recycle() + scaledBitmap.recycle() + bitmap.recycle() + Log.i(TAG, "Captured a screenshot") +// latch.countDown() + }, + handler + ) + } + +// val success = latch.await(200, MILLISECONDS) +// Log.i(TAG, "Captured a screenshot: $success") + } + + private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { + callback(this) + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } + } + } + + 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) + childNodes.add(childNode) + child.traverse(childNode) + } + } + parentNode.children = childNodes + } +} 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 0000000000..5d0abe558a --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -0,0 +1,89 @@ +package io.sentry.android.replay + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.media.CamcorderProfile +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowManager +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import java.io.File +import java.lang.ref.WeakReference +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE +import kotlin.math.roundToInt + +class WindowRecorder { + + companion object { + private const val TAG = "WindowRecorder" + } + + private val rootViewsSpy by lazy(NONE) { + RootViewsSpy.install() + } + + private var encoder: SimpleVideoEncoder? = null + private val isRecording = AtomicBoolean(false) + private val recorders: WeakHashMap = WeakHashMap() + + private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + if (added) { + // stop tracking other windows so they don't interfere in the recording like a 25th frame effect + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } + + val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) + recorders[root] = recorder + root.viewTreeObserver?.addOnDrawListener(recorder) + } else { + root.viewTreeObserver?.removeOnDrawListener(recorders[root]) + recorders.remove(root) + + recorders.entries.forEach { + it.key.viewTreeObserver.addOnDrawListener(it.value) + } + } + } + + fun startRecording(context: Context) { + if (isRecording.getAndSet(true)) { + return + } + + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager +// val (height, width) = (wm.currentWindowMetrics.bounds.bottom / +// context.resources.displayMetrics.density).roundToInt() to +// (wm.currentWindowMetrics.bounds.right / +// context.resources.displayMetrics.density).roundToInt() + val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + + val videoFile = File(context.cacheDir, "sentry-sr.mp4") + encoder = SimpleVideoEncoder( + MuxerConfig( + videoFile, + videoWidth = (720 / aspectRatio).roundToInt(), + videoHeight = 720, + frameRate = 1f, + bitrate = 500 * 1000, + ) + ) + encoder?.start() + rootViewsSpy.listeners += onRootViewsChangedListener + } + + fun stopRecording() { + rootViewsSpy.listeners -= onRootViewsChangedListener + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } + recorders.clear() + encoder?.startRelease() + encoder = null + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt new file mode 100644 index 0000000000..eff39a9394 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -0,0 +1,211 @@ +package io.sentry.android.replay + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Build.VERSION.SDK_INT +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. + */ +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 + 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 attachedToPhoneWindow(maybeDecorView: View): Boolean { + return decorViewClass?.let { decorViewClass -> + decorViewClass.isInstance(maybeDecorView) + } ?: false + } + + 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. + */ +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() + + fun copyRootViewList(): List { + return delegatingViewList.toList() + } + + private val delegatingViewList = object : ArrayList() { + 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 { + 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) + } + } + + fun windowManagerMViewsArray(): Array { + val sdkInt = SDK_INT + if (sdkInt >= 19) { + return arrayOf() + } + try { + windowManagerInstance?.let { windowManagerInstance -> + mViewsField?.let { mViewsField -> + @Suppress("UNCHECKED_CAST") + return mViewsField[windowManagerInstance] as Array + } + } + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + } + return arrayOf() + } +} 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 0000000000..435153e376 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt @@ -0,0 +1,20 @@ +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import java.nio.ByteBuffer + +/** + * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/FrameMuxer.kt + */ +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 0000000000..f382aa6b4e --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -0,0 +1,53 @@ +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import android.util.Log +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit + +/** + * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/Mp4FrameMuxer.kt + */ +class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { + private val frameUsec: 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) + Log.i("SimpleMp4FrameMuxer", "start() videoFormat=$videoFormat videoTrackIndex=$videoTrackIndex") + 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 = frameUsec * 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 { + return finalVideoTime + } +} 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 0000000000..e8b156fa76 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -0,0 +1,166 @@ +package io.sentry.android.replay.video + +import android.graphics.Bitmap +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaCodecList.REGULAR_CODECS +import android.media.MediaFormat +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Surface +import io.sentry.android.replay.video.SimpleFrameMuxer +import io.sentry.android.replay.video.SimpleMp4FrameMuxer +import java.io.File + +class SimpleVideoEncoder( + val muxerConfig: MuxerConfig, +) { + companion object { + const val TAG = "SimpleVideoEncoder" + } + + private val mediaFormat: MediaFormat = run { + Log.i(TAG, "mediaFormat creation begin") + + val format = MediaFormat.createVideoFormat( + muxerConfig.mimeType, + muxerConfig.videoWidth, + muxerConfig.videoHeight + ) + + // 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, muxerConfig.bitrate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + Log.i(TAG, "mediaFormat creation end format=$format") + + format + } + + private val mediaCodec: MediaCodec = run { + Log.i(TAG, "mediaCodec creation begin") + +// val codecs = MediaCodecList(REGULAR_CODECS) +// val codecName = codecs.findEncoderForFormat(mediaFormat) +// val codec = MediaCodec.createByCodecName(codecName) + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + + Log.i(TAG, "mediaCodec creation end codec=$codec") + + codec + } + + private val frameMuxer = muxerConfig.frameMuxer + + private var surface: Surface? = null + + fun start() { + mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) + + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + surface = mediaCodec.createInputSurface() + mediaCodec.start() + +// drainCodec(false) + } + + private fun createMediaCodecCallback(): MediaCodec.Callback { + return object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + } + + override fun onOutputBufferAvailable( + codec: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo + ) { + // need to catch, since this is from callback, so there are no + // things like pigeon auto-catch + val encodedData = codec.getOutputBuffer(index)!! + + var effectiveSize = info.size + + if (info.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. + effectiveSize = 0 + } + + if (effectiveSize != 0) { + if (!frameMuxer.isStarted()) { + throw RuntimeException("muxer hasn't started") + } + frameMuxer.muxVideoFrame(encodedData, info) + } + + mediaCodec.releaseOutputBuffer(index, false) + + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + Log.i(TAG, "drainCodec end of stream reached") + actualRelease() + } + } + + override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { + Log.e(TAG, "onError (MediaCodec.Callback)", e) + } + + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + Log.i(TAG, "onOutputFormatChanged format=$format") + + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + Log.i(TAG, "encoder output format changed: $newFormat") + + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) + } + } + } + + 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) + } + + /** + * can only *start* releasing, since it is asynchronous + */ + fun startRelease() { +// drainCodec(true) + mediaCodec.signalEndOfInputStream() + } + + private fun actualRelease() { + mediaCodec.stop() + mediaCodec.release() + surface?.release() + + frameMuxer.release() + } +} + +data class MuxerConfig( + val file: File, + val videoWidth: Int, + val videoHeight: Int, + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, + val frameRate: Float, + val bitrate: Int, + val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate), +) 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 0000000000..d127d79076 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -0,0 +1,157 @@ +package io.sentry.android.replay.viewhierarchy + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +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.view.View +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.ImageView +import android.widget.TextView + +data class ViewHierarchyNode( + val x: Float, + val y: Float, + val width: Int, + val height: Int, + val shouldRedact: Boolean = false, + val dominantColor: Int? = null, + val visibleRect: Rect? = null +) { + + var children: List? = null + + companion object { + + private fun isVisible(view: View?): Boolean { + if (view == null || !view.isShown) { + return false + } + val actualPosition = Rect() + view.getGlobalVisibleRect(actualPosition) + val screen = Rect( + 0, + 0, + view.context.resources.displayMetrics.widthPixels, + view.context.resources.displayMetrics.heightPixels + ) + return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) + } + + fun adjustAlpha(color: Int): Int { + val alpha = 255 + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + fun fromView(view: View): ViewHierarchyNode { + var shouldRedact = false + var dominantColor: Int? = null + var rect: Rect? = null + when (view) { + is TextView -> { + val nodeInfo = AccessibilityNodeInfo() + view.onInitializeAccessibilityNodeInfo(nodeInfo) + shouldRedact = nodeInfo.isVisibleToUser + if (shouldRedact) { + val bounds = Rect() + val text = view.text.toString() + view.paint.getTextBounds(text, 0, text.length, bounds) + dominantColor = adjustAlpha(view.currentTextColor) + rect = Rect() + view.getGlobalVisibleRect(rect) + + var textEnd = Int.MIN_VALUE + var textStart = Int.MAX_VALUE + if (view.layout != null) { + for (i in 0 until view.layout.lineCount) { + val min = view.layout.getLineStart(i) + val minPosition = view.layout.getPrimaryHorizontal(min).toInt() + val max = view.layout.getLineVisibleEnd(i) + val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() + if (minPosition < textStart) { + textStart = minPosition + } + if (maxPosition > textEnd) { + textEnd = maxPosition + } + } + } else { + textEnd = rect.right - rect.left + textStart = 0 + } + // TODO: support known 3rd-party widgets like MaterialButton with an icon + rect.left += textStart + view.paddingStart + rect.right = rect.left + (textEnd - textStart) + } + } + + is ImageView -> { + shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) + if (shouldRedact) { + dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) + rect = Rect() + view.getGlobalVisibleRect(rect) + } + } + } + return ViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + shouldRedact, + dominantColor, + rect + ) + } + + private fun Drawable.isRedactable(): Boolean { + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 + else -> true + } + } + + private fun Drawable.pickDominantColor(): Int { + // TODO: pick default color based on dark/light default theme + return when (this) { + is BitmapDrawable -> { + val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + color + } + + else -> { + if (intrinsicHeight > 0 && intrinsicWidth > 0) { + val bmp = + Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + try { + draw(canvas) + val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + bmp.recycle() + color + } catch (e: Throwable) { + Color.BLACK + } + } else { + Color.BLACK + } + } + } + } + } +} From b1ced8534d462686bf27ca0ad2867c65c0c6e29f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 1 Feb 2024 16:47:15 +0100 Subject: [PATCH 003/184] Add sentry replay envelope and event --- .../core/DefaultAndroidEventProcessor.java | 17 + .../src/main/java/io/sentry/DataCategory.java | 1 + .../main/java/io/sentry/EventProcessor.java | 12 + sentry/src/main/java/io/sentry/Hint.java | 15 + sentry/src/main/java/io/sentry/Hub.java | 22 ++ .../src/main/java/io/sentry/HubAdapter.java | 6 + sentry/src/main/java/io/sentry/IHub.java | 3 + .../main/java/io/sentry/ISentryClient.java | 3 + .../java/io/sentry/MainEventProcessor.java | 10 + sentry/src/main/java/io/sentry/NoOpHub.java | 5 + .../main/java/io/sentry/NoOpSentryClient.java | 6 + .../main/java/io/sentry/ReplayRecording.java | 101 ++++++ sentry/src/main/java/io/sentry/Sentry.java | 5 + .../src/main/java/io/sentry/SentryClient.java | 115 +++++++ .../java/io/sentry/SentryEnvelopeItem.java | 83 ++++- .../main/java/io/sentry/SentryItemType.java | 2 + .../java/io/sentry/SentryReplayEvent.java | 290 ++++++++++++++++++ 17 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ReplayRecording.java create mode 100644 sentry/src/main/java/io/sentry/SentryReplayEvent.java 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 8dcfe196c2..0e1ddc7a3a 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; @@ -256,4 +257,20 @@ 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) { + // we only set memory data if it's not a hard crash, when it's a hard crash the event is + // enriched on restart, so non static data might be wrong, eg lowMemory or availMem will + // be different if the App. crashes because of OOM. + processNonCachedEvent(event, hint); + } + + setCommons(event, false, applyScopeData); + + return event; + } } diff --git a/sentry/src/main/java/io/sentry/DataCategory.java b/sentry/src/main/java/io/sentry/DataCategory.java index 6b119b43e4..3e54f9da4b 100644 --- a/sentry/src/main/java/io/sentry/DataCategory.java +++ b/sentry/src/main/java/io/sentry/DataCategory.java @@ -13,6 +13,7 @@ public enum DataCategory { Monitor("monitor"), Profile("profile"), Transaction("transaction"), + Replay("replay"), Security("security"), UserReport("user_report"), Unknown("unknown"); diff --git a/sentry/src/main/java/io/sentry/EventProcessor.java b/sentry/src/main/java/io/sentry/EventProcessor.java index ba67508614..9e52408edb 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/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index 07dde3cb80..a638d240ab 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -27,6 +27,7 @@ public final class Hint { private final @NotNull Map internalStorage = new HashMap(); private final @NotNull List attachments = new ArrayList<>(); + private final @NotNull List replayRecordings = new ArrayList<>(); private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; @@ -70,6 +71,12 @@ public synchronized void remove(@NotNull String name) { internalStorage.remove(name); } + public void addReplayRecording(final @Nullable ReplayRecording recording) { + if (recording != null) { + replayRecordings.add(recording); + } + } + public void addAttachment(@Nullable Attachment attachment) { if (attachment != null) { attachments.add(attachment); @@ -86,6 +93,10 @@ public void addAttachments(@Nullable List attachments) { return new ArrayList<>(attachments); } + public @NotNull List getReplayRecordings() { + return new ArrayList<>(replayRecordings); + } + public void replaceAttachments(@Nullable List attachments) { clearAttachments(); addAttachments(attachments); @@ -95,6 +106,10 @@ public void clearAttachments() { attachments.clear(); } + public void clearReplayRecordings() { + replayRecordings.clear(); + } + /** * Clears all attributes added via {@link #set(String, Object)} Note: SDK internal attributes are * being kept. This is useful to avoid leaking any objects (e.g. Android activities) being diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index e7942722fc..1d449d401d 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -920,6 +920,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 { + StackItem item = stack.peek(); + sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), null); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error while capturing check-in for slug", 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 68a9bdf11d..ecae05cc98 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -262,6 +262,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 01431043f0..f6b2eab81d 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -572,6 +572,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/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 15b5f25c4b..5ea2e5f847 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -147,6 +147,9 @@ 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/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index abbf21c84e..813d3aaf26 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -149,6 +149,16 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { return transaction; } + @Override + public @NotNull SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + setCommons(event); + 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/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index d186c69ca2..b238704cc5 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -218,6 +218,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/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index a37d09eb89..757b074f82 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -62,6 +62,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/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java new file mode 100644 index 0000000000..26b4d73370 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -0,0 +1,101 @@ +package io.sentry; + +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; + +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 Map unknown; + + // TODO spec it out, good enough for now + private @Nullable List payload; + + @Nullable + public Integer getSegmentId() { + return segmentId; + } + + public void setSegmentId(@Nullable Integer segmentId) { + this.segmentId = segmentId; + } + + @Nullable + public List getPayload() { + return payload; + } + + public void setPayload(@Nullable List payload) { + this.payload = 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(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ReplayRecording deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + + final ReplayRecording replay = new ReplayRecording(); + + @Nullable Map unknown = null; + @Nullable Integer segmentId = 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(); + + replay.setSegmentId(segmentId); + replay.setUnknown(unknown); + return replay; + } + } +} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 0aff89c0d0..392dd0cef2 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1025,4 +1025,9 @@ public interface OptionsConfiguration { public static @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return getCurrentHub().captureCheckIn(checkIn); } + + public static void captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + getCurrentHub().captureReplay(replay, hint); + } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 2973c1f8bc..0b8da468b6 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -246,6 +246,53 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul return sentryId; } + @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.getReplayId() != null) { + sentryId = event.getReplayId(); + } + + 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 { + final SentryEnvelope envelope = + buildEnvelope(event, hint.getReplayRecordings()); + + hint.clear(); + if (envelope != null) { + transport.send(envelope, hint); + } else { + sentryId = SentryId.EMPTY_ID; + } + } 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; + } + private void addScopeAttachmentsToHint(@Nullable IScope scope, @NotNull Hint hint) { if (scope != null) { hint.addAttachments(scope.getAttachments()); @@ -432,6 +479,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."); @@ -485,6 +566,40 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } + private @Nullable SentryEnvelope buildEnvelope( + final @Nullable SentryReplayEvent event, + final @Nullable List replayRecordings + ) { + SentryId sentryId = null; + final List envelopeItems = new ArrayList<>(); + + if (event != null) { + final SentryEnvelopeItem eventItem = + SentryEnvelopeItem.fromEvent(options.getSerializer(), event); + envelopeItems.add(eventItem); + sentryId = event.getEventId(); + } + + if (replayRecordings != null) { + for (final ReplayRecording replayRecording : replayRecordings) { + final SentryEnvelopeItem replayItem = + SentryEnvelopeItem.fromReplayRecording( + options.getSerializer(), options.getLogger(), replayRecording); + envelopeItems.add(replayItem); + } + } + + + if (!envelopeItems.isEmpty()) { + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(sentryId, options.getSdkVersion()); + + return new SentryEnvelope(envelopeHeader, envelopeItems); + } + + return null; + } + /** * Updates the session data based on the event, hint and scope data * diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index acd6b36aa9..f583ff0f0a 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -19,6 +19,7 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.Reader; +import java.io.StringWriter; import java.io.Writer; import java.nio.charset.Charset; import java.util.concurrent.Callable; @@ -102,8 +103,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."); @@ -342,6 +342,85 @@ public ClientReport getClientReport(final @NotNull ISerializer serializer) throw } } + public static SentryEnvelopeItem fromReplayRecording( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull ReplayRecording replayRecording) { + + final CachedItem cachedItem = + new CachedItem( + () -> { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + + // session replay recording format + // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} + + serializer.serialize(replayRecording, writer); + writer.write("\n"); + if (replayRecording.getPayload() != null) { + serializer.serialize(replayRecording.getPayload(), writer); + } + + // final byte[] payload = compressRecordingPayload(serializer, replayRecording); + // stream.write(payload); + + writer.flush(); + stream.flush(); + return stream.toByteArray(); + } + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); + return null; + } + }); + + // try { + // final byte[] data = cachedItem.getBytes(); + // final String dataStr = new String(data, UTF_8); + // + // final String[] items = dataStr.split("\n", 2); + // final String header = items[0]; + // final String payload = items[1]; + // + // final ByteArrayInputStream byteArrayInputStream = new + // ByteArrayInputStream(payload.getBytes(UTF_8)); + // final GZIPInputStream inputStream = new GZIPInputStream(byteArrayInputStream); + // + // final ByteArrayOutputStream decodedData = new ByteArrayOutputStream(); + // + // byte[] buf = new byte[4096]; + // int readLen; + // while ((readLen = inputStream.read(buf, 0, buf.length)) != -1) { + // decodedData.write(buf, 0, readLen); + // } + // + // } catch (Exception e) { + // + // } + + final SentryEnvelopeItemHeader itemHeader = + new SentryEnvelopeItemHeader( + SentryItemType.ReplayRecording, () -> cachedItem.getBytes().length, null, null); + + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + SentryEnvelopeItem item = new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); + + try { + StringWriter writer = new StringWriter(); + serializer.serialize(item.header, writer); + writer.flush(); + writer.flush(); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "f", e); + } + return item; + } + + private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index c4535cb6a1..69aa7b7a92 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -32,6 +32,8 @@ public static SentryItemType resolve(Object item) { return Session; } else if (item instanceof ClientReport) { return ClientReport; + } else if (item instanceof SentryReplayEvent) { + return ReplayEvent; } else { return Attachment; } 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 0000000000..8de7599422 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -0,0 +1,290 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +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 SentryReplayEvent extends SentryBaseEvent + implements JsonUnknown, JsonSerializable { + + 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"; + } + + private @Nullable String type; + private @Nullable String replayType; + private @Nullable SentryId replayId; + private @Nullable Integer segmentId; + private @Nullable Double timestamp; + private @Nullable Double replayStartTimestamp; + private @Nullable List urls; + private @Nullable List errorIds; + private @Nullable List traceIds; + private @Nullable Map unknown; + + public SentryReplayEvent() { + super(); + this.replayId = this.getEventId(); + this.type = "replay_event"; + this.replayType = "session"; + this.errorIds = new ArrayList<>(); + this.traceIds = new ArrayList<>(); + this.urls = new ArrayList<>(); + } + + @Nullable + public String getType() { + return type; + } + + public void setType(final @Nullable String type) { + this.type = type; + } + + @Nullable + public SentryId getReplayId() { + return replayId; + } + + public void setReplayId(final @Nullable SentryId replayId) { + this.replayId = replayId; + } + + @Nullable + public Integer getSegmentId() { + return segmentId; + } + + public void setSegmentId(final @Nullable Integer segmentId) { + this.segmentId = segmentId; + } + + @Nullable + public Double getTimestamp() { + return timestamp; + } + + public void setTimestamp(final @Nullable Double timestamp) { + this.timestamp = timestamp; + } + + @Nullable + public Double getReplayStartTimestamp() { + return replayStartTimestamp; + } + + public void setReplayStartTimestamp(final @Nullable Double 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; + } + + @Nullable + public String getReplayType() { + return replayType; + } + + public void setReplayType(@Nullable String replayType) { + this.replayType = replayType; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (type != null) { + writer.name(JsonKeys.TYPE).value(type); + } + if (replayType != null) { + writer.name(JsonKeys.REPLAY_TYPE).value(replayType); + } + if (replayId != null) { + writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); + } + if (segmentId != null) { + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + } + if (timestamp != null) { + writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + } + 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 { + + @Override + public @NotNull SentryReplayEvent deserialize( + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + + SentryBaseEvent.Deserializer baseEventDeserializer = new SentryBaseEvent.Deserializer(); + + final SentryReplayEvent replay = new SentryReplayEvent(); + + @Nullable Map unknown = null; + @Nullable String type = null; + @Nullable String replayType = null; + @Nullable SentryId replayId = null; + @Nullable Integer segmentId = null; + @Nullable Double timestamp = null; + @Nullable Double 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.nextStringOrNull(); + 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 = nextTimestamp(reader, logger); + break; + case JsonKeys.REPLAY_START_TIMESTAMP: + replayStartTimestamp = nextTimestamp(reader, logger); + break; + case JsonKeys.URLS: + urls = nextStringList(reader); + break; + case JsonKeys.ERROR_IDS: + errorIds = nextStringList(reader); + break; + case JsonKeys.TRACE_IDS: + traceIds = nextStringList(reader); + break; + default: + if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + reader.endObject(); + + replay.setType(type); + replay.setReplayType(replayType); + replay.setReplayId(replayId); + replay.setSegmentId(segmentId); + replay.setTimestamp(timestamp); + replay.setReplayStartTimestamp(replayStartTimestamp); + replay.setUrls(urls); + replay.setErrorIds(errorIds); + replay.setTraceIds(traceIds); + replay.setUnknown(unknown); + return replay; + } + + @Nullable + private static Double nextTimestamp( + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws IOException { + @Nullable Double result; + try { + result = reader.nextDoubleOrNull(); + } catch (NumberFormatException e) { + final Date date = reader.nextDateOrNull(logger); + result = date != null ? DateUtils.dateToSeconds(date) : null; + } + return result; + } + + @Nullable + private static List nextStringList(final @NotNull JsonObjectReader reader) + throws IOException { + @Nullable List result = null; + final @Nullable Object data = reader.nextObjectOrNull(); + if (data instanceof List) { + result = new ArrayList<>(((List) data).size()); + for (Object item : (List) data) { + if (item instanceof String) { + result.add((String) item); + } + } + } + return result; + } + } +} From 11f699cee5a031b685b6577c251589d194ecb005 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 11:44:48 +0100 Subject: [PATCH 004/184] Add TODOs and license headers --- .../android/replay/ScreenshotRecorder.kt | 3 +- .../sentry/android/replay/WindowRecorder.kt | 5 +- .../java/io/sentry/android/replay/Windows.kt | 47 +++++++--------- .../android/replay/video/SimpleFrameMuxer.kt | 33 ++++++++++- .../replay/video/SimpleMp4FrameMuxer.kt | 32 ++++++++++- .../replay/video/SimpleVideoEncoder.kt | 56 ++++++++++--------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 7 +++ 7 files changed, 118 insertions(+), 65 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 93a416291f..feae4557a6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -22,7 +22,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.system.measureTimeMillis -class ScreenshotRecorder( +internal class ScreenshotRecorder( val rootView: WeakReference, val encoder: SimpleVideoEncoder ) : ViewTreeObserver.OnDrawListener { @@ -37,7 +37,6 @@ class ScreenshotRecorder( private var lastCapturedAtMs: Long? = null override fun onDraw() { - // cheap debounce for testing val now = SystemClock.uptimeMillis() if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { return diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 5d0abe558a..7ded87f785 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -19,10 +19,6 @@ import kotlin.math.roundToInt class WindowRecorder { - companion object { - private const val TAG = "WindowRecorder" - } - private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } @@ -61,6 +57,7 @@ class WindowRecorder { // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() + // TODO: support this for api level < 30 val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() val videoFile = File(context.cacheDir, "sentry-sr.mp4") diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index eff39a9394..b621465c20 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -1,3 +1,21 @@ +/** + * Adapted from https://github.com/square/curtains + * + * 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 @@ -40,6 +58,7 @@ internal object WindowSpy { */ 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" @@ -80,12 +99,6 @@ internal object WindowSpy { } } - fun attachedToPhoneWindow(maybeDecorView: View): Boolean { - return decorViewClass?.let { decorViewClass -> - decorViewClass.isInstance(maybeDecorView) - } ?: false - } - fun pullWindow(maybeDecorView: View): Window? { return decorViewClass?.let { decorViewClass -> if (decorViewClass.isInstance(maybeDecorView)) { @@ -122,10 +135,6 @@ internal class RootViewsSpy private constructor() { val listeners = CopyOnWriteArrayList() - fun copyRootViewList(): List { - return delegatingViewList.toList() - } - private val delegatingViewList = object : ArrayList() { override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } @@ -190,22 +199,4 @@ internal object WindowManagerSpy { Log.w("WindowManagerSpy", ignored) } } - - fun windowManagerMViewsArray(): Array { - val sdkInt = SDK_INT - if (sdkInt >= 19) { - return arrayOf() - } - try { - windowManagerInstance?.let { windowManagerInstance -> - mViewsField?.let { mViewsField -> - @Suppress("UNCHECKED_CAST") - return mViewsField[windowManagerInstance] as Array - } - } - } catch (ignored: Throwable) { - Log.w("WindowManagerSpy", ignored) - } - return arrayOf() - } } 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 index 435153e376..70bb8cff46 100644 --- 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 @@ -1,12 +1,39 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/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 -/** - * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/FrameMuxer.kt - */ interface SimpleFrameMuxer { fun isStarted(): Boolean 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 index f382aa6b4e..69f92701d3 100644 --- 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 @@ -1,3 +1,32 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/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 @@ -7,9 +36,6 @@ import android.util.Log import java.nio.ByteBuffer import java.util.concurrent.TimeUnit -/** - * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/Mp4FrameMuxer.kt - */ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index e8b156fa76..5b52347957 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,3 +1,32 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.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.graphics.Bitmap @@ -14,16 +43,10 @@ import io.sentry.android.replay.video.SimpleFrameMuxer import io.sentry.android.replay.video.SimpleMp4FrameMuxer import java.io.File -class SimpleVideoEncoder( +internal class SimpleVideoEncoder( val muxerConfig: MuxerConfig, ) { - companion object { - const val TAG = "SimpleVideoEncoder" - } - private val mediaFormat: MediaFormat = run { - Log.i(TAG, "mediaFormat creation begin") - val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, muxerConfig.videoWidth, @@ -40,21 +63,15 @@ class SimpleVideoEncoder( format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) - Log.i(TAG, "mediaFormat creation end format=$format") - format } private val mediaCodec: MediaCodec = run { - Log.i(TAG, "mediaCodec creation begin") - // val codecs = MediaCodecList(REGULAR_CODECS) // val codecName = codecs.findEncoderForFormat(mediaFormat) // val codec = MediaCodec.createByCodecName(codecName) val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) - Log.i(TAG, "mediaCodec creation end codec=$codec") - codec } @@ -68,8 +85,6 @@ class SimpleVideoEncoder( mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) surface = mediaCodec.createInputSurface() mediaCodec.start() - -// drainCodec(false) } private fun createMediaCodecCallback(): MediaCodec.Callback { @@ -82,8 +97,6 @@ class SimpleVideoEncoder( index: Int, info: MediaCodec.BufferInfo ) { - // need to catch, since this is from callback, so there are no - // things like pigeon auto-catch val encodedData = codec.getOutputBuffer(index)!! var effectiveSize = info.size @@ -104,25 +117,19 @@ class SimpleVideoEncoder( mediaCodec.releaseOutputBuffer(index, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - Log.i(TAG, "drainCodec end of stream reached") actualRelease() } } override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - Log.e(TAG, "onError (MediaCodec.Callback)", e) } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - Log.i(TAG, "onOutputFormatChanged format=$format") - // should happen before receiving buffers, and should only happen once if (frameMuxer.isStarted()) { throw RuntimeException("format changed twice") } val newFormat: MediaFormat = mediaCodec.outputFormat - Log.i(TAG, "encoder output format changed: $newFormat") - // now that we have the Magic Goodies, start the muxer frameMuxer.start(newFormat) } @@ -142,7 +149,6 @@ class SimpleVideoEncoder( * can only *start* releasing, since it is asynchronous */ fun startRelease() { -// drainCodec(true) mediaCodec.signalEndOfInputStream() } @@ -155,7 +161,7 @@ class SimpleVideoEncoder( } } -data class MuxerConfig( +internal data class MuxerConfig( val file: File, val videoWidth: Int, val videoHeight: Int, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index d127d79076..d6d92c4858 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -15,6 +15,7 @@ import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView import android.widget.TextView +// TODO: merge with ViewHierarchyNode from sentry-core maybe? data class ViewHierarchyNode( val x: Float, val y: Float, @@ -53,6 +54,8 @@ data class ViewHierarchyNode( } fun fromView(view: View): ViewHierarchyNode { + // TODO: Extract redacting into its own class/function + // TODO: extract redacting into a separate thread? var shouldRedact = false var dominantColor: Int? = null var rect: Rect? = null @@ -89,6 +92,7 @@ data class ViewHierarchyNode( textStart = 0 } // TODO: support known 3rd-party widgets like MaterialButton with an icon + // TODO: also calculate height properly based on text bounds rect.left += textStart + view.paddingStart rect.right = rect.left + (textEnd - textStart) } @@ -134,6 +138,9 @@ data class ViewHierarchyNode( else -> { if (intrinsicHeight > 0 && intrinsicWidth > 0) { + // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil + // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color + // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time val bmp = Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) From dd0e9a4892aa652c472af627851d2245f2d52266 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 12:03:40 +0100 Subject: [PATCH 005/184] Api dump --- .../api/sentry-android-replay.api | 63 +++++++++++++++++++ .../java/io/sentry/android/replay/Windows.kt | 4 +- 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 sentry-android-replay/api/sentry-android-replay.api 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 0000000000..b6029a3370 --- /dev/null +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -0,0 +1,63 @@ +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 final class io/sentry/android/replay/WindowRecorder { + public fun ()V + public final fun startRecording (Landroid/content/Context;)V + public final fun stopRecording ()V +} + +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 final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; + public fun (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;)V + public synthetic fun (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()F + public final fun component2 ()F + public final fun component3 ()I + public final fun component4 ()I + public final fun component5 ()Z + public final fun component6 ()Ljava/lang/Integer; + public final fun component7 ()Landroid/graphics/Rect; + public final fun copy (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public static synthetic fun copy$default (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;FFIIZLjava/lang/Integer;Landroid/graphics/Rect;ILjava/lang/Object;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public fun equals (Ljava/lang/Object;)Z + public final fun getChildren ()Ljava/util/List; + public final fun getDominantColor ()Ljava/lang/Integer; + public final fun getHeight ()I + 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 fun hashCode ()I + public final fun setChildren (Ljava/util/List;)V + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { + public final fun adjustAlpha (I)I + public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; +} + diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index b621465c20..fc45aebf0f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -35,7 +35,7 @@ import kotlin.LazyThreadSafetyMode.NONE * Note: this property is called [phoneWindow] because the only implementation of [Window] is * the internal class android.view.PhoneWindow. */ -val View.phoneWindow: Window? +internal val View.phoneWindow: Window? get() { return WindowSpy.pullWindow(rootView) } @@ -117,7 +117,7 @@ internal object WindowSpy { * If you only care about either attached or detached, consider implementing [OnRootViewAddedListener] * or [OnRootViewRemovedListener] instead. */ -fun interface OnRootViewsChangedListener { +internal fun interface OnRootViewsChangedListener { /** * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] * are called. From d34ddee5d836060f2ba9a2e719b23b14c9747b18 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 12:06:53 +0100 Subject: [PATCH 006/184] Formatting --- .../android/replay/ScreenshotRecorder.kt | 206 +++++++------- .../sentry/android/replay/WindowRecorder.kt | 104 ++++---- .../java/io/sentry/android/replay/Windows.kt | 249 ++++++++--------- .../replay/video/SimpleVideoEncoder.kt | 219 ++++++++------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 252 +++++++++--------- 5 files changed, 510 insertions(+), 520 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index feae4557a6..442e740796 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -18,132 +18,130 @@ import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.system.measureTimeMillis internal class ScreenshotRecorder( - val rootView: WeakReference, - val encoder: SimpleVideoEncoder + val rootView: WeakReference, + val encoder: SimpleVideoEncoder ) : ViewTreeObserver.OnDrawListener { - private val thread = HandlerThread("SentryReplay").also { it.start() } - private val handler = Handler(thread.looper) - private val bitmapToVH = WeakHashMap() + private val thread = HandlerThread("SentryReplay").also { it.start() } + private val handler = Handler(thread.looper) + private val bitmapToVH = WeakHashMap() - companion object { - const val TAG = "ScreenshotRecorder" - } - - private var lastCapturedAtMs: Long? = null - override fun onDraw() { - val now = SystemClock.uptimeMillis() - if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { - return + companion object { + const val TAG = "ScreenshotRecorder" } - lastCapturedAtMs = now - val root = rootView.get() - if (root == null || root.width <= 0 || root.height <= 0) { - return - } - - val window = root.phoneWindow ?: return - val bitmap = Bitmap.createBitmap( - root.width, - root.height, - Bitmap.Config.ARGB_8888 - ) - Log.e("BITMAP CREATED", bitmap.toString()) - - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - bitmapToVH[bitmap] = rootNode - } - Log.e("TIME", time.toString()) + private var lastCapturedAtMs: Long? = null + override fun onDraw() { + val now = SystemClock.uptimeMillis() + if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { + return + } + lastCapturedAtMs = now + + val root = rootView.get() + if (root == null || root.width <= 0 || root.height <= 0) { + return + } + + val window = root.phoneWindow ?: return + val bitmap = Bitmap.createBitmap( + root.width, + root.height, + Bitmap.Config.ARGB_8888 + ) + Log.e("BITMAP CREATED", bitmap.toString()) + + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + bitmapToVH[bitmap] = rootNode + } + Log.e("TIME", time.toString()) // val latch = CountDownLatch(1) - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - PixelCopy.request( - window, - bitmap, - { copyResult: Int -> - Log.d(TAG, "PixelCopy result: $copyResult") - if (copyResult != PixelCopy.SUCCESS) { - Log.e(TAG, "Failed to capture screenshot") - return@request - } - - Log.e("BITMAP CAPTURED", bitmap.toString()) - val viewHierarchy = bitmapToVH[bitmap] - - if (viewHierarchy != null) { - val canvas = Canvas(bitmap) - viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - val paint = Paint().apply { - color = it.dominantColor ?: Color.BLACK - } - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) - } - } - } - - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, - true - ) + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + Log.d(TAG, "PixelCopy result: $copyResult") + if (copyResult != PixelCopy.SUCCESS) { + Log.e(TAG, "Failed to capture screenshot") + return@request + } + + Log.e("BITMAP CAPTURED", bitmap.toString()) + val viewHierarchy = bitmapToVH[bitmap] + + if (viewHierarchy != null) { + val canvas = Canvas(bitmap) + viewHierarchy.traverse { + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + val paint = Paint().apply { + color = it.dominantColor ?: Color.BLACK + } + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) + } + } + } + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) // val baos = ByteArrayOutputStream() // scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) // val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) - encoder.encode(scaledBitmap) + encoder.encode(scaledBitmap) // bmp.recycle() - scaledBitmap.recycle() - bitmap.recycle() - Log.i(TAG, "Captured a screenshot") + scaledBitmap.recycle() + bitmap.recycle() + Log.i(TAG, "Captured a screenshot") // latch.countDown() - }, - handler - ) - } + }, + handler + ) + } // val success = latch.await(200, MILLISECONDS) // Log.i(TAG, "Captured a screenshot: $success") - } - - private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { - callback(this) - if (this.children != null) { - this.children!!.forEach { - it.traverse(callback) - } - } - } - - private fun View.traverse(parentNode: ViewHierarchyNode) { - if (this !is ViewGroup) { - return } - if (this.childCount == 0) { - return + private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { + callback(this) + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } + } } - val childNodes = ArrayList(this.childCount) - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child != null) { - val childNode = ViewHierarchyNode.fromView(child) - childNodes.add(childNode) - child.traverse(childNode) - } + 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) + childNodes.add(childNode) + child.traverse(childNode) + } + } + parentNode.children = childNodes } - parentNode.children = childNodes - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 7ded87f785..125c5ce461 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,10 +1,6 @@ package io.sentry.android.replay import android.content.Context -import android.content.res.Configuration -import android.content.res.Configuration.ORIENTATION_LANDSCAPE -import android.media.CamcorderProfile -import android.util.DisplayMetrics import android.view.View import android.view.ViewTreeObserver import android.view.WindowManager @@ -19,68 +15,68 @@ import kotlin.math.roundToInt class WindowRecorder { - private val rootViewsSpy by lazy(NONE) { - RootViewsSpy.install() - } + private val rootViewsSpy by lazy(NONE) { + RootViewsSpy.install() + } - private var encoder: SimpleVideoEncoder? = null - private val isRecording = AtomicBoolean(false) - private val recorders: WeakHashMap = WeakHashMap() + private var encoder: SimpleVideoEncoder? = null + private val isRecording = AtomicBoolean(false) + private val recorders: WeakHashMap = WeakHashMap() - private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> - if (added) { - // stop tracking other windows so they don't interfere in the recording like a 25th frame effect - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) - } + private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + if (added) { + // stop tracking other windows so they don't interfere in the recording like a 25th frame effect + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } - val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) - recorders[root] = recorder - root.viewTreeObserver?.addOnDrawListener(recorder) - } else { - root.viewTreeObserver?.removeOnDrawListener(recorders[root]) - recorders.remove(root) + val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) + recorders[root] = recorder + root.viewTreeObserver?.addOnDrawListener(recorder) + } else { + root.viewTreeObserver?.removeOnDrawListener(recorders[root]) + recorders.remove(root) - recorders.entries.forEach { - it.key.viewTreeObserver.addOnDrawListener(it.value) - } + recorders.entries.forEach { + it.key.viewTreeObserver.addOnDrawListener(it.value) + } + } } - } - fun startRecording(context: Context) { - if (isRecording.getAndSet(true)) { - return - } + fun startRecording(context: Context) { + if (isRecording.getAndSet(true)) { + return + } - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager // val (height, width) = (wm.currentWindowMetrics.bounds.bottom / // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() - // TODO: support this for api level < 30 - val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + // TODO: support this for api level < 30 + val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() - val videoFile = File(context.cacheDir, "sentry-sr.mp4") - encoder = SimpleVideoEncoder( - MuxerConfig( - videoFile, - videoWidth = (720 / aspectRatio).roundToInt(), - videoHeight = 720, - frameRate = 1f, - bitrate = 500 * 1000, - ) - ) - encoder?.start() - rootViewsSpy.listeners += onRootViewsChangedListener - } + val videoFile = File(context.cacheDir, "sentry-sr.mp4") + encoder = SimpleVideoEncoder( + MuxerConfig( + videoFile, + videoWidth = (720 / aspectRatio).roundToInt(), + videoHeight = 720, + frameRate = 1f, + bitrate = 500 * 1000 + ) + ) + encoder?.start() + rootViewsSpy.listeners += onRootViewsChangedListener + } - fun stopRecording() { - rootViewsSpy.listeners -= onRootViewsChangedListener - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) + fun stopRecording() { + rootViewsSpy.listeners -= onRootViewsChangedListener + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } + recorders.clear() + encoder?.startRelease() + encoder = null } - recorders.clear() - encoder?.startRelease() - encoder = null - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index fc45aebf0f..7238c8cc2d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -19,7 +19,6 @@ package io.sentry.android.replay import android.annotation.SuppressLint -import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.view.View @@ -36,80 +35,82 @@ import kotlin.LazyThreadSafetyMode.NONE * the internal class android.view.PhoneWindow. */ internal val View.phoneWindow: Window? - get() { - return WindowSpy.pullWindow(rootView) - } - + 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 + /** + * 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 - } + + /** + * 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 + fun pullWindow(maybeDecorView: View): Window? { + return decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> + windowField[maybeDecorView] as Window + } + } else { + null + } } - } else { - null - } } - } } /** @@ -118,14 +119,14 @@ internal object WindowSpy { * 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 - ) + /** + * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] + * are called. + */ + fun onRootViewsChanged( + view: View, + added: Boolean + ) } /** @@ -133,70 +134,70 @@ internal fun interface OnRootViewsChangedListener { */ internal class RootViewsSpy private constructor() { - val listeners = CopyOnWriteArrayList() + val listeners = CopyOnWriteArrayList() - private val delegatingViewList = object : ArrayList() { - override fun add(element: View): Boolean { - listeners.forEach { it.onRootViewsChanged(element, true) } - return super.add(element) - } + private val delegatingViewList = object : ArrayList() { + 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 + 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 { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } + companion object { + fun install(): RootViewsSpy { + return RootViewsSpy().apply { + 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 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 } + private val windowManagerInstance by lazy(NONE) { + windowManagerClass?.getMethod("getInstance")?.invoke(null) } - } - // 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 + private val mViewsField by lazy(NONE) { + windowManagerClass?.let { windowManagerClass -> + windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true } + } } - try { - windowManagerInstance?.let { windowManagerInstance -> - mViewsField?.let { mViewsField -> - @Suppress("UNCHECKED_CAST") - val mViews = mViewsField[windowManagerInstance] as ArrayList - mViewsField[windowManagerInstance] = swap(mViews) + + // 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) } - } - } catch (ignored: Throwable) { - Log.w("WindowManagerSpy", ignored) } - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 5b52347957..70fe81d7a4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -32,141 +32,136 @@ package io.sentry.android.replay.video import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo -import android.media.MediaCodecList -import android.media.MediaCodecList.REGULAR_CODECS import android.media.MediaFormat import android.os.Handler import android.os.Looper -import android.util.Log import android.view.Surface -import io.sentry.android.replay.video.SimpleFrameMuxer -import io.sentry.android.replay.video.SimpleMp4FrameMuxer import java.io.File internal class SimpleVideoEncoder( - val muxerConfig: MuxerConfig, + val muxerConfig: MuxerConfig ) { - private val mediaFormat: MediaFormat = run { - val format = MediaFormat.createVideoFormat( - muxerConfig.mimeType, - muxerConfig.videoWidth, - muxerConfig.videoHeight - ) - - // 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, muxerConfig.bitrate) - format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) - - format - } - - private val mediaCodec: MediaCodec = run { + private val mediaFormat: MediaFormat = run { + val format = MediaFormat.createVideoFormat( + muxerConfig.mimeType, + muxerConfig.videoWidth, + muxerConfig.videoHeight + ) + + // 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, muxerConfig.bitrate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + format + } + + private val mediaCodec: MediaCodec = run { // val codecs = MediaCodecList(REGULAR_CODECS) // val codecName = codecs.findEncoderForFormat(mediaFormat) // val codec = MediaCodec.createByCodecName(codecName) - val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) - codec - } - - private val frameMuxer = muxerConfig.frameMuxer + codec + } - private var surface: Surface? = null + private val frameMuxer = muxerConfig.frameMuxer - fun start() { - mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) + private var surface: Surface? = null - mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - surface = mediaCodec.createInputSurface() - mediaCodec.start() - } + fun start() { + mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) - private fun createMediaCodecCallback(): MediaCodec.Callback { - return object : MediaCodec.Callback() { - override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { - } + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + surface = mediaCodec.createInputSurface() + mediaCodec.start() + } - override fun onOutputBufferAvailable( - codec: MediaCodec, - index: Int, - info: MediaCodec.BufferInfo - ) { - val encodedData = codec.getOutputBuffer(index)!! + private fun createMediaCodecCallback(): MediaCodec.Callback { + return object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + } - var effectiveSize = info.size + override fun onOutputBufferAvailable( + codec: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo + ) { + val encodedData = codec.getOutputBuffer(index)!! + + var effectiveSize = info.size + + if (info.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. + effectiveSize = 0 + } + + if (effectiveSize != 0) { + if (!frameMuxer.isStarted()) { + throw RuntimeException("muxer hasn't started") + } + frameMuxer.muxVideoFrame(encodedData, info) + } + + mediaCodec.releaseOutputBuffer(index, false) + + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + actualRelease() + } + } - if (info.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. - effectiveSize = 0 - } + override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { + } - if (effectiveSize != 0) { - if (!frameMuxer.isStarted()) { - throw RuntimeException("muxer hasn't started") + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) } - frameMuxer.muxVideoFrame(encodedData, info) - } - - mediaCodec.releaseOutputBuffer(index, false) - - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - actualRelease() - } - } - - override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - } - - override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - // should happen before receiving buffers, and should only happen once - if (frameMuxer.isStarted()) { - throw RuntimeException("format changed twice") - } - val newFormat: MediaFormat = mediaCodec.outputFormat - // now that we have the Magic Goodies, start the muxer - frameMuxer.start(newFormat) - } + } + } + + 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) + } + + /** + * can only *start* releasing, since it is asynchronous + */ + fun startRelease() { + mediaCodec.signalEndOfInputStream() + } + + private fun actualRelease() { + mediaCodec.stop() + mediaCodec.release() + surface?.release() + + frameMuxer.release() } - } - - 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) - } - - /** - * can only *start* releasing, since it is asynchronous - */ - fun startRelease() { - mediaCodec.signalEndOfInputStream() - } - - private fun actualRelease() { - mediaCodec.stop() - mediaCodec.release() - surface?.release() - - frameMuxer.release() - } } internal data class MuxerConfig( - val file: File, - val videoWidth: Int, - val videoHeight: Int, - val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, - val frameRate: Float, - val bitrate: Int, - val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate), + val file: File, + val videoWidth: Int, + val videoHeight: Int, + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, + val frameRate: Float, + val bitrate: Int, + val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate) ) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index d6d92c4858..9dcf117b91 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -17,148 +17,148 @@ import android.widget.TextView // TODO: merge with ViewHierarchyNode from sentry-core maybe? data class ViewHierarchyNode( - val x: Float, - val y: Float, - val width: Int, - val height: Int, - val shouldRedact: Boolean = false, - val dominantColor: Int? = null, - val visibleRect: Rect? = null + val x: Float, + val y: Float, + val width: Int, + val height: Int, + val shouldRedact: Boolean = false, + val dominantColor: Int? = null, + val visibleRect: Rect? = null ) { - var children: List? = null + var children: List? = null - companion object { + companion object { - private fun isVisible(view: View?): Boolean { - if (view == null || !view.isShown) { - return false - } - val actualPosition = Rect() - view.getGlobalVisibleRect(actualPosition) - val screen = Rect( - 0, - 0, - view.context.resources.displayMetrics.widthPixels, - view.context.resources.displayMetrics.heightPixels - ) - return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) - } + private fun isVisible(view: View?): Boolean { + if (view == null || !view.isShown) { + return false + } + val actualPosition = Rect() + view.getGlobalVisibleRect(actualPosition) + val screen = Rect( + 0, + 0, + view.context.resources.displayMetrics.widthPixels, + view.context.resources.displayMetrics.heightPixels + ) + return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) + } - fun adjustAlpha(color: Int): Int { - val alpha = 255 - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb(alpha, red, green, blue) - } + fun adjustAlpha(color: Int): Int { + val alpha = 255 + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } - fun fromView(view: View): ViewHierarchyNode { - // TODO: Extract redacting into its own class/function - // TODO: extract redacting into a separate thread? - var shouldRedact = false - var dominantColor: Int? = null - var rect: Rect? = null - when (view) { - is TextView -> { - val nodeInfo = AccessibilityNodeInfo() - view.onInitializeAccessibilityNodeInfo(nodeInfo) - shouldRedact = nodeInfo.isVisibleToUser - if (shouldRedact) { - val bounds = Rect() - val text = view.text.toString() - view.paint.getTextBounds(text, 0, text.length, bounds) - dominantColor = adjustAlpha(view.currentTextColor) - rect = Rect() - view.getGlobalVisibleRect(rect) + fun fromView(view: View): ViewHierarchyNode { + // TODO: Extract redacting into its own class/function + // TODO: extract redacting into a separate thread? + var shouldRedact = false + var dominantColor: Int? = null + var rect: Rect? = null + when (view) { + is TextView -> { + val nodeInfo = AccessibilityNodeInfo() + view.onInitializeAccessibilityNodeInfo(nodeInfo) + shouldRedact = nodeInfo.isVisibleToUser + if (shouldRedact) { + val bounds = Rect() + val text = view.text.toString() + view.paint.getTextBounds(text, 0, text.length, bounds) + dominantColor = adjustAlpha(view.currentTextColor) + rect = Rect() + view.getGlobalVisibleRect(rect) - var textEnd = Int.MIN_VALUE - var textStart = Int.MAX_VALUE - if (view.layout != null) { - for (i in 0 until view.layout.lineCount) { - val min = view.layout.getLineStart(i) - val minPosition = view.layout.getPrimaryHorizontal(min).toInt() - val max = view.layout.getLineVisibleEnd(i) - val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() - if (minPosition < textStart) { - textStart = minPosition + var textEnd = Int.MIN_VALUE + var textStart = Int.MAX_VALUE + if (view.layout != null) { + for (i in 0 until view.layout.lineCount) { + val min = view.layout.getLineStart(i) + val minPosition = view.layout.getPrimaryHorizontal(min).toInt() + val max = view.layout.getLineVisibleEnd(i) + val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() + if (minPosition < textStart) { + textStart = minPosition + } + if (maxPosition > textEnd) { + textEnd = maxPosition + } + } + } else { + textEnd = rect.right - rect.left + textStart = 0 + } + // TODO: support known 3rd-party widgets like MaterialButton with an icon + // TODO: also calculate height properly based on text bounds + rect.left += textStart + view.paddingStart + rect.right = rect.left + (textEnd - textStart) + } } - if (maxPosition > textEnd) { - textEnd = maxPosition + + is ImageView -> { + shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) + if (shouldRedact) { + dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) + rect = Rect() + view.getGlobalVisibleRect(rect) + } } - } - } else { - textEnd = rect.right - rect.left - textStart = 0 } - // TODO: support known 3rd-party widgets like MaterialButton with an icon - // TODO: also calculate height properly based on text bounds - rect.left += textStart + view.paddingStart - rect.right = rect.left + (textEnd - textStart) - } + return ViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + shouldRedact, + dominantColor, + rect + ) } - is ImageView -> { - shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) - if (shouldRedact) { - dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) - rect = Rect() - view.getGlobalVisibleRect(rect) - } + private fun Drawable.isRedactable(): Boolean { + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 + else -> true + } } - } - return ViewHierarchyNode( - view.x, - view.y, - view.width, - view.height, - shouldRedact, - dominantColor, - rect - ) - } - - private fun Drawable.isRedactable(): Boolean { - return when (this) { - is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false - is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 - else -> true - } - } - private fun Drawable.pickDominantColor(): Int { - // TODO: pick default color based on dark/light default theme - return when (this) { - is BitmapDrawable -> { - val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - color - } + private fun Drawable.pickDominantColor(): Int { + // TODO: pick default color based on dark/light default theme + return when (this) { + is BitmapDrawable -> { + val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + color + } - else -> { - if (intrinsicHeight > 0 && intrinsicWidth > 0) { - // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil - // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color - // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time - val bmp = - Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - try { - draw(canvas) - val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - bmp.recycle() - color - } catch (e: Throwable) { - Color.BLACK + else -> { + if (intrinsicHeight > 0 && intrinsicWidth > 0) { + // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil + // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color + // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time + val bmp = + Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + try { + draw(canvas) + val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + bmp.recycle() + color + } catch (e: Throwable) { + Color.BLACK + } + } else { + Color.BLACK + } + } } - } else { - Color.BLACK - } } - } } - } } From 0cca47c803a3cb142176eaaaf0c29fbe10f69e01 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 12:47:30 +0100 Subject: [PATCH 007/184] Lint --- .../io/sentry/android/replay/WindowRecorder.kt | 15 +++++++++++++-- .../replay/viewhierarchy/ViewHierarchyNode.kt | 12 +++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 125c5ce461..e7c8e61b97 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,6 +1,9 @@ package io.sentry.android.replay import android.content.Context +import android.graphics.Point +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import android.view.View import android.view.ViewTreeObserver import android.view.WindowManager @@ -53,8 +56,16 @@ class WindowRecorder { // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() - // TODO: support this for api level < 30 - val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + // TODO: API level check + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val aspectRatio = if (VERSION.SDK_INT >= VERSION_CODES.R) { + wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + } else { + val screenResolution = Point() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealSize(screenResolution) + screenResolution.y.toFloat() / screenResolution.x.toFloat() + } val videoFile = File(context.cacheDir, "sentry-sr.mp4") encoder = SimpleVideoEncoder( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 9dcf117b91..ff598d3b78 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.viewhierarchy +import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -10,6 +11,8 @@ 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.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView @@ -61,9 +64,16 @@ data class ViewHierarchyNode( var rect: Rect? = null when (view) { is TextView -> { - val nodeInfo = AccessibilityNodeInfo() + // TODO: API level check + // TODO: perhaps this is heavy, might reconsider + val nodeInfo = if (VERSION.SDK_INT >= VERSION_CODES.R) { + AccessibilityNodeInfo() + } else { + AccessibilityNodeInfo.obtain() + } view.onInitializeAccessibilityNodeInfo(nodeInfo) shouldRedact = nodeInfo.isVisibleToUser + nodeInfo.recycle() if (shouldRedact) { val bounds = Rect() val text = view.text.toString() From 5ebdfed87d56674699feadc2495cce66eecfba7a Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 13 Feb 2024 11:56:29 +0000 Subject: [PATCH 008/184] Format code --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index ff598d3b78..f044ef0850 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,6 +1,5 @@ package io.sentry.android.replay.viewhierarchy -import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color From 04f43ed67e2a88a80610e530d59efb039794dff9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 14:03:44 +0100 Subject: [PATCH 009/184] More comments --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 1 + .../src/main/java/io/sentry/android/replay/WindowRecorder.kt | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 442e740796..a37561b649 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -35,6 +35,7 @@ internal class ScreenshotRecorder( private var lastCapturedAtMs: Long? = null override fun onDraw() { + // TODO: replace with Debouncer from sentry-core val now = SystemClock.uptimeMillis() if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { return diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index e7c8e61b97..5e24a0af24 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -28,6 +28,10 @@ class WindowRecorder { private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { + if (recorders.containsKey(root)) { + // TODO: log + return@OnRootViewsChangedListener + } // stop tracking other windows so they don't interfere in the recording like a 25th frame effect recorders.entries.forEach { it.key.viewTreeObserver.removeOnDrawListener(it.value) From b46184701c76c0df69acaf62344b8a3dde89b0d9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 15:51:52 +0100 Subject: [PATCH 010/184] Disable detekt plugin for now --- sentry-android-replay/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 69fa4ef2b4..2314960f5e 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -7,7 +7,8 @@ plugins { jacoco id(Config.QualityPlugins.jacocoAndroid) id(Config.QualityPlugins.gradleVersions) - id(Config.QualityPlugins.detektPlugin) + // TODO: enable it later +// id(Config.QualityPlugins.detektPlugin) } android { From a63cac1e40df9f7562a3d4acead57a9bf9687438 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 15 Feb 2024 18:00:04 +0100 Subject: [PATCH 011/184] WIP --- sentry/src/main/java/io/sentry/Hub.java | 4 +- .../main/java/io/sentry/JsonObjectWriter.java | 5 + .../main/java/io/sentry/JsonSerializer.java | 4 + .../src/main/java/io/sentry/ObjectWriter.java | 1 + .../main/java/io/sentry/ReplayRecording.java | 18 ++- .../java/io/sentry/SentryEnvelopeItem.java | 38 ----- .../main/java/io/sentry/SentryItemType.java | 3 + .../main/java/io/sentry/rrweb/RRWebEvent.java | 91 ++++++++++++ .../java/io/sentry/rrweb/RRWebEventType.java | 32 +++++ .../java/io/sentry/rrweb/RRWebMetaEvent.java | 133 ++++++++++++++++++ .../java/io/sentry/rrweb/RRWebVideoEvent.java | 46 ++++++ .../java/io/sentry/util/MapObjectWriter.java | 6 + .../ReplayRecordingSerializationTest.kt | 22 +++ .../rrweb/RRWebEventSerializationTest.kt | 78 ++++++++++ .../rrweb/RRWebMetaEventSerializationTest.kt | 42 ++++++ .../src/test/resources/json/rrweb_event.json | 4 + .../test/resources/json/rrweb_meta_event.json | 9 ++ 17 files changed, 491 insertions(+), 45 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java create mode 100644 sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/rrweb_event.json create mode 100644 sentry/src/test/resources/json/rrweb_meta_event.json diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index 1d449d401d..25778256d6 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -933,9 +933,9 @@ private IScope buildLocalScope( } else { try { StackItem item = stack.peek(); - sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), null); + sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), hint); } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error while capturing check-in for slug", e); + options.getLogger().log(SentryLevel.ERROR, "Error while capturing replay", e); } } this.lastEventId = sentryId; diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index b174ddb484..3c0a326a5d 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,6 +52,11 @@ 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(); diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index c22eb095b5..62d6ddcb9d 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -29,6 +29,8 @@ 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.util.Objects; import java.io.BufferedOutputStream; import java.io.BufferedWriter; @@ -89,6 +91,8 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put( ProfileMeasurementValue.class, new ProfileMeasurementValue.Deserializer()); deserializersByClass.put(Request.class, new Request.Deserializer()); + deserializersByClass.put(RRWebEventType.class, new RRWebEventType.Deserializer()); + deserializersByClass.put(RRWebMetaEvent.class, new RRWebMetaEvent.Deserializer()); deserializersByClass.put(SdkInfo.class, new SdkInfo.Deserializer()); deserializersByClass.put(SdkVersion.class, new SdkVersion.Deserializer()); deserializersByClass.put(SentryEnvelopeHeader.class, new SentryEnvelopeHeader.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index ea8d4e83ea..0c424461bd 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -16,6 +16,7 @@ public interface ObjectWriter { ObjectWriter name(final @NotNull String name) throws IOException; ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; ObjectWriter nullValue() throws IOException; diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 26b4d73370..64b7c31cda 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.rrweb.RRWebEvent; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.HashMap; @@ -15,11 +16,9 @@ public static final class JsonKeys { } private @Nullable Integer segmentId; + private @Nullable List payload; private @Nullable Map unknown; - // TODO spec it out, good enough for now - private @Nullable List payload; - @Nullable public Integer getSegmentId() { return segmentId; @@ -30,11 +29,11 @@ public void setSegmentId(@Nullable Integer segmentId) { } @Nullable - public List getPayload() { + public List getPayload() { return payload; } - public void setPayload(@Nullable List payload) { + public void setPayload(@Nullable List payload) { this.payload = payload; } @@ -53,6 +52,15 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } } writer.endObject(); + + // session replay recording format + // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} + + writer.jsonValue("\n"); + + if (payload != null) { + writer.value(logger, payload); + } } @Override diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index f583ff0f0a..91a80a866e 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -354,21 +354,7 @@ public static SentryEnvelopeItem fromReplayRecording( try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { - - // session replay recording format - // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} - serializer.serialize(replayRecording, writer); - writer.write("\n"); - if (replayRecording.getPayload() != null) { - serializer.serialize(replayRecording.getPayload(), writer); - } - - // final byte[] payload = compressRecordingPayload(serializer, replayRecording); - // stream.write(payload); - - writer.flush(); - stream.flush(); return stream.toByteArray(); } } catch (Throwable t) { @@ -377,30 +363,6 @@ public static SentryEnvelopeItem fromReplayRecording( } }); - // try { - // final byte[] data = cachedItem.getBytes(); - // final String dataStr = new String(data, UTF_8); - // - // final String[] items = dataStr.split("\n", 2); - // final String header = items[0]; - // final String payload = items[1]; - // - // final ByteArrayInputStream byteArrayInputStream = new - // ByteArrayInputStream(payload.getBytes(UTF_8)); - // final GZIPInputStream inputStream = new GZIPInputStream(byteArrayInputStream); - // - // final ByteArrayOutputStream decodedData = new ByteArrayOutputStream(); - // - // byte[] buf = new byte[4096]; - // int readLen; - // while ((readLen = inputStream.read(buf, 0, buf.length)) != -1) { - // decodedData.write(buf, 0, readLen); - // } - // - // } catch (Exception e) { - // - // } - final SentryEnvelopeItemHeader itemHeader = new SentryEnvelopeItemHeader( SentryItemType.ReplayRecording, () -> cachedItem.getBytes().length, null, null); diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 69aa7b7a92..7cd58a9de2 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"), Unknown("__unknown__"); // DataCategory.Unknown @@ -34,6 +35,8 @@ public static SentryItemType resolve(Object item) { return ClientReport; } else if (item instanceof SentryReplayEvent) { return ReplayEvent; + } else if (item instanceof ReplayRecording) { + return ReplayRecording; } else { return Attachment; } 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 0000000000..ba7f7227ab --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java @@ -0,0 +1,91 @@ +package io.sentry.rrweb; + +import io.sentry.Breadcrumb; +import io.sentry.ILogger; +import io.sentry.JsonObjectReader; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectWriter; +import io.sentry.SentryBaseEvent; +import io.sentry.SentryLongDate; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Request; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import io.sentry.util.CollectionUtils; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +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; + } + + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String TIMESTAMP = "timestamp"; + } + + public static final class Serializer { + public void serialize( + @NotNull RRWebEvent baseEvent, @NotNull ObjectWriter writer, @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( + @NotNull RRWebEvent baseEvent, + @NotNull String nextName, + @NotNull JsonObjectReader reader, + @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 0000000000..412ab234ab --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -0,0 +1,32 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonSerializable; +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(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull RRWebEventType deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + return RRWebEventType.values()[reader.nextInt()]; + } + } +} 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 0000000000..67f7a82e4d --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -0,0 +1,133 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +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; + private @Nullable Map unknown; + + 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; + } + + 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.name(JsonKeys.DATA); + writer.beginObject(); + writer.name(JsonKeys.HREF).value(href); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + new RRWebEvent.Serializer().serialize(this, writer, logger); + 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( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + RRWebMetaEvent event = new RRWebMetaEvent(); + Map unknown = null; + + RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + break; + 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 (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + event.setUnknown(unknown); + reader.endObject(); + return event; + } + } +} 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 0000000000..17a54320b0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -0,0 +1,46 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectWriter; +import java.io.IOException; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + + } + + @Override public @Nullable Map getUnknown() { + return null; + } + + @Override public void setUnknown(@Nullable Map unknown) { + + } + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String TAG = "tag"; + 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"; + } + + +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc2..7d25c7d9dd 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -151,6 +151,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/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt new file mode 100644 index 0000000000..b731cabdb4 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -0,0 +1,22 @@ +package io.sentry.protocol + +import io.sentry.DateUtils +import io.sentry.ILogger +import io.sentry.ReplayRecording +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import org.mockito.kotlin.mock + +class ReplayRecordingSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = ReplayRecording().apply { + segmentId = 0 + payload = listOf( + + ) + } + } + private val fixture = Fixture() +} 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 0000000000..1223075c6b --- /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.JsonObjectReader +import io.sentry.JsonSerializable +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: JsonObjectReader, 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/RRWebMetaEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt new file mode 100644 index 0000000000..3ab8ad03cb --- /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" + width = 1080 + height = 1920 + 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/resources/json/rrweb_event.json b/sentry/src/test/resources/json/rrweb_event.json new file mode 100644 index 0000000000..d5610238e9 --- /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_meta_event.json b/sentry/src/test/resources/json/rrweb_meta_event.json new file mode 100644 index 0000000000..a1d9621f52 --- /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", + "width": 1080, + "height": 1920 + } +} From fa72057630fd5c81ec7c8c84eef7c442f84e5fc7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:51:00 +0100 Subject: [PATCH 012/184] Add replay envelopes --- .../core/DefaultAndroidEventProcessor.java | 5 +- sentry/src/main/java/io/sentry/Hint.java | 26 +- sentry/src/main/java/io/sentry/Hub.java | 10 +- .../src/main/java/io/sentry/HubAdapter.java | 2 +- .../main/java/io/sentry/ISentryClient.java | 3 +- .../main/java/io/sentry/JsonObjectWriter.java | 3 +- .../main/java/io/sentry/JsonSerializer.java | 2 + .../java/io/sentry/MainEventProcessor.java | 5 +- .../main/java/io/sentry/NoOpSentryClient.java | 4 +- .../src/main/java/io/sentry/ObjectWriter.java | 1 + .../main/java/io/sentry/ReplayRecording.java | 9 - sentry/src/main/java/io/sentry/Sentry.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 108 ++++-- .../java/io/sentry/SentryEnvelopeItem.java | 127 +++++-- .../main/java/io/sentry/SentryItemType.java | 2 - .../java/io/sentry/SentryReplayEvent.java | 192 +++++----- .../main/java/io/sentry/rrweb/RRWebEvent.java | 32 +- .../java/io/sentry/rrweb/RRWebEventType.java | 6 +- .../java/io/sentry/rrweb/RRWebMetaEvent.java | 47 ++- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 329 +++++++++++++++++- .../ReplayRecordingSerializationTest.kt | 41 ++- .../SentryReplayEventSerializationTest.kt | 58 +++ .../rrweb/RRWebMetaEventSerializationTest.kt | 2 +- .../rrweb/RRWebVideoEventSerializationTest.kt | 47 +++ .../test/resources/json/replay_recording.json | 3 + .../json/replay_recording_payload.json | 32 ++ .../test/resources/json/rrweb_meta_event.json | 4 +- .../resources/json/rrweb_video_event.json | 21 ++ .../resources/json/sentry_replay_event.json | 258 ++++++++++++++ 29 files changed, 1127 insertions(+), 254 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/replay_recording.json create mode 100644 sentry/src/test/resources/json/replay_recording_payload.json create mode 100644 sentry/src/test/resources/json/rrweb_video_event.json create mode 100644 sentry/src/test/resources/json/sentry_replay_event.json 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 0e1ddc7a3a..0fde2c33db 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 @@ -260,12 +260,9 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { @Override public @NotNull SentryReplayEvent process( - final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { final boolean applyScopeData = shouldApplyScopeData(event, hint); if (applyScopeData) { - // we only set memory data if it's not a hard crash, when it's a hard crash the event is - // enriched on restart, so non static data might be wrong, eg lowMemory or availMem will - // be different if the App. crashes because of OOM. processNonCachedEvent(event, hint); } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index a638d240ab..750017d00d 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -27,11 +27,10 @@ public final class Hint { private final @NotNull Map internalStorage = new HashMap(); private final @NotNull List attachments = new ArrayList<>(); - private final @NotNull List replayRecordings = 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(); @@ -71,12 +70,6 @@ public synchronized void remove(@NotNull String name) { internalStorage.remove(name); } - public void addReplayRecording(final @Nullable ReplayRecording recording) { - if (recording != null) { - replayRecordings.add(recording); - } - } - public void addAttachment(@Nullable Attachment attachment) { if (attachment != null) { attachments.add(attachment); @@ -93,10 +86,6 @@ public void addAttachments(@Nullable List attachments) { return new ArrayList<>(attachments); } - public @NotNull List getReplayRecordings() { - return new ArrayList<>(replayRecordings); - } - public void replaceAttachments(@Nullable List attachments) { clearAttachments(); addAttachments(attachments); @@ -106,10 +95,6 @@ public void clearAttachments() { attachments.clear(); } - public void clearReplayRecordings() { - replayRecordings.clear(); - } - /** * Clears all attributes added via {@link #set(String, Object)} Note: SDK internal attributes are * being kept. This is useful to avoid leaking any objects (e.g. Android activities) being @@ -151,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 25778256d6..b91b551705 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -922,14 +922,14 @@ private IScope buildLocalScope( @Override public @NotNull SentryId captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + 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."); + .getLogger() + .log( + SentryLevel.WARNING, + "Instance is disabled and this 'captureReplay' call is a no-op."); } else { try { StackItem item = stack.peek(); diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index ecae05cc98..5d7796f164 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -264,7 +264,7 @@ public void reportFullyDisplayed() { @Override public @NotNull SentryId captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { return Sentry.getCurrentHub().captureReplay(replay, hint); } diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 5ea2e5f847..e568746e9a 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -147,7 +147,8 @@ public interface ISentryClient { return captureException(throwable, scope, null); } - @NotNull SentryId captureReplayEvent( + @NotNull + SentryId captureReplayEvent( @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint); /** diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index 3c0a326a5d..ff5114606c 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,7 +52,8 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } - @Override public ObjectWriter jsonValue(@Nullable String value) throws IOException { + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { jsonWriter.jsonValue(value); return this; } diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 62d6ddcb9d..95c0c538ac 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -31,6 +31,7 @@ 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; @@ -93,6 +94,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(Request.class, new Request.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()); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 813d3aaf26..e79ebc37bd 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -152,13 +152,16 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { @Override public @NotNull SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { setCommons(event); - setDebugMeta(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/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 757b074f82..24b43f2b84 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -63,8 +63,8 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint } @Override - public @NotNull SentryId captureReplayEvent(@NotNull SentryReplayEvent event, - @Nullable IScope scope, @Nullable Hint hint) { + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint) { return SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index 0c424461bd..a5b8d12a4e 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -16,6 +16,7 @@ public interface ObjectWriter { ObjectWriter name(final @NotNull String name) throws IOException; ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; ObjectWriter nullValue() throws IOException; diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 64b7c31cda..c110c00700 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -52,15 +52,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } } writer.endObject(); - - // session replay recording format - // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} - - writer.jsonValue("\n"); - - if (payload != null) { - writer.value(logger, payload); - } } @Override diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 392dd0cef2..6058199d62 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1027,7 +1027,7 @@ public interface OptionsConfiguration { } public static void captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { getCurrentHub().captureReplay(replay, hint); } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0b8da468b6..129d415a8a 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -248,7 +248,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul @Override public @NotNull SentryId captureReplayEvent( - @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { + @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SessionReplay is required."); if (hint == null) { @@ -262,8 +262,8 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options.getLogger().log(SentryLevel.DEBUG, "Capturing session replay: %s", event.getEventId()); SentryId sentryId = SentryId.EMPTY_ID; - if (event.getReplayId() != null) { - sentryId = event.getReplayId(); + if (event.getEventId() != null) { + sentryId = event.getEventId(); } event = processReplayEvent(event, hint, options.getEventProcessors()); @@ -274,15 +274,22 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } try { - final SentryEnvelope envelope = - buildEnvelope(event, hint.getReplayRecordings()); + @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(); - if (envelope != null) { - transport.send(envelope, hint); - } else { - sentryId = SentryId.EMPTY_ID; - } + transport.send(envelope, hint); } catch (IOException e) { options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); @@ -566,38 +573,22 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } - private @Nullable SentryEnvelope buildEnvelope( - final @Nullable SentryReplayEvent event, - final @Nullable List replayRecordings - ) { - SentryId sentryId = null; + private @NotNull SentryEnvelope buildEnvelope( + final @NotNull SentryReplayEvent event, + final @Nullable ReplayRecording replayRecording, + final @Nullable TraceContext traceContext) { final List envelopeItems = new ArrayList<>(); - if (event != null) { - final SentryEnvelopeItem eventItem = - SentryEnvelopeItem.fromEvent(options.getSerializer(), event); - envelopeItems.add(eventItem); - sentryId = event.getEventId(); - } - - if (replayRecordings != null) { - for (final ReplayRecording replayRecording : replayRecordings) { - final SentryEnvelopeItem replayItem = - SentryEnvelopeItem.fromReplayRecording( - options.getSerializer(), options.getLogger(), replayRecording); - envelopeItems.add(replayItem); - } - } + 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); - if (!envelopeItems.isEmpty()) { - final SentryEnvelopeHeader envelopeHeader = - new SentryEnvelopeHeader(sentryId, options.getSdkVersion()); - - return new SentryEnvelope(envelopeHeader, envelopeItems); - } - - return null; + return new SentryEnvelope(envelopeHeader, envelopeItems); } /** @@ -921,6 +912,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/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 91a80a866e..53b2d24ea5 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -19,9 +19,11 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.Reader; -import java.io.StringWriter; import java.io.Writer; +import java.nio.ByteBuffer; import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Callable; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -342,47 +344,70 @@ public ClientReport getClientReport(final @NotNull ISerializer serializer) throw } } - public static SentryEnvelopeItem fromReplayRecording( - final @NotNull ISerializer serializer, - final @NotNull ILogger logger, - final @NotNull ReplayRecording replayRecording) { + 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))) { - serializer.serialize(replayRecording, writer); - return stream.toByteArray(); - } - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); - return null; - } - }); + new CachedItem( + () -> { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Map replayPayload = new HashMap<>(); + // first serialize replay event json bytes + serializer.serialize(replayEvent, writer); + replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); + stream.reset(); + + // next serialize replay recording in the following format: + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + if (replayRecording != null) { + serializer.serialize(replayRecording, writer); + writer.write("\n"); + writer.flush(); + if (replayRecording.getPayload() != null) { + serializer.serialize(replayRecording.getPayload(), writer); + } + replayPayload.put( + SentryItemType.ReplayRecording.getItemType(), stream.toByteArray()); + stream.reset(); + } + + // next serialize replay video bytes from given file + if (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 { + replayVideo.delete(); + } + }); final SentryEnvelopeItemHeader itemHeader = - new SentryEnvelopeItemHeader( - SentryItemType.ReplayRecording, () -> cachedItem.getBytes().length, null, null); + new SentryEnvelopeItemHeader( + SentryItemType.ReplayVideo, () -> cachedItem.getBytes().length, null, null); // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef - SentryEnvelopeItem item = new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); - - try { - StringWriter writer = new StringWriter(); - serializer.serialize(item.header, writer); - writer.flush(); - writer.flush(); - } catch (Exception e) { - logger.log(SentryLevel.ERROR, "f", e); - } - return item; + return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); } - private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; @@ -402,4 +427,40 @@ public CachedItem(final @Nullable Callable dataFactory) { return bytes != null ? bytes : new byte[] {}; } } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private static byte[] serializeToMsgpack(Map map) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + // Write map header + baos.write((byte) (0x80 | map.size())); + + // Iterate over the map and serialize each key-value pair + for (Map.Entry entry : map.entrySet()) { + // Pack the key as a string + byte[] keyBytes = entry.getKey().getBytes(Charset.forName("UTF-8")); + int keyLength = keyBytes.length; + if (keyLength <= 31) { + baos.write((byte) (0xA0 | keyLength)); + } else { + baos.write((byte) (0xD9)); + baos.write((byte) (keyLength)); + } + baos.write(keyBytes); + + // Pack the value as a binary string + byte[] valueBytes = entry.getValue(); + int valueLength = valueBytes.length; + if (valueLength <= 255) { + baos.write((byte) (0xC4)); + baos.write((byte) (valueLength)); + } else { + baos.write((byte) (0xC5)); + baos.write(ByteBuffer.allocate(4).putInt(valueLength).array()); + } + baos.write(valueBytes); + } + + return baos.toByteArray(); + } } diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 7cd58a9de2..79deda34c0 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -35,8 +35,6 @@ public static SentryItemType resolve(Object item) { return ClientReport; } else if (item instanceof SentryReplayEvent) { return ReplayEvent; - } else if (item instanceof ReplayRecording) { - return ReplayRecording; } else { return Attachment; } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index 8de7599422..623986d868 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -2,36 +2,50 @@ import io.sentry.protocol.SentryId; import io.sentry.vendor.gson.stream.JsonToken; +import java.io.File; import java.io.IOException; +import java.math.BigDecimal; 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 { + implements JsonUnknown, JsonSerializable { - 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"; + public enum ReplayType implements JsonSerializable { + SESSION, + BUFFER; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(name().toLowerCase(Locale.ROOT)); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull ReplayType deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); + } + } } - private @Nullable String type; - private @Nullable String replayType; + public static final long REPLAY_VIDEO_MAX_SIZE = 10 * 1024 * 1024; + public static final String REPLAY_EVENT_TYPE = "replay_event"; + + private @NotNull File videoFile; + private @NotNull String type; + private @NotNull ReplayType replayType; private @Nullable SentryId replayId; - private @Nullable Integer segmentId; - private @Nullable Double timestamp; - private @Nullable Double replayStartTimestamp; + private int segmentId; + private @NotNull Date timestamp; + private @Nullable Date replayStartTimestamp; private @Nullable List urls; private @Nullable List errorIds; private @Nullable List traceIds; @@ -39,20 +53,30 @@ public static final class JsonKeys { public SentryReplayEvent() { super(); - this.replayId = this.getEventId(); - this.type = "replay_event"; - this.replayType = "session"; + 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 + @NotNull + public File getVideoFile() { + return videoFile; + } + + public void setVideoFile(final @NotNull File videoFile) { + this.videoFile = videoFile; + } + + @NotNull public String getType() { return type; } - public void setType(final @Nullable String type) { + public void setType(final @NotNull String type) { this.type = type; } @@ -65,30 +89,29 @@ public void setReplayId(final @Nullable SentryId replayId) { this.replayId = replayId; } - @Nullable - public Integer getSegmentId() { + public int getSegmentId() { return segmentId; } - public void setSegmentId(final @Nullable Integer segmentId) { + public void setSegmentId(final int segmentId) { this.segmentId = segmentId; } - @Nullable - public Double getTimestamp() { + @NotNull + public Date getTimestamp() { return timestamp; } - public void setTimestamp(final @Nullable Double timestamp) { + public void setTimestamp(final @NotNull Date timestamp) { this.timestamp = timestamp; } @Nullable - public Double getReplayStartTimestamp() { + public Date getReplayStartTimestamp() { return replayStartTimestamp; } - public void setReplayStartTimestamp(final @Nullable Double replayStartTimestamp) { + public void setReplayStartTimestamp(final @Nullable Date replayStartTimestamp) { this.replayStartTimestamp = replayStartTimestamp; } @@ -119,36 +142,46 @@ public void setTraceIds(final @Nullable List traceIds) { this.traceIds = traceIds; } - @Nullable - public String getReplayType() { + @NotNull + public ReplayType getReplayType() { return replayType; } - public void setReplayType(@Nullable String replayType) { + public void setReplayType(final @NotNull ReplayType replayType) { this.replayType = replayType; } + // 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 { + throws IOException { writer.beginObject(); - if (type != null) { - writer.name(JsonKeys.TYPE).value(type); - } - if (replayType != null) { - writer.name(JsonKeys.REPLAY_TYPE).value(replayType); - } + 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, BigDecimal.valueOf(DateUtils.dateToSeconds(timestamp))); if (replayId != null) { writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); } - if (segmentId != null) { - writer.name(JsonKeys.SEGMENT_ID).value(segmentId); - } - if (timestamp != null) { - writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); - } if (replayStartTimestamp != null) { - writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); + writer + .name(JsonKeys.REPLAY_START_TIMESTAMP) + .value(logger, BigDecimal.valueOf(DateUtils.dateToSeconds(replayStartTimestamp))); } if (urls != null) { writer.name(JsonKeys.URLS).value(logger, urls); @@ -183,9 +216,10 @@ public void setUnknown(final @Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { + @SuppressWarnings("unchecked") @Override public @NotNull SentryReplayEvent deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { SentryBaseEvent.Deserializer baseEventDeserializer = new SentryBaseEvent.Deserializer(); @@ -193,11 +227,11 @@ public static final class Deserializer implements JsonDeserializer unknown = null; @Nullable String type = null; - @Nullable String replayType = null; + @Nullable ReplayType replayType = null; @Nullable SentryId replayId = null; @Nullable Integer segmentId = null; - @Nullable Double timestamp = null; - @Nullable Double replayStartTimestamp = null; + @Nullable Date timestamp = null; + @Nullable Date replayStartTimestamp = null; @Nullable List urls = null; @Nullable List errorIds = null; @Nullable List traceIds = null; @@ -210,7 +244,7 @@ public static final class Deserializer implements JsonDeserializer) reader.nextObjectOrNull(); break; case JsonKeys.ERROR_IDS: - errorIds = nextStringList(reader); + errorIds = (List) reader.nextObjectOrNull(); break; case JsonKeys.TRACE_IDS: - traceIds = nextStringList(reader); + traceIds = (List) reader.nextObjectOrNull(); break; default: if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { @@ -245,11 +279,19 @@ public static final class Deserializer implements JsonDeserializer nextStringList(final @NotNull JsonObjectReader reader) - throws IOException { - @Nullable List result = null; - final @Nullable Object data = reader.nextObjectOrNull(); - if (data instanceof List) { - result = new ArrayList<>(((List) data).size()); - for (Object item : (List) data) { - if (item instanceof String) { - result.add((String) item); - } - } - } - return result; - } } + // endregion json } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java index ba7f7227ab..999149331c 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java @@ -1,25 +1,11 @@ package io.sentry.rrweb; -import io.sentry.Breadcrumb; import io.sentry.ILogger; import io.sentry.JsonObjectReader; -import io.sentry.JsonSerializable; -import io.sentry.JsonUnknown; import io.sentry.ObjectWriter; -import io.sentry.SentryBaseEvent; -import io.sentry.SentryLongDate; -import io.sentry.protocol.Contexts; -import io.sentry.protocol.DebugMeta; -import io.sentry.protocol.Request; -import io.sentry.protocol.SdkVersion; -import io.sentry.protocol.SentryId; -import io.sentry.protocol.User; -import io.sentry.util.CollectionUtils; import java.io.IOException; -import java.util.Map; import java.util.Objects; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; public abstract class RRWebEvent { @@ -32,7 +18,7 @@ protected RRWebEvent(final @NotNull RRWebEventType type) { } protected RRWebEvent() { - this(RRWebEventType.Custom); + this(RRWebEventType.Custom); } @NotNull @@ -60,8 +46,8 @@ public static final class JsonKeys { public static final class Serializer { public void serialize( - @NotNull RRWebEvent baseEvent, @NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @NotNull RRWebEvent baseEvent, @NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { writer.name(JsonKeys.TYPE).value(logger, baseEvent.type); writer.name(JsonKeys.TIMESTAMP).value(baseEvent.timestamp); } @@ -70,15 +56,15 @@ public void serialize( public static final class Deserializer { @SuppressWarnings("unchecked") public boolean deserializeValue( - @NotNull RRWebEvent baseEvent, - @NotNull String nextName, - @NotNull JsonObjectReader reader, - @NotNull ILogger logger) - throws Exception { + @NotNull RRWebEvent baseEvent, + @NotNull String nextName, + @NotNull JsonObjectReader reader, + @NotNull ILogger logger) + throws Exception { switch (nextName) { case JsonKeys.TYPE: baseEvent.type = - Objects.requireNonNull(reader.nextOrNull(logger, new RRWebEventType.Deserializer())); + Objects.requireNonNull(reader.nextOrNull(logger, new RRWebEventType.Deserializer())); return true; case JsonKeys.TIMESTAMP: baseEvent.timestamp = reader.nextLong(); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java index 412ab234ab..a81e5f30e8 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -17,15 +17,15 @@ public enum RRWebEventType implements JsonSerializable { Custom, Plugin; - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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 RRWebEventType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { return RRWebEventType.values()[reader.nextInt()]; } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index 67f7a82e4d..d64400e323 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -58,14 +58,20 @@ public static final class JsonKeys { } @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + 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); - new RRWebEvent.Serializer().serialize(this, writer, logger); if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -91,18 +97,37 @@ public static final class Deserializer implements JsonDeserializer unknown = null; - 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: + baseEventDeserializer.deserializeValue(event, nextName, reader, logger); break; + } + } + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebMetaEvent event, + final @NotNull JsonObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + 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; @@ -116,18 +141,14 @@ public static final class Deserializer implements JsonDeserializer(); - } - reader.nextUnknown(logger, unknown, nextName); + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); } - break; + reader.nextUnknown(logger, unknown, nextName); } } event.setUnknown(unknown); reader.endObject(); - return event; } } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index 17a54320b0..a20c705e5a 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -1,31 +1,178 @@ package io.sentry.rrweb; import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; 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 { - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + 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 int duration; + 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; + 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; } - @Override public @Nullable Map getUnknown() { - return null; + public long getSize() { + return size; } - @Override public void setUnknown(@Nullable Map unknown) { + public void setSize(final long size) { + this.size = size; + } + + public int getDuration() { + return duration; + } + + public void setDuration(final int duration) { + this.duration = duration; + } + + @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; + } + + @Override + public @Nullable Map getUnknown() { + return dataUnknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.dataUnknown = 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 TAG = "tag"; public static final String PAYLOAD = "payload"; public static final String SEGMENT_ID = "segmentId"; @@ -42,5 +189,177 @@ public static final class JsonKeys { public static final String TOP = "top"; } + @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.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(duration); + 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 (String key : payloadUnknown.keySet()) { + 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( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + RRWebVideoEvent event = new RRWebVideoEvent(); + 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: + baseEventDeserializer.deserializeValue(event, nextName, reader, logger); + break; + } + } + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebVideoEvent event, + final @NotNull JsonObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + Map dataUknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUknown == null) { + dataUknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUknown, nextName); + } + } + event.setUnknown(dataUknown); + reader.endObject(); + } + + private void deserializePayload( + final @NotNull RRWebVideoEvent event, + final @NotNull JsonObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + 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.duration = reader.nextInt(); + 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.setUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json } diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index b731cabdb4..68b45fd874 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -1,11 +1,18 @@ package io.sentry.protocol -import io.sentry.DateUtils import io.sentry.ILogger +import io.sentry.JsonSerializer import io.sentry.ReplayRecording -import io.sentry.SentryEvent -import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebMetaEventSerializationTest +import io.sentry.rrweb.RRWebVideoEventSerializationTest +import org.junit.Test import org.mockito.kotlin.mock +import java.io.StringWriter +import kotlin.test.assertEquals class ReplayRecordingSerializationTest { class Fixture { @@ -14,9 +21,35 @@ class ReplayRecordingSerializationTest { fun getSut() = ReplayRecording().apply { segmentId = 0 payload = listOf( - + RRWebMetaEventSerializationTest.Fixture().getSut(), + RRWebVideoEventSerializationTest.Fixture().getSut() ) } } + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/replay_recording.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/replay_recording.json") + val actual = deserializeJson(expectedJson, ReplayRecording.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } + + @Test + fun serializePayload() { + val expected = sanitizedFile("json/replay_recording_payload.json") + val writer = StringWriter() + JsonSerializer(SentryOptions()).serialize(fixture.getSut().payload as Any, writer) + val actual = writer.toString() + assertEquals(expected, actual) + } } 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 0000000000..82b32e96c7 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -0,0 +1,58 @@ +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.getDateTimeWithMillisPrecision("987654321.123") + replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + SentryBaseEventSerializationTest.Fixture().update(this) + } + } + 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/RRWebMetaEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt index 3ab8ad03cb..29ec354333 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt @@ -16,8 +16,8 @@ class RRWebMetaEventSerializationTest { fun getSut() = RRWebMetaEvent().apply { href = "https://sentry.io" - width = 1080 height = 1920 + width = 1080 type = Meta timestamp = 1234567890 } 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 0000000000..79bfd02456 --- /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 + duration = 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/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json new file mode 100644 index 0000000000..b0de77bc87 --- /dev/null +++ b/sentry/src/test/resources/json/replay_recording.json @@ -0,0 +1,3 @@ +{ + "segment_id": 0 +} diff --git a/sentry/src/test/resources/json/replay_recording_payload.json b/sentry/src/test/resources/json/replay_recording_payload.json new file mode 100644 index 0000000000..fe790b52d6 --- /dev/null +++ b/sentry/src/test/resources/json/replay_recording_payload.json @@ -0,0 +1,32 @@ +[ + { + "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 + } + } + } +] diff --git a/sentry/src/test/resources/json/rrweb_meta_event.json b/sentry/src/test/resources/json/rrweb_meta_event.json index a1d9621f52..5eb561a78d 100644 --- a/sentry/src/test/resources/json/rrweb_meta_event.json +++ b/sentry/src/test/resources/json/rrweb_meta_event.json @@ -3,7 +3,7 @@ "timestamp": 1234567890, "data": { "href": "https://sentry.io", - "width": 1080, - "height": 1920 + "height": 1920, + "width": 1080 } } 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 0000000000..692dafe879 --- /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_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json new file mode 100644 index 0000000000..b3d1c9ddb5 --- /dev/null +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -0,0 +1,258 @@ +{ + "type": "replay_event", + "replay_type": "session", + "segment_id": 0, + "timestamp": 987654321.123, + "replay_id": "f715e1d64ef64ea3ad7744b5230813c3", + "replay_start_timestamp": 987654321.123, + "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"] + }, + "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" + } + }, + "server_name": "e6f0ae04-0f40-421b-aad1-f68c15117937", + "dist": "27022a08-aace-40c6-8d0a-358a27fcaa7a", + "breadcrumbs": + [ + { + "timestamp": "2009-11-16T01:08:47.000Z", + "message": "46f233c0-7c2d-488a-b05a-7be559173e16", + "type": "ace57e2e-305e-4048-abf0-6c8538ea7bf4", + "data": + { + "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" + }, + "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "level": "debug" + } + ], + "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" + } + ] + }, + "extra": + { + "34a7d067-fad2-49d9-97b9-71eff243127b": "fe3dc1cf-4a99-4213-85bb-e0957b8349b8" + } +} From 6cfb511d03b0bb6068994b58ce51f1beaac0e05f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:52:56 +0100 Subject: [PATCH 013/184] Remove jsonValue --- sentry/src/main/java/io/sentry/JsonObjectWriter.java | 6 ------ sentry/src/main/java/io/sentry/ObjectWriter.java | 2 -- sentry/src/main/java/io/sentry/util/MapObjectWriter.java | 6 ------ 3 files changed, 14 deletions(-) diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index ff5114606c..b174ddb484 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,12 +52,6 @@ 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(); diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index a5b8d12a4e..ea8d4e83ea 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -17,8 +17,6 @@ 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; diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 7d25c7d9dd..26f80eddc2 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -151,12 +151,6 @@ 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); From 0d031d770405212dd1d37af3d7c6dcbd90ec4e25 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:55:40 +0100 Subject: [PATCH 014/184] Remove --- sentry/src/main/java/io/sentry/SentryItemType.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 79deda34c0..ab5fb3bc73 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -33,8 +33,6 @@ public static SentryItemType resolve(Object item) { return Session; } else if (item instanceof ClientReport) { return ClientReport; - } else if (item instanceof SentryReplayEvent) { - return ReplayEvent; } else { return Attachment; } From 07e6b261803a095c7b4774c13ae73805d365bb35 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:58:22 +0100 Subject: [PATCH 015/184] Fix json --- .../resources/json/sentry_replay_event.json | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json index b3d1c9ddb5..04c96968b9 100644 --- a/sentry/src/test/resources/json/sentry_replay_event.json +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -211,22 +211,7 @@ "dc2813d0-0f66-4a3f-a995-71268f61a8fa": "991659ad-7c59-4dd3-bb89-0bd5c74014bd" } }, - "server_name": "e6f0ae04-0f40-421b-aad1-f68c15117937", "dist": "27022a08-aace-40c6-8d0a-358a27fcaa7a", - "breadcrumbs": - [ - { - "timestamp": "2009-11-16T01:08:47.000Z", - "message": "46f233c0-7c2d-488a-b05a-7be559173e16", - "type": "ace57e2e-305e-4048-abf0-6c8538ea7bf4", - "data": - { - "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" - }, - "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", - "level": "debug" - } - ], "debug_meta": { "sdk_info": @@ -250,9 +235,5 @@ "arch": "d00d5bea-fb5c-43c9-85f0-dc1350d957a4" } ] - }, - "extra": - { - "34a7d067-fad2-49d9-97b9-71eff243127b": "fe3dc1cf-4a99-4213-85bb-e0957b8349b8" } } From 18af924efa5d49b0922703d3cf23b92699e5951f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Feb 2024 00:19:02 +0100 Subject: [PATCH 016/184] Finalize replay envelopes --- buildSrc/src/main/java/Config.kt | 1 + sentry/build.gradle.kts | 1 + .../main/java/io/sentry/JsonObjectWriter.java | 11 ++ .../main/java/io/sentry/JsonSerializer.java | 2 + .../src/main/java/io/sentry/ObjectWriter.java | 4 + .../main/java/io/sentry/ReplayRecording.java | 84 +++++++++++- .../java/io/sentry/SentryEnvelopeItem.java | 36 ++---- .../java/io/sentry/SentryReplayEvent.java | 32 ++++- .../main/java/io/sentry/rrweb/RRWebEvent.java | 23 +++- .../java/io/sentry/rrweb/RRWebEventType.java | 4 +- .../java/io/sentry/rrweb/RRWebMetaEvent.java | 45 ++++++- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 89 +++++++++++-- .../test/java/io/sentry/SentryClientTest.kt | 120 +++++++++++++++++- .../ReplayRecordingSerializationTest.kt | 20 +-- .../rrweb/RRWebEventSerializationTest.kt | 4 +- .../test/resources/json/replay_recording.json | 5 +- 16 files changed, 407 insertions(+), 74 deletions(-) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8a02ce0e65..408d36051a 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -195,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/sentry/build.gradle.kts b/sentry/build.gradle.kts index 2f35cbd4f7..08efc550d5 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/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index b174ddb484..f1e84e6d5a 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 95c0c538ac..9f0e93f59a 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -92,6 +92,7 @@ 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()); @@ -107,6 +108,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/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index ea8d4e83ea..91e64a0c8b 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/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index c110c00700..d8892191f2 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -1,8 +1,15 @@ package io.sentry; import io.sentry.rrweb.RRWebEvent; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebMetaEvent; +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; @@ -37,6 +44,19 @@ public void setPayload(@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 { @@ -52,6 +72,15 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } } 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 @@ -66,14 +95,16 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { + @SuppressWarnings("unchecked") @Override public @NotNull ReplayRecording deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @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) { @@ -92,7 +123,58 @@ public static final class Deserializer implements JsonDeserializer 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 (Map.Entry entry : eventMap.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (key.equals("type")) { + RRWebEventType type = RRWebEventType.values()[(int) value]; + switch (type) { + case Meta: + final RRWebEvent metaEvent = + new RRWebMetaEvent.Deserializer().deserialize(mapReader, logger); + payload.add(metaEvent); + break; + case Custom: + final Map data = + (Map) eventMap.getOrDefault("data", Collections.emptyMap()); + final String tag = + (String) data.getOrDefault(RRWebEvent.JsonKeys.TAG, "default"); + switch (tag) { + case RRWebVideoEvent.EVENT_TAG: + final RRWebEvent videoEvent = + new RRWebVideoEvent.Deserializer().deserialize(mapReader, logger); + payload.add(videoEvent); + 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/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 53b2d24ea5..be6838670e 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -21,6 +21,7 @@ import java.io.Reader; import java.io.Writer; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; @@ -365,22 +366,16 @@ public static SentryEnvelopeItem fromReplay( replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); stream.reset(); - // next serialize replay recording in the following format: - // {"segment_id":0}\n{json-serialized-rrweb-protocol} + // next serialize replay recording if (replayRecording != null) { serializer.serialize(replayRecording, writer); - writer.write("\n"); - writer.flush(); - if (replayRecording.getPayload() != null) { - serializer.serialize(replayRecording.getPayload(), writer); - } replayPayload.put( SentryItemType.ReplayRecording.getItemType(), stream.toByteArray()); stream.reset(); } // next serialize replay video bytes from given file - if (replayVideo.exists()) { + if (replayVideo != null && replayVideo.exists()) { final byte[] videoBytes = readBytesFromFile( replayVideo.getPath(), SentryReplayEvent.REPLAY_VIDEO_MAX_SIZE); @@ -395,7 +390,9 @@ public static SentryEnvelopeItem fromReplay( logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); return null; } finally { - replayVideo.delete(); + if (replayVideo != null) { + replayVideo.delete(); + } } }); @@ -428,7 +425,7 @@ public CachedItem(final @Nullable Callable dataFactory) { } } - @SuppressWarnings("CharsetObjectCanBeUsed") + @SuppressWarnings({"CharsetObjectCanBeUsed", "UnnecessaryParentheses"}) private static byte[] serializeToMsgpack(Map map) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -440,24 +437,17 @@ private static byte[] serializeToMsgpack(Map map) throws IOExcep // Pack the key as a string byte[] keyBytes = entry.getKey().getBytes(Charset.forName("UTF-8")); int keyLength = keyBytes.length; - if (keyLength <= 31) { - baos.write((byte) (0xA0 | keyLength)); - } else { - baos.write((byte) (0xD9)); - baos.write((byte) (keyLength)); - } + // string up to 255 chars + baos.write((byte) (0xd9)); + baos.write((byte) (keyLength)); baos.write(keyBytes); // Pack the value as a binary string byte[] valueBytes = entry.getValue(); int valueLength = valueBytes.length; - if (valueLength <= 255) { - baos.write((byte) (0xC4)); - baos.write((byte) (valueLength)); - } else { - baos.write((byte) (0xC5)); - baos.write(ByteBuffer.allocate(4).putInt(valueLength).array()); - } + // 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); } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index 623986d868..a351a21d54 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -1,6 +1,7 @@ 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; @@ -29,8 +30,8 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ReplayType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ReplayType deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -39,7 +40,7 @@ public static final class Deserializer implements JsonDeserializer { public static final long REPLAY_VIDEO_MAX_SIZE = 10 * 1024 * 1024; public static final String REPLAY_EVENT_TYPE = "replay_event"; - private @NotNull File videoFile; + private @Nullable File videoFile; private @NotNull String type; private @NotNull ReplayType replayType; private @Nullable SentryId replayId; @@ -62,12 +63,12 @@ public SentryReplayEvent() { timestamp = DateUtils.getCurrentDateTime(); } - @NotNull + @Nullable public File getVideoFile() { return videoFile; } - public void setVideoFile(final @NotNull File videoFile) { + public void setVideoFile(final @Nullable File videoFile) { this.videoFile = videoFile; } @@ -151,6 +152,25 @@ 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"; @@ -219,7 +239,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull RRWebEventType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return RRWebEventType.values()[reader.nextInt()]; } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index d64400e323..0a1c914cb3 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -2,12 +2,14 @@ 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; import java.io.IOException; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; @@ -19,6 +21,7 @@ public final class RRWebMetaEvent extends RRWebEvent implements JsonUnknown, Jso private int height; private int width; private @Nullable Map unknown; + private @Nullable Map dataUnknown; public RRWebMetaEvent() { super(RRWebEventType.Meta); @@ -50,6 +53,31 @@ 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"; @@ -97,8 +125,9 @@ public static final class Deserializer implements JsonDeserializer unknown = null; RRWebMetaEvent event = new RRWebMetaEvent(); RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); @@ -109,17 +138,23 @@ public static final class Deserializer implements JsonDeserializer(); + } + reader.nextUnknown(logger, unknown, nextName); + } break; } } + event.setUnknown(unknown); reader.endObject(); return event; } private void deserializeData( final @NotNull RRWebMetaEvent event, - final @NotNull JsonObjectReader reader, + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { Map unknown = null; @@ -147,7 +182,7 @@ private void deserializeData( reader.nextUnknown(logger, unknown, nextName); } } - event.setUnknown(unknown); + event.setDataUnknown(unknown); reader.endObject(); } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index a20c705e5a..f9d61e591e 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -2,12 +2,14 @@ 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; import java.io.IOException; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; @@ -34,6 +36,7 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private int frameRate; private int left; private int top; + private @Nullable Map unknown; private @Nullable Map payloadUnknown; private @Nullable Map dataUnknown; @@ -158,14 +161,62 @@ 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 dataUnknown; + return unknown; } @Override public void setUnknown(final @Nullable Map unknown) { - this.dataUnknown = 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 + && duration == that.duration + && 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, + duration, + encoding, + container, + height, + width, + frameCount, + frameRateType, + frameRate, + left, + top); } // region json @@ -173,7 +224,6 @@ public void setUnknown(final @Nullable Map unknown) { // 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 TAG = "tag"; public static final String PAYLOAD = "payload"; public static final String SEGMENT_ID = "segmentId"; public static final String SIZE = "size"; @@ -195,13 +245,20 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr new RRWebEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.DATA); serializeData(writer, logger); + if (unknown != null) { + for (String key : unknown.keySet()) { + 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(JsonKeys.TAG).value(tag); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); writer.name(JsonKeys.PAYLOAD); serializePayload(writer, logger); if (dataUnknown != null) { @@ -244,8 +301,10 @@ public static final class Deserializer implements JsonDeserializer unknown = null; + RRWebVideoEvent event = new RRWebVideoEvent(); RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); @@ -256,17 +315,23 @@ public static final class Deserializer implements JsonDeserializer(); + } + reader.nextUnknown(logger, unknown, nextName); + } break; } } + event.setUnknown(unknown); reader.endObject(); return event; } private void deserializeData( final @NotNull RRWebVideoEvent event, - final @NotNull JsonObjectReader reader, + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { Map dataUknown = null; @@ -275,7 +340,7 @@ private void deserializeData( while (reader.peek() == JsonToken.NAME) { final String nextName = reader.nextName(); switch (nextName) { - case JsonKeys.TAG: + case RRWebEvent.JsonKeys.TAG: final String tag = reader.nextStringOrNull(); event.tag = tag == null ? "" : tag; break; @@ -289,13 +354,13 @@ private void deserializeData( reader.nextUnknown(logger, dataUknown, nextName); } } - event.setUnknown(dataUknown); + event.setDataUnknown(dataUknown); reader.endObject(); } private void deserializePayload( final @NotNull RRWebVideoEvent event, - final @NotNull JsonObjectReader reader, + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { Map payloadUnknown = null; @@ -357,7 +422,7 @@ private void deserializePayload( reader.nextUnknown(logger, payloadUnknown, nextName); } } - event.setUnknown(payloadUnknown); + event.setPayloadUnknown(payloadUnknown); reader.endObject(); } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 8fab30790f..0733e6ea45 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -5,7 +5,6 @@ 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 @@ -41,6 +40,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 @@ -2480,6 +2480,105 @@ class SentryClientTest { ) } + @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)) + ) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() @@ -2538,6 +2637,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( @@ -2721,4 +2835,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/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index 68b45fd874..d8f93ddfb5 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -1,17 +1,14 @@ package io.sentry.protocol +import io.sentry.FileFromResources import io.sentry.ILogger -import io.sentry.JsonSerializer import io.sentry.ReplayRecording -import io.sentry.SentryOptions import io.sentry.protocol.SerializationUtils.deserializeJson -import io.sentry.protocol.SerializationUtils.sanitizedFile import io.sentry.protocol.SerializationUtils.serializeToString import io.sentry.rrweb.RRWebMetaEventSerializationTest import io.sentry.rrweb.RRWebVideoEventSerializationTest import org.junit.Test import org.mockito.kotlin.mock -import java.io.StringWriter import kotlin.test.assertEquals class ReplayRecordingSerializationTest { @@ -31,25 +28,18 @@ class ReplayRecordingSerializationTest { @Test fun serialize() { - val expected = sanitizedFile("json/replay_recording.json") + 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 = sanitizedFile("json/replay_recording.json") + 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) } - - @Test - fun serializePayload() { - val expected = sanitizedFile("json/replay_recording_payload.json") - val writer = StringWriter() - JsonSerializer(SentryOptions()).serialize(fixture.getSut().payload as Any, writer) - val actual = writer.toString() - assertEquals(expected, actual) - } } diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt index 1223075c6b..2c2b60cd28 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt @@ -2,8 +2,8 @@ package io.sentry.rrweb 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.protocol.SerializationUtils.deserializeJson import io.sentry.protocol.SerializationUtils.sanitizedFile @@ -27,7 +27,7 @@ class RRWebEventSerializationTest { } 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/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index b0de77bc87..287419e1eb 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,3 +1,2 @@ -{ - "segment_id": 0 -} +{"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}}}] From 64cedfa4f88599bb7ebb65f98f33e76d3fa69f97 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Feb 2024 00:20:07 +0100 Subject: [PATCH 017/184] Introduce MapObjectReader --- sentry/api/sentry.api | 554 ++++++++++++++---- .../src/main/java/io/sentry/Breadcrumb.java | 7 +- sentry/src/main/java/io/sentry/CheckIn.java | 2 +- .../main/java/io/sentry/JsonDeserializer.java | 2 +- .../main/java/io/sentry/JsonObjectReader.java | 196 +++++-- .../main/java/io/sentry/MonitorConfig.java | 4 +- .../main/java/io/sentry/MonitorContexts.java | 2 +- .../main/java/io/sentry/MonitorSchedule.java | 2 +- .../src/main/java/io/sentry/ObjectReader.java | 101 ++++ .../java/io/sentry/ProfilingTraceData.java | 2 +- .../io/sentry/ProfilingTransactionData.java | 2 +- .../SentryAppStartProfilingOptions.java | 2 +- .../main/java/io/sentry/SentryBaseEvent.java | 2 +- .../java/io/sentry/SentryEnvelopeHeader.java | 2 +- .../io/sentry/SentryEnvelopeItemHeader.java | 2 +- .../src/main/java/io/sentry/SentryEvent.java | 4 +- .../main/java/io/sentry/SentryItemType.java | 2 +- .../src/main/java/io/sentry/SentryLevel.java | 4 +- .../main/java/io/sentry/SentryLockReason.java | 2 +- sentry/src/main/java/io/sentry/Session.java | 2 +- .../src/main/java/io/sentry/SpanContext.java | 4 +- sentry/src/main/java/io/sentry/SpanId.java | 2 +- .../src/main/java/io/sentry/SpanStatus.java | 4 +- .../src/main/java/io/sentry/TraceContext.java | 6 +- .../src/main/java/io/sentry/UserFeedback.java | 4 +- .../io/sentry/clientreport/ClientReport.java | 6 +- .../sentry/clientreport/DiscardedEvent.java | 4 +- .../ProfileMeasurement.java | 4 +- .../ProfileMeasurementValue.java | 4 +- .../src/main/java/io/sentry/protocol/App.java | 4 +- .../main/java/io/sentry/protocol/Browser.java | 4 +- .../java/io/sentry/protocol/Contexts.java | 4 +- .../java/io/sentry/protocol/DebugImage.java | 6 +- .../java/io/sentry/protocol/DebugMeta.java | 4 +- .../main/java/io/sentry/protocol/Device.java | 6 +- .../src/main/java/io/sentry/protocol/Geo.java | 4 +- .../src/main/java/io/sentry/protocol/Gpu.java | 4 +- .../io/sentry/protocol/MeasurementValue.java | 4 +- .../java/io/sentry/protocol/Mechanism.java | 4 +- .../main/java/io/sentry/protocol/Message.java | 4 +- .../io/sentry/protocol/OperatingSystem.java | 4 +- .../main/java/io/sentry/protocol/Request.java | 4 +- .../java/io/sentry/protocol/Response.java | 4 +- .../main/java/io/sentry/protocol/SdkInfo.java | 4 +- .../java/io/sentry/protocol/SdkVersion.java | 6 +- .../io/sentry/protocol/SentryException.java | 4 +- .../java/io/sentry/protocol/SentryId.java | 4 +- .../io/sentry/protocol/SentryPackage.java | 6 +- .../io/sentry/protocol/SentryRuntime.java | 6 +- .../java/io/sentry/protocol/SentrySpan.java | 6 +- .../io/sentry/protocol/SentryStackFrame.java | 4 +- .../io/sentry/protocol/SentryStackTrace.java | 4 +- .../java/io/sentry/protocol/SentryThread.java | 6 +- .../io/sentry/protocol/SentryTransaction.java | 4 +- .../io/sentry/protocol/TransactionInfo.java | 4 +- .../main/java/io/sentry/protocol/User.java | 4 +- .../io/sentry/protocol/ViewHierarchy.java | 6 +- .../io/sentry/protocol/ViewHierarchyNode.java | 4 +- .../java/io/sentry/util/MapObjectReader.java | 350 +++++++++++ .../java/io/sentry/util/MapObjectWriter.java | 11 + .../java/io/sentry/JsonObjectReaderTest.kt | 2 +- .../java/io/sentry/SentryEnvelopeItemTest.kt | 235 +++++++- .../SentryBaseEventSerializationTest.kt | 4 +- 63 files changed, 1369 insertions(+), 299 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ObjectReader.java create mode 100644 sentry/src/main/java/io/sentry/util/MapObjectReader.java diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8dc0851098..20e2d8b53b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -136,8 +136,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 +181,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 { @@ -226,6 +226,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static final field Error 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 Transaction Lio/sentry/DataCategory; @@ -300,6 +301,7 @@ 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; } @@ -387,12 +389,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 @@ -421,6 +425,7 @@ public final class io/sentry/Hub : 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 @@ -471,6 +476,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 @@ -559,6 +565,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; @@ -723,6 +730,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; @@ -843,7 +851,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 { @@ -851,23 +859,38 @@ 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 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 { @@ -887,11 +910,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; @@ -936,6 +961,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; } @@ -1018,8 +1044,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 { @@ -1040,8 +1066,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 { @@ -1063,8 +1089,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 { @@ -1119,6 +1145,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 @@ -1331,13 +1358,48 @@ 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 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; @@ -1426,8 +1488,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 { @@ -1484,8 +1546,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 { @@ -1519,6 +1581,30 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +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; @@ -1669,6 +1755,7 @@ public final class io/sentry/Sentry { public static fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public static fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)V public static fun captureUserFeedback (Lio/sentry/UserFeedback;)V public static fun clearBreadcrumbs ()V public static fun cloneMainHub ()Lio/sentry/IHub; @@ -1742,8 +1829,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 { @@ -1809,7 +1896,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 { @@ -1839,6 +1926,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient { public fun captureCheckIn (Lio/sentry/CheckIn;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; 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 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 @@ -1899,8 +1987,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 { @@ -1917,6 +2005,7 @@ public final class io/sentry/SentryEnvelopeItem { public static fun fromClientReport (Lio/sentry/ISerializer;Lio/sentry/clientreport/ClientReport;)Lio/sentry/SentryEnvelopeItem; public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)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; @@ -1940,8 +2029,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 { @@ -1987,8 +2076,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 { @@ -2047,6 +2136,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 Transaction Lio/sentry/SentryItemType; public static final field Unknown Lio/sentry/SentryItemType; @@ -2097,8 +2187,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 { @@ -2374,6 +2464,70 @@ 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/SentrySpanStorage { public fun get (Ljava/lang/String;)Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/SentrySpanStorage; @@ -2495,8 +2649,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 { @@ -2617,8 +2771,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 { @@ -2662,8 +2816,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 { @@ -2704,8 +2858,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/SystemOutLogger : io/sentry/ILogger { @@ -2733,8 +2887,8 @@ 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 { @@ -2893,8 +3047,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 { @@ -3005,8 +3159,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 { @@ -3050,8 +3204,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 { @@ -3344,8 +3498,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 { @@ -3368,8 +3522,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 { @@ -3410,8 +3564,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 { @@ -3444,8 +3598,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 { @@ -3479,8 +3633,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 { @@ -3513,8 +3667,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 { @@ -3543,8 +3697,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 { @@ -3633,8 +3787,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 { @@ -3647,8 +3801,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 { @@ -3706,8 +3860,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 { @@ -3747,8 +3901,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 { @@ -3783,8 +3937,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 { @@ -3817,8 +3971,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 { @@ -3847,8 +4001,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 { @@ -3882,8 +4036,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 { @@ -3930,8 +4084,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 { @@ -3970,8 +4124,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 { @@ -4000,8 +4154,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 { @@ -4034,8 +4188,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 { @@ -4067,8 +4221,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 { @@ -4094,8 +4248,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 { @@ -4113,8 +4267,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 { @@ -4139,8 +4293,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 { @@ -4173,8 +4327,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 { @@ -4243,8 +4397,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 { @@ -4284,8 +4438,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 { @@ -4324,8 +4478,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 { @@ -4362,8 +4516,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 { @@ -4386,8 +4540,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 { @@ -4438,8 +4592,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 { @@ -4466,8 +4620,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 { @@ -4507,8 +4661,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 { @@ -4526,6 +4680,152 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { 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 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/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 getDuration ()I + 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 setDuration (I)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 @@ -4727,6 +5027,40 @@ 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 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; @@ -4737,10 +5071,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/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fe2055c336..da1453bc68 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 4c83771324..e7c6abef3e 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/JsonDeserializer.java b/sentry/src/main/java/io/sentry/JsonDeserializer.java index 7e62814fe6..390328231b 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 79c9a35420..c069a9e26d 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,90 +91,79 @@ 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 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(); - return null; - } - return JsonObjectReader.dateOrNull(nextString(), logger); - } - - public static @Nullable Date dateOrNull(@Nullable String dateString, ILogger logger) { - if (dateString == null) { + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); 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); } @@ -177,7 +176,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/MonitorConfig.java b/sentry/src/main/java/io/sentry/MonitorConfig.java index c25884c2bd..7d15eb26d3 100644 --- a/sentry/src/main/java/io/sentry/MonitorConfig.java +++ b/sentry/src/main/java/io/sentry/MonitorConfig.java @@ -105,8 +105,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 3a15aa4113..00ccb680fc 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/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java new file mode 100644 index 0000000000..0449379f7d --- /dev/null +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -0,0 +1,101 @@ +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(@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 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/ProfilingTraceData.java b/sentry/src/main/java/io/sentry/ProfilingTraceData.java index 35902f5f04..9d54a7ee0e 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTraceData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTraceData.java @@ -448,7 +448,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 46ba9bba44..045b859f05 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/SentryAppStartProfilingOptions.java b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java index d98ec2c32f..a9828792d7 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 c247342cc2..58435194a7 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/SentryEnvelopeHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java index ceb7e7bdd5..3e9525d307 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/SentryEnvelopeItemHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java index 1ca9a1c8c2..6903d9b1bb 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 5bd1cf3877..d370458acb 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 ab5fb3bc73..b0d9c62e90 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -65,7 +65,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 ac179c9831..f1c6e04cb8 100644 --- a/sentry/src/main/java/io/sentry/SentryLevel.java +++ b/sentry/src/main/java/io/sentry/SentryLevel.java @@ -21,8 +21,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger 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 f376317f6b..bd04f48ab0 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/Session.java b/sentry/src/main/java/io/sentry/Session.java index 500da919fe..482b055b67 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 be428708cb..5a43ff845e 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/SpanId.java b/sentry/src/main/java/io/sentry/SpanId.java index 7e221775ce..70608fb7cb 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 b0b1bf78c8..5185d27e05 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 ef2944a9e9..df799aaa07 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -141,7 +141,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; @@ -239,8 +239,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; diff --git a/sentry/src/main/java/io/sentry/UserFeedback.java b/sentry/src/main/java/io/sentry/UserFeedback.java index 27086188fe..b580744ee7 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 66c3188116..e1b8abcaea 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 8fb5da3165..10b12b0fed 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 94e77edbfb..1e6ff5fb41 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 9639ba892f..b0cebf5439 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 b7b41638db..90d7d7aff6 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; @@ -255,7 +255,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 99fe427c27..ed32be5ea2 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 21be9fd8a5..28d2e8d2a4 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 d26432033e..e769e2c2ca 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 134947507a..458c4de631 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 4f06f74995..25cfa41fd1 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 fefc340e1b..6042b72d1d 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,7 @@ 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 Geo deserialize(@NotNull ObjectReader reader, 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 0dfe85f68f..b4a8344e2d 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 5ba28f4126..85cf6cf40f 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; @@ -101,7 +101,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 648aed39c2..fac8808f2d 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 a1c79e2198..9aceea56a6 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/OperatingSystem.java b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java index 796a4ea1a0..ecfb59542b 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 14f5403844..44e205a390 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 23a16c78f8..f1a9303710 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 ee3ac1eb16..928a8b522d 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 f7ba230463..aa997910be 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 5ee9464a3c..4d56e12747 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 c1e5ea1819..109655fdf2 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 cea6bb8497..aa2358d8df 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 751e664ae6..7d2ed8fa1e 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 b627aa4c9f..8e4396ac44 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; @@ -218,8 +218,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 fcb93eb2e8..03d64e2172 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 90b42666c8..e79e8e7ec0 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 1d57e35b10..accb05968e 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 d0c9271a26..791fda1799 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; @@ -231,7 +231,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 69e5156040..791c9bbbd6 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 923eb95877..525d644fdc 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/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java new file mode 100644 index 0000000000..ba2cb83ed3 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -0,0 +1,350 @@ +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.Date; +import java.util.Deque; +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, Map unknown, 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 { + return nextValueOrNull(); + } + + @Nullable + @Override + public Map nextMapOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + return nextValueOrNull(); + } + + @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 { + String dateString = nextStringOrNull(); + return ObjectReader.dateOrNull(dateString, logger); + } + + @Nullable + @Override + public TimeZone nextTimeZoneOrNull(final @NotNull ILogger logger) throws IOException { + 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; + } + + Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return JsonToken.END_DOCUMENT; + } + + if (currentEntry.getKey() != null) { + return JsonToken.NAME; + } + + 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 { + 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 { + Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + 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 { + Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + 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 (Object entry : (List) value) { + 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 { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } else { + throw new IOException("Expected int"); + } + } + + @Nullable + @Override + public Integer nextIntegerOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + @Override + public long nextLong() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } else { + throw new IOException("Expected long"); + } + } + + @Nullable + @Override + public Long nextLongOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return null; + } + + @Override + public String nextString() throws IOException { + 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 { + 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 { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } else { + throw new IOException("Expected double"); + } + } + + @Nullable + @Override + public Double nextDoubleOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + @Nullable + @Override + public Float nextFloatOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } + return null; + } + + @Override + public float nextFloat() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } else { + throw new IOException("Expected float"); + } + } + + @Override + public void nextNull() throws IOException { + nextValueOrNull(); + } + + @Override + public void setLenient(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 { + Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return null; + } + T value = (T) currentEntry.getValue(); + if (deserializer != null && logger != null) { + return deserializer.deserialize(this, logger); + } else if (value instanceof List) { + List list = (List) value; + if (!list.isEmpty()) { + T next = (T) list.remove(0); + if (next instanceof Map) { + stack.addLast(new AbstractMap.SimpleEntry<>(null, next)); + } + return next; + } + } else if (value instanceof Map) { + stack.addLast(new AbstractMap.SimpleEntry<>(null, value)); + return value; + } + 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 26f80eddc2..0bbc70a779 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/JsonObjectReaderTest.kt b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt index 976c88a1cd..ec18b4ed5c 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt @@ -285,7 +285,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/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 9817897651..efc5e5cadf 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/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4bc13559da..3da517ef56 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() From b8cb924594c5b56ee7f35d207bb7511b7ae729ba Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Feb 2024 00:44:03 +0100 Subject: [PATCH 018/184] Add missing test --- .../java/io/sentry/SentryEnvelopeItemTest.kt | 235 ++++++++++++++++-- 1 file changed, 214 insertions(+), 21 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 9817897651..efc5e5cadf 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() + } } From 1e76fc7499e8a1f4747c67c3aed4d1547c83759e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:21:35 +0100 Subject: [PATCH 019/184] Add test for MapObjectReader --- .../java/io/sentry/util/MapObjectReader.java | 11 +- .../io/sentry/util/MapObjectReaderTest.kt | 131 ++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index ba2cb83ed3..e4dff5f491 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -8,6 +8,7 @@ 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.List; @@ -161,7 +162,8 @@ public void beginArray() throws IOException { // 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 (Object entry : (List) value) { + for (int i = ((List) value).size() - 1; i >= 0; i--) { + Object entry = ((List) value).get(i); stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); } } else { @@ -295,7 +297,10 @@ public float nextFloat() throws IOException { @Override public void nextNull() throws IOException { - nextValueOrNull(); + Object value = nextValueOrNull(); + if (value != null) { + throw new IOException("Expected null but was " + peek()); + } } @Override @@ -327,7 +332,7 @@ private T nextValueOrNull( if (deserializer != null && logger != null) { return deserializer.deserialize(this, logger); } else if (value instanceof List) { - List list = (List) value; + List list = new ArrayList<>((List) value); if (!list.isEmpty()) { T next = (T) list.remove(0); if (next instanceof Map) { 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 0000000000..ab52919c43 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -0,0 +1,131 @@ +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("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) + + val reader = MapObjectReader(data) + reader.beginObject() + assertEquals(JsonToken.NAME, reader.peek()) + 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("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() + } +} From 13c19718d27874b59115661e7f2dbf915685c2d1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:24:02 +0100 Subject: [PATCH 020/184] Add MapObjectWriter change --- .../src/main/java/io/sentry/util/MapObjectWriter.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc2..0bbc70a779 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); From 86baf7fc968a8ccc7dc7e8bdce22bd37e60bf8a6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:36:42 +0100 Subject: [PATCH 021/184] Add finals --- .../src/main/java/io/sentry/ObjectReader.java | 3 +- .../java/io/sentry/util/MapObjectReader.java | 53 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java index 0449379f7d..6c2210897e 100644 --- a/sentry/src/main/java/io/sentry/ObjectReader.java +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -11,7 +11,8 @@ import org.jetbrains.annotations.Nullable; public interface ObjectReader extends Closeable { - static @Nullable Date dateOrNull(@Nullable String dateString, final @NotNull ILogger logger) { + static @Nullable Date dateOrNull( + final @Nullable String dateString, final @NotNull ILogger logger) { if (dateString == null) { return null; } diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index e4dff5f491..6cb05989b2 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -28,7 +28,8 @@ public MapObjectReader(final Map root) { } @Override - public void nextUnknown(final @NotNull ILogger logger, Map unknown, String name) { + public void nextUnknown( + final @NotNull ILogger logger, final Map unknown, final String name) { try { unknown.put(name, nextObjectOrNull()); } catch (Exception exception) { @@ -63,14 +64,14 @@ public T nextOrNull( @Nullable @Override public Date nextDateOrNull(final @NotNull ILogger logger) throws IOException { - String dateString = nextStringOrNull(); + final String dateString = nextStringOrNull(); return ObjectReader.dateOrNull(dateString, logger); } @Nullable @Override public TimeZone nextTimeZoneOrNull(final @NotNull ILogger logger) throws IOException { - String timeZoneId = nextStringOrNull(); + final String timeZoneId = nextStringOrNull(); return timeZoneId != null ? TimeZone.getTimeZone(timeZoneId) : null; } @@ -87,7 +88,7 @@ public JsonToken peek() throws IOException { return JsonToken.END_DOCUMENT; } - Map.Entry currentEntry = stack.peekLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry == null) { return JsonToken.END_DOCUMENT; } @@ -96,7 +97,7 @@ public JsonToken peek() throws IOException { return JsonToken.NAME; } - Object value = currentEntry.getValue(); + final Object value = currentEntry.getValue(); if (value instanceof Map) { return JsonToken.BEGIN_OBJECT; @@ -118,7 +119,7 @@ public JsonToken peek() throws IOException { @NotNull @Override public String nextName() throws IOException { - Map.Entry currentEntry = stack.peekLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry != null && currentEntry.getKey() != null) { return currentEntry.getKey(); } @@ -127,11 +128,11 @@ public String nextName() throws IOException { @Override public void beginObject() throws IOException { - Map.Entry currentEntry = stack.removeLast(); + final Map.Entry currentEntry = stack.removeLast(); if (currentEntry == null) { throw new IOException("No more entries"); } - Object value = currentEntry.getValue(); + 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)); @@ -153,17 +154,17 @@ public void endObject() throws IOException { @Override public void beginArray() throws IOException { - Map.Entry currentEntry = stack.removeLast(); + final Map.Entry currentEntry = stack.removeLast(); if (currentEntry == null) { throw new IOException("No more entries"); } - Object value = currentEntry.getValue(); + 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--) { - Object entry = ((List) value).get(i); + final Object entry = ((List) value).get(i); stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); } } else { @@ -185,7 +186,7 @@ public boolean hasNext() throws IOException { @Override public int nextInt() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).intValue(); } else { @@ -196,7 +197,7 @@ public int nextInt() throws IOException { @Nullable @Override public Integer nextIntegerOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).intValue(); } @@ -205,7 +206,7 @@ public Integer nextIntegerOrNull() throws IOException { @Override public long nextLong() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).longValue(); } else { @@ -216,7 +217,7 @@ public long nextLong() throws IOException { @Nullable @Override public Long nextLongOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).longValue(); } @@ -225,7 +226,7 @@ public Long nextLongOrNull() throws IOException { @Override public String nextString() throws IOException { - String value = nextValueOrNull(); + final String value = nextValueOrNull(); if (value != null) { return value; } else { @@ -241,7 +242,7 @@ public String nextStringOrNull() throws IOException { @Override public boolean nextBoolean() throws IOException { - Boolean value = nextValueOrNull(); + final Boolean value = nextValueOrNull(); if (value != null) { return value; } else { @@ -257,7 +258,7 @@ public Boolean nextBooleanOrNull() throws IOException { @Override public double nextDouble() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).doubleValue(); } else { @@ -268,7 +269,7 @@ public double nextDouble() throws IOException { @Nullable @Override public Double nextDoubleOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).doubleValue(); } @@ -278,7 +279,7 @@ public Double nextDoubleOrNull() throws IOException { @Nullable @Override public Float nextFloatOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).floatValue(); } @@ -287,7 +288,7 @@ public Float nextFloatOrNull() throws IOException { @Override public float nextFloat() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).floatValue(); } else { @@ -297,14 +298,14 @@ public float nextFloat() throws IOException { @Override public void nextNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value != null) { throw new IOException("Expected null but was " + peek()); } } @Override - public void setLenient(boolean lenient) {} + public void setLenient(final boolean lenient) {} @Override public void skipValue() throws IOException {} @@ -324,17 +325,17 @@ private T nextValueOrNull() throws IOException { private T nextValueOrNull( final @Nullable ILogger logger, final @Nullable JsonDeserializer deserializer) throws Exception { - Map.Entry currentEntry = stack.peekLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry == null) { return null; } - T value = (T) currentEntry.getValue(); + final T value = (T) currentEntry.getValue(); if (deserializer != null && logger != null) { return deserializer.deserialize(this, logger); } else if (value instanceof List) { List list = new ArrayList<>((List) value); if (!list.isEmpty()) { - T next = (T) list.remove(0); + final T next = (T) list.remove(0); if (next instanceof Map) { stack.addLast(new AbstractMap.SimpleEntry<>(null, next)); } From f1ca9f68bb0444b43eb705811a9176bf869fc89e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:40:33 +0100 Subject: [PATCH 022/184] Fix test --- .../io/sentry/protocol/SentryReplayEventSerializationTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt index 82b32e96c7..7be66d31e7 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -27,6 +27,10 @@ class SentryReplayEventSerializationTest { errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") traceIds = listOf("340cfef948204549ac07c3b353c81c50") SentryBaseEventSerializationTest.Fixture().update(this) + // irrelevant for replay + serverName = null + breadcrumbs = null + extras = null } } private val fixture = Fixture() From fbbe0d97b431de445abf0f8184751ab7652f54db Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:56:52 +0100 Subject: [PATCH 023/184] Fix test --- .../android/core/SessionTrackingIntegrationTest.kt | 9 +++++++++ .../protocol/SentryReplayEventSerializationTest.kt | 4 ++++ 2 files changed, 13 insertions(+) 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 af32fa3714..341d15e608 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 @@ -15,6 +15,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 @@ -141,6 +142,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/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt index 82b32e96c7..7be66d31e7 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -27,6 +27,10 @@ class SentryReplayEventSerializationTest { errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") traceIds = listOf("340cfef948204549ac07c3b353c81c50") SentryBaseEventSerializationTest.Fixture().update(this) + // irrelevant for replay + serverName = null + breadcrumbs = null + extras = null } } private val fixture = Fixture() From fd6396040f50ce68a397782961ee4478de485506 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 11:54:19 +0100 Subject: [PATCH 024/184] Address review --- .../src/main/java/io/sentry/SentryEnvelopeItem.java | 2 +- .../src/main/java/io/sentry/SentryReplayEvent.java | 9 ++------- .../main/java/io/sentry/rrweb/RRWebMetaEvent.java | 2 ++ .../main/java/io/sentry/rrweb/RRWebVideoEvent.java | 12 +++++++----- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index be6838670e..2512845129 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -435,7 +435,7 @@ private static byte[] serializeToMsgpack(Map map) throws IOExcep // Iterate over the map and serialize each key-value pair for (Map.Entry entry : map.entrySet()) { // Pack the key as a string - byte[] keyBytes = entry.getKey().getBytes(Charset.forName("UTF-8")); + byte[] keyBytes = entry.getKey().getBytes(UTF_8); int keyLength = keyBytes.length; // string up to 255 chars baos.write((byte) (0xd9)); diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index a351a21d54..eaab8d0ae3 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -5,7 +5,6 @@ import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -192,16 +191,12 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger 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, BigDecimal.valueOf(DateUtils.dateToSeconds(timestamp))); + 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, BigDecimal.valueOf(DateUtils.dateToSeconds(replayStartTimestamp))); + writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); } if (urls != null) { writer.name(JsonKeys.URLS).value(logger, urls); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index 0a1c914cb3..dee4fb2ef3 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -20,6 +20,8 @@ public final class RRWebMetaEvent extends RRWebEvent implements JsonUnknown, Jso 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; diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index f9d61e591e..c55e055cca 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -36,6 +36,8 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js 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; @@ -334,7 +336,7 @@ private void deserializeData( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map dataUknown = null; + Map dataUnknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -348,13 +350,13 @@ private void deserializeData( deserializePayload(event, reader, logger); break; default: - if (dataUknown == null) { - dataUknown = new ConcurrentHashMap<>(); + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); } - reader.nextUnknown(logger, dataUknown, nextName); + reader.nextUnknown(logger, dataUnknown, nextName); } } - event.setDataUnknown(dataUknown); + event.setDataUnknown(dataUnknown); reader.endObject(); } From 93785cc282c0c2a32b1dcd3e8a57a32ff2aa7658 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 12:10:30 +0100 Subject: [PATCH 025/184] Add finals and annotations --- sentry/src/main/java/io/sentry/Hub.java | 2 +- .../java/io/sentry/MainEventProcessor.java | 3 +- .../main/java/io/sentry/ReplayRecording.java | 8 +-- .../java/io/sentry/SentryEnvelopeItem.java | 54 ++++++++++--------- .../java/io/sentry/SentryReplayEvent.java | 8 +-- .../main/java/io/sentry/rrweb/RRWebEvent.java | 12 +++-- .../java/io/sentry/rrweb/RRWebEventType.java | 5 +- .../java/io/sentry/rrweb/RRWebMetaEvent.java | 10 ++-- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 23 ++++---- 9 files changed, 66 insertions(+), 59 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index b91b551705..8861c1c67f 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -932,7 +932,7 @@ private IScope buildLocalScope( "Instance is disabled and this 'captureReplay' call is a no-op."); } else { try { - StackItem item = stack.peek(); + 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); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index e79ebc37bd..d6445e3a56 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -150,7 +150,8 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { } @Override - public @NotNull SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + 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 diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index d8892191f2..4e6aecdb2b 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -31,7 +31,7 @@ public Integer getSegmentId() { return segmentId; } - public void setSegmentId(@Nullable Integer segmentId) { + public void setSegmentId(final @Nullable Integer segmentId) { this.segmentId = segmentId; } @@ -40,7 +40,7 @@ public List getPayload() { return payload; } - public void setPayload(@Nullable List payload) { + public void setPayload(final @Nullable List payload) { this.payload = payload; } @@ -136,11 +136,11 @@ public static final class Deserializer implements JsonDeserializer eventMap = (Map) event; final ObjectReader mapReader = new MapObjectReader(eventMap); - for (Map.Entry entry : eventMap.entrySet()) { + for (final Map.Entry entry : eventMap.entrySet()) { final String key = entry.getKey(); final Object value = entry.getValue(); if (key.equals("type")) { - RRWebEventType type = RRWebEventType.values()[(int) value]; + final RRWebEventType type = RRWebEventType.values()[(int) value]; switch (type) { case Meta: final RRWebEvent metaEvent = diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 2512845129..ff162f4464 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -425,32 +425,34 @@ public CachedItem(final @Nullable Callable dataFactory) { } } - @SuppressWarnings({"CharsetObjectCanBeUsed", "UnnecessaryParentheses"}) - private static byte[] serializeToMsgpack(Map map) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - // Write map header - baos.write((byte) (0x80 | map.size())); - - // Iterate over the map and serialize each key-value pair - for (Map.Entry entry : map.entrySet()) { - // Pack the key as a string - byte[] keyBytes = entry.getKey().getBytes(UTF_8); - 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 - byte[] valueBytes = entry.getValue(); - 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); - } + @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(); + return baos.toByteArray(); + } } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index eaab8d0ae3..95623d2ff6 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -22,15 +22,15 @@ public enum ReplayType implements JsonSerializable { BUFFER; @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + 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(@NotNull ObjectReader reader, @NotNull ILogger logger) - throws Exception { + public @NotNull ReplayType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -236,7 +236,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull RRWebEventType deserialize( - @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + 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/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index dee4fb2ef3..b0aca2f337 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -127,11 +127,11 @@ public static final class Deserializer implements JsonDeserializer unknown = null; - RRWebMetaEvent event = new RRWebMetaEvent(); - RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + @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(); @@ -159,7 +159,7 @@ private void deserializeData( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map unknown = null; + @Nullable Map unknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index c55e055cca..5bea9e3c47 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -242,14 +242,15 @@ public static final class JsonKeys { } @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + 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 (String key : unknown.keySet()) { - Object value = unknown.get(key); + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); writer.name(key); writer.value(logger, value); } @@ -289,8 +290,8 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.name(JsonKeys.LEFT).value(left); writer.name(JsonKeys.TOP).value(top); if (payloadUnknown != null) { - for (String key : payloadUnknown.keySet()) { - Object value = payloadUnknown.get(key); + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); writer.name(key); writer.value(logger, value); } @@ -303,12 +304,12 @@ public static final class Deserializer implements JsonDeserializer unknown = null; + @Nullable Map unknown = null; - RRWebVideoEvent event = new RRWebVideoEvent(); - RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + final RRWebVideoEvent event = new RRWebVideoEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); while (reader.peek() == JsonToken.NAME) { final String nextName = reader.nextName(); @@ -336,7 +337,7 @@ private void deserializeData( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map dataUnknown = null; + @Nullable Map dataUnknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -365,7 +366,7 @@ private void deserializePayload( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map payloadUnknown = null; + @Nullable Map payloadUnknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { From 4e55ec0fbaeea6ee01bd23344b6cf31a6330e0bf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 12:28:28 +0100 Subject: [PATCH 026/184] Specify SHA for license headers --- .../src/main/java/io/sentry/android/replay/Windows.kt | 2 +- .../java/io/sentry/android/replay/video/SimpleFrameMuxer.kt | 2 +- .../java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt | 2 +- .../java/io/sentry/android/replay/video/SimpleVideoEncoder.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 7238c8cc2d..86ff440d02 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/square/curtains + * Adapted from https://github.com/square/curtains/tree/v1.2.5 * * Copyright 2021 Square Inc. * 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 index 70bb8cff46..17f454967b 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * 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 * 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 index 69f92701d3..bdedb888cd 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleMp4FrameMuxer.kt + * 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 * diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 70fe81d7a4..eeafc61a36 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt + * 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 * From 9603672ca97f2d03654aca55e79edd4cab9f34a7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 14:24:29 +0100 Subject: [PATCH 027/184] Address review from Dhiogo --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 2 +- .../sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index a37561b649..8fd4d61f0b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -43,7 +43,7 @@ internal class ScreenshotRecorder( lastCapturedAtMs = now val root = rootView.get() - if (root == null || root.width <= 0 || root.height <= 0) { + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index f044ef0850..77ef0c8c97 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -128,9 +128,11 @@ data class ViewHierarchyNode( } private 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 > 0 && bitmap.width > 0 + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 10 && bitmap.width > 10 else -> true } } From 1ce57cbd24afa08a583d28e0ab6e2365a0592203 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 14:17:55 +0100 Subject: [PATCH 028/184] Address review from Markus --- buildSrc/src/main/java/Config.kt | 2 +- .../android/replay/ScreenshotRecorder.kt | 81 ++++++++++++++----- .../sentry/android/replay/WindowRecorder.kt | 53 ++++++------ .../replay/video/SimpleVideoEncoder.kt | 4 + .../replay/viewhierarchy/ViewHierarchyNode.kt | 50 ++---------- 5 files changed, 97 insertions(+), 93 deletions(-) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8a02ce0e65..f06d9bc5d7 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -34,7 +34,7 @@ object Config { val minSdkVersion = 19 val minSdkVersionOkHttp = 21 - val minSdkVersionReplay = 26 + val minSdkVersionReplay = 19 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 8fd4d61f0b..ff36495f2f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -1,9 +1,11 @@ package io.sentry.android.replay +import android.annotation.TargetApi import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color +import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Rect import android.graphics.RectF import android.os.Handler import android.os.HandlerThread @@ -20,14 +22,26 @@ import java.lang.ref.WeakReference import java.util.WeakHashMap import kotlin.system.measureTimeMillis +// TODO: use ILogger of Sentry and change level +@TargetApi(26) internal class ScreenshotRecorder( - val rootView: WeakReference, val encoder: SimpleVideoEncoder ) : ViewTreeObserver.OnDrawListener { + private var rootView: WeakReference? = null private val thread = HandlerThread("SentryReplay").also { it.start() } private val handler = Handler(thread.looper) private val bitmapToVH = WeakHashMap() + 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(encoder.muxerConfig.scaleFactor, encoder.muxerConfig.scaleFactor) + } companion object { const val TAG = "ScreenshotRecorder" @@ -37,12 +51,12 @@ internal class ScreenshotRecorder( override fun onDraw() { // TODO: replace with Debouncer from sentry-core val now = SystemClock.uptimeMillis() - if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { + if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 500L) { return } lastCapturedAtMs = now - val root = rootView.get() + val root = rootView?.get() if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { return } @@ -53,7 +67,6 @@ internal class ScreenshotRecorder( root.height, Bitmap.Config.ARGB_8888 ) - Log.e("BITMAP CREATED", bitmap.toString()) val time = measureTimeMillis { val rootNode = ViewHierarchyNode.fromView(root) @@ -62,8 +75,7 @@ internal class ScreenshotRecorder( } Log.e("TIME", time.toString()) -// val latch = CountDownLatch(1) - + // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { PixelCopy.request( window, @@ -78,26 +90,37 @@ internal class ScreenshotRecorder( Log.e("BITMAP CAPTURED", bitmap.toString()) val viewHierarchy = bitmapToVH[bitmap] - if (viewHierarchy != null) { - val canvas = Canvas(bitmap) + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) + + if (viewHierarchy == null) { + Log.e(TAG, "Failed to determine view hierarchy, not capturing") + return@request + } else { + val canvas = Canvas(scaledBitmap) + canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { if (it.shouldRedact && (it.width > 0 && it.height > 0)) { it.visibleRect ?: return@traverse - val paint = Paint().apply { - color = it.dominantColor ?: Color.BLACK + // TODO: check for view type rather than rely on absence of dominantColor here + val color = if (it.dominantColor == null) { + singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) + singlePixelBitmap.getPixel(0, 0) + } else { + it.dominantColor } - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) + + maskingPaint.setColor(color) + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) } } } - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, - true - ) // val baos = ByteArrayOutputStream() // scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) // val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) @@ -106,14 +129,30 @@ internal class ScreenshotRecorder( scaledBitmap.recycle() bitmap.recycle() Log.i(TAG, "Captured a screenshot") -// latch.countDown() }, handler ) } + } + + 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) + } -// val success = latch.await(200, MILLISECONDS) -// Log.i(TAG, "Captured a screenshot: $success") + fun close() { + unbind(rootView?.get()) + rootView?.clear() + thread.quitSafely() } private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 5e24a0af24..1a60d686b4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,21 +1,21 @@ package io.sentry.android.replay +import android.annotation.TargetApi import android.content.Context import android.graphics.Point import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.view.View -import android.view.ViewTreeObserver import android.view.WindowManager import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import java.io.File import java.lang.ref.WeakReference -import java.util.WeakHashMap import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt +@TargetApi(26) class WindowRecorder { private val rootViewsSpy by lazy(NONE) { @@ -24,28 +24,20 @@ class WindowRecorder { private var encoder: SimpleVideoEncoder? = null private val isRecording = AtomicBoolean(false) - private val recorders: WeakHashMap = WeakHashMap() + private val rootViews = ArrayList>() + private var recorder: ScreenshotRecorder? = null private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { - if (recorders.containsKey(root)) { - // TODO: log - return@OnRootViewsChangedListener - } - // stop tracking other windows so they don't interfere in the recording like a 25th frame effect - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) - } - - val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) - recorders[root] = recorder - root.viewTreeObserver?.addOnDrawListener(recorder) + rootViews.add(WeakReference(root)) + recorder?.bind(root) } else { - root.viewTreeObserver?.removeOnDrawListener(recorders[root]) - recorders.remove(root) + recorder?.unbind(root) + rootViews.removeAll { it.get() == root } - recorders.entries.forEach { - it.key.viewTreeObserver.addOnDrawListener(it.value) + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null && root != newRoot) { + recorder?.bind(newRoot) } } } @@ -62,13 +54,16 @@ class WindowRecorder { // context.resources.displayMetrics.density).roundToInt() // TODO: API level check // PixelCopy takes screenshots including system bars, so we have to get the real size here + val height: Int val aspectRatio = if (VERSION.SDK_INT >= VERSION_CODES.R) { - wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + height = wm.currentWindowMetrics.bounds.bottom + height.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() } else { val screenResolution = Point() @Suppress("DEPRECATION") wm.defaultDisplay.getRealSize(screenResolution) - screenResolution.y.toFloat() / screenResolution.x.toFloat() + height = screenResolution.y + height.toFloat() / screenResolution.x.toFloat() } val videoFile = File(context.cacheDir, "sentry-sr.mp4") @@ -77,21 +72,23 @@ class WindowRecorder { videoFile, videoWidth = (720 / aspectRatio).roundToInt(), videoHeight = 720, - frameRate = 1f, + scaleFactor = 720f / height, + frameRate = 2f, bitrate = 500 * 1000 ) - ) - encoder?.start() + ).also { it.start() } + recorder = ScreenshotRecorder(encoder!!) rootViewsSpy.listeners += onRootViewsChangedListener } fun stopRecording() { rootViewsSpy.listeners -= onRootViewsChangedListener - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) - } - recorders.clear() + rootViews.forEach { recorder?.unbind(it.get()) } + recorder?.close() + rootViews.clear() + recorder = null encoder?.startRelease() encoder = null + isRecording.set(false) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index eeafc61a36..4046eec37b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -29,6 +29,7 @@ */ package io.sentry.android.replay.video +import android.annotation.TargetApi import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo @@ -38,6 +39,7 @@ import android.os.Looper import android.view.Surface import java.io.File +@TargetApi(26) internal class SimpleVideoEncoder( val muxerConfig: MuxerConfig ) { @@ -156,10 +158,12 @@ internal class SimpleVideoEncoder( } } +@TargetApi(24) internal data class MuxerConfig( val file: File, val videoWidth: Int, val videoHeight: Int, + val scaleFactor: Float, val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, val frameRate: Float, val bitrate: Int, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 77ef0c8c97..ad9c6dfda8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,7 +1,6 @@ package io.sentry.android.replay.viewhierarchy -import android.graphics.Bitmap -import android.graphics.Canvas +import android.annotation.TargetApi import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable @@ -18,6 +17,7 @@ import android.widget.ImageView import android.widget.TextView // TODO: merge with ViewHierarchyNode from sentry-core maybe? +@TargetApi(26) data class ViewHierarchyNode( val x: Float, val y: Float, @@ -47,11 +47,11 @@ data class ViewHierarchyNode( return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) } - fun adjustAlpha(color: Int): Int { + fun Int.toOpaque(): Int { val alpha = 255 - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) + val red = Color.red(this) + val green = Color.green(this) + val blue = Color.blue(this) return Color.argb(alpha, red, green, blue) } @@ -77,7 +77,7 @@ data class ViewHierarchyNode( val bounds = Rect() val text = view.text.toString() view.paint.getTextBounds(text, 0, text.length, bounds) - dominantColor = adjustAlpha(view.currentTextColor) + dominantColor = view.currentTextColor.toOpaque() rect = Rect() view.getGlobalVisibleRect(rect) @@ -110,7 +110,6 @@ data class ViewHierarchyNode( is ImageView -> { shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) if (shouldRedact) { - dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) rect = Rect() view.getGlobalVisibleRect(rect) } @@ -136,40 +135,5 @@ data class ViewHierarchyNode( else -> true } } - - private fun Drawable.pickDominantColor(): Int { - // TODO: pick default color based on dark/light default theme - return when (this) { - is BitmapDrawable -> { - val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - color - } - - else -> { - if (intrinsicHeight > 0 && intrinsicWidth > 0) { - // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil - // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color - // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time - val bmp = - Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - try { - draw(canvas) - val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - bmp.recycle() - color - } catch (e: Throwable) { - Color.BLACK - } - } else { - Color.BLACK - } - } - } - } } } From 62477b47e3d8ffed2c24e0786717af4b70f6fa63 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 14:30:30 +0100 Subject: [PATCH 029/184] Remove public captureReplay method --- sentry/src/main/java/io/sentry/Sentry.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 6058199d62..0aff89c0d0 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1025,9 +1025,4 @@ public interface OptionsConfiguration { public static @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return getCurrentHub().captureCheckIn(checkIn); } - - public static void captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { - getCurrentHub().captureReplay(replay, hint); - } } From af42fb3578733c1af687b48133fd9272c54c2603 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 16:21:01 +0100 Subject: [PATCH 030/184] Fix test --- .../io/sentry/protocol/SentryReplayEventSerializationTest.kt | 4 ++-- sentry/src/test/resources/json/sentry_replay_event.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt index 7be66d31e7..6ecd680076 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -21,8 +21,8 @@ class SentryReplayEventSerializationTest { fun getSut() = SentryReplayEvent().apply { replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") segmentId = 0 - timestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") - replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + 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") diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json index 04c96968b9..dea5a99e59 100644 --- a/sentry/src/test/resources/json/sentry_replay_event.json +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -2,9 +2,9 @@ "type": "replay_event", "replay_type": "session", "segment_id": 0, - "timestamp": 987654321.123, + "timestamp": "1942-07-09T12:55:34.000Z", "replay_id": "f715e1d64ef64ea3ad7744b5230813c3", - "replay_start_timestamp": 987654321.123, + "replay_start_timestamp": "1942-07-09T12:55:34.000Z", "urls": [ "ScreenOne" From 1951891a845cc1bf167605183e5fe1384540df7e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 16:26:25 +0100 Subject: [PATCH 031/184] api dump --- sentry-android-replay/api/sentry-android-replay.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b6029a3370..8f334554f5 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -57,7 +57,7 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { - public final fun adjustAlpha (I)I public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun toOpaque (I)I } From 4e54c77b149347b3f44c5479f7976fc8417575a0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 2 Mar 2024 00:04:28 +0100 Subject: [PATCH 032/184] api dump --- sentry/api/sentry.api | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 20e2d8b53b..b6acae0cde 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1755,7 +1755,6 @@ public final class io/sentry/Sentry { public static fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; - public static fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)V public static fun captureUserFeedback (Lio/sentry/UserFeedback;)V public static fun clearBreadcrumbs ()V public static fun cloneMainHub ()Lio/sentry/IHub; From b2940c424147399abc28e81e0bb441780bc932c6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 Mar 2024 12:00:40 +0100 Subject: [PATCH 033/184] Address review from Markus --- .../android/replay/ScreenshotRecorder.kt | 19 +++++++++++-------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 10 ++-------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ff36495f2f..3b3a6758fc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -90,17 +90,18 @@ internal class ScreenshotRecorder( Log.e("BITMAP CAPTURED", bitmap.toString()) val viewHierarchy = bitmapToVH[bitmap] - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, - true - ) + var scaledBitmap: Bitmap? = null if (viewHierarchy == null) { Log.e(TAG, "Failed to determine view hierarchy, not capturing") return@request } else { + scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { @@ -124,9 +125,11 @@ internal class ScreenshotRecorder( // val baos = ByteArrayOutputStream() // scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) // val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) - encoder.encode(scaledBitmap) + scaledBitmap?.let { + encoder.encode(it) + it.recycle() + } // bmp.recycle() - scaledBitmap.recycle() bitmap.recycle() Log.i(TAG, "Captured a screenshot") }, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index ad9c6dfda8..9b6a068f05 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,7 +1,6 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi -import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable @@ -47,13 +46,8 @@ data class ViewHierarchyNode( return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) } - fun Int.toOpaque(): Int { - val alpha = 255 - val red = Color.red(this) - val green = Color.green(this) - val blue = Color.blue(this) - return Color.argb(alpha, red, green, blue) - } + // TODO: check if this works on RN + private fun Int.toOpaque() = this or 0xFF000000.toInt() fun fromView(view: View): ViewHierarchyNode { // TODO: Extract redacting into its own class/function From 002a0f35f58d06784caad862666d965bd7ce8f0e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 Mar 2024 12:24:43 +0100 Subject: [PATCH 034/184] Api dump --- sentry-android-replay/api/sentry-android-replay.api | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 8f334554f5..e81c5840ea 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -58,6 +58,5 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public final fun toOpaque (I)I } From 9e87fe8ce0ff8d95c761fa1ec2fb110b98816048 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 19 Mar 2024 15:43:22 +0100 Subject: [PATCH 035/184] Add replay integration --- .../api/sentry-android-core.api | 4 + sentry-android-core/build.gradle.kts | 1 + .../core/AndroidOptionsInitializer.java | 8 +- .../sentry/android/core/LifecycleWatcher.java | 62 ++-- .../io/sentry/android/core/SentryAndroid.java | 80 ++++- .../api/sentry-android-replay.api | 28 +- sentry-android-replay/build.gradle.kts | 4 + .../io/sentry/android/replay/ReplayCache.kt | 171 ++++++++++ .../android/replay/ReplayIntegration.kt | 313 ++++++++++++++++++ .../android/replay/ScreenshotRecorder.kt | 145 ++++---- .../sentry/android/replay/WindowRecorder.kt | 83 ++--- .../java/io/sentry/android/replay/Windows.kt | 10 +- .../replay/video/SimpleMp4FrameMuxer.kt | 7 +- .../replay/video/SimpleVideoEncoder.kt | 143 ++++---- .../sentry/android/replay/ReplayCacheTest.kt | 86 +++++ sentry-android/build.gradle.kts | 1 + sentry/api/sentry.api | 10 +- sentry/src/main/java/io/sentry/IScope.java | 18 + sentry/src/main/java/io/sentry/NoOpScope.java | 9 + sentry/src/main/java/io/sentry/Scope.java | 17 + .../java/io/sentry/SentryEnvelopeItem.java | 5 +- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 8 +- 22 files changed, 1005 insertions(+), 208 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 51eb48f1b2..55278c6356 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,6 +245,10 @@ public final class io/sentry/android/core/SentryAndroid { public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V public static fun init (Landroid/content/Context;Lio/sentry/Sentry$OptionsConfiguration;)V + public static fun pauseReplay ()V + public static fun resumeReplay ()V + public static fun startReplay ()V + public static fun stopReplay ()V } public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/SentryDateProvider { diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 4ab0aec423..da4851c92a 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) // lifecycle processor, session tracking 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 41d0dec6b2..b58051cee7 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,7 @@ 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.ReplayIntegration; import io.sentry.android.timber.SentryTimberIntegration; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; @@ -29,6 +30,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; @@ -230,7 +232,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 @@ -295,6 +298,9 @@ static void installDefaultIntegrations( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); + if (isReplayAvailable) { + options.addIntegration(new ReplayIntegration(context, CurrentDateProvider.getInstance())); + } } /** 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 7b38bcd9c2..ff281d2beb 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,45 @@ 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); + SentryAndroid.startReplay(); + } else if (!isFreshSession.getAndSet(false)) { + // only resume if it's not a fresh session, which has been started in SentryAndroid.init + SentryAndroid.resumeReplay(); } + 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(); - } + SentryAndroid.pauseReplay(); + scheduleEndSession(); AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); @@ -122,8 +123,11 @@ private void scheduleEndSession() { new TimerTask() { @Override public void run() { - addSessionBreadcrumb("end"); - hub.endSession(); + if (enableSessionTracking) { + addSessionBreadcrumb("end"); + hub.endSession(); + } + SentryAndroid.stopReplay(); } }; @@ -164,7 +168,7 @@ TimerTask getTimerTask() { } @TestOnly - @Nullable + @NotNull Timer getTimer() { return timer; } 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 af68a026fb..39a2019d1d 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 @@ -15,6 +15,8 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; +import io.sentry.android.replay.ReplayIntegration; +import io.sentry.android.replay.ReplayIntegrationKt; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -33,6 +35,11 @@ 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 boolean isReplayAvailable = false; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -99,6 +106,8 @@ public static synchronized void init( final boolean isTimberAvailable = (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); + isReplayAvailable = + classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final LoadClass loadClass = new LoadClass(); @@ -118,7 +127,8 @@ public static synchronized void init( loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable); + isTimberAvailable, + isReplayAvailable); configuration.configure(options); @@ -145,9 +155,12 @@ public static synchronized void init( true); final @NotNull IHub hub = Sentry.getCurrentHub(); - if (hub.getOptions().isEnableAutoSessionTracking() && ContextUtils.isForegroundImportance()) { - hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); - hub.startSession(); + if (ContextUtils.isForegroundImportance()) { + if (hub.getOptions().isEnableAutoSessionTracking()) { + hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); + hub.startSession(); + } + startReplay(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); @@ -212,4 +225,63 @@ private static void deduplicateIntegrations( } } } + + public static synchronized void startReplay() { + performReplayAction( + "starting", + (replay) -> { + replay.start(); + }); + } + + public static synchronized void stopReplay() { + performReplayAction( + "stopping", + (replay) -> { + replay.stop(); + }); + } + + public static synchronized void resumeReplay() { + performReplayAction( + "resuming", + (replay) -> { + replay.resume(); + }); + } + + public static synchronized void pauseReplay() { + performReplayAction( + "pausing", + (replay) -> { + replay.pause(); + }); + } + + private static void performReplayAction( + final @NotNull String actionName, final @NotNull ReplayCallable action) { + final @NotNull IHub hub = Sentry.getCurrentHub(); + if (isReplayAvailable) { + final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); + if (replay != null) { + action.call(replay); + } else { + hub.getOptions() + .getLogger() + .log( + SentryLevel.INFO, + "Session Replay wasn't registered yet, not " + actionName + " the replay"); + } + } else { + hub.getOptions() + .getLogger() + .log( + SentryLevel.INFO, + "Session Replay wasn't found on classpath, not " + actionName + " the replay"); + } + } + + private interface ReplayCallable { + void call(final @NotNull ReplayIntegration replay); + } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index e81c5840ea..5af2ca943b 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,10 +6,30 @@ public final class io/sentry/android/replay/BuildConfig { public fun ()V } -public final class io/sentry/android/replay/WindowRecorder { - public fun ()V - public final fun startRecording (Landroid/content/Context;)V - public final fun stopRecording ()V +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { + public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; + public static final field VIDEO_BUFFER_DURATION J + public static final field VIDEO_SEGMENT_DURATION J + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V + public fun close ()V + public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public final fun pause ()V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun resume ()V + public final fun start ()V + public final fun stop ()V +} + +public final class io/sentry/android/replay/ReplayIntegration$Companion { +} + +public final class io/sentry/android/replay/ReplayIntegrationKt { + public static final fun getReplayIntegration (Lio/sentry/IHub;)Lio/sentry/android/replay/ReplayIntegration; + public static final fun gracefullyShutdown (Ljava/util/concurrent/ExecutorService;Lio/sentry/SentryOptions;)V +} + +public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { + public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V } public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2314960f5e..319386ee2b 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -19,6 +19,8 @@ android { targetSdk = Config.Android.targetSdkVersion minSdk = Config.Android.minSdkVersionReplay + testInstrumentationRunner = Config.TestLibs.androidJUnitRunner + // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } @@ -67,7 +69,9 @@ dependencies { // 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) 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 0000000000..cf6d6a93aa --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -0,0 +1,171 @@ +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 io.sentry.util.FileUtils +import java.io.Closeable +import java.io.File + +internal class ReplayCache( + private val options: SentryOptions, + private val replayId: SentryId, + private val recorderConfig: ScreenshotRecorderConfig +) : Closeable { + + private val encoderLock = Any() + private var encoder: SimpleVideoEncoder? = null + + private 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 + private val frames = mutableListOf() + + 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() + } + + val frame = ReplayFrame(screenshot, frameTimestamp) + frames += frame + } + + fun createVideoOf(duration: Long, from: Long, segmentId: Int): 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 + val videoFile = File(replayCacheDir, "$segmentId.mp4") + encoder = synchronized(encoderLock) { + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recorderConfig = recorderConfig, + frameRate = recorderConfig.frameRate.toFloat(), + bitrate = 20 * 1000 + ) + ) + }.also { it.start() } + + 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() + val frameCountBefore = frameCount + while (iter.hasNext()) { + val frame = iter.next() + if (frame.timestamp in (timestamp..timestamp + step)) { + frameCount++ + encode(frame) + lastFrame = frame + break // we only support 1 frame per given interval + } + } + + // if the frame count hasn't changed we just replicate the last known frame to respect + // the video duration. + if (frameCountBefore == frameCount) { + frameCount++ + encode(lastFrame) + } + } + + 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 + } + + frames.removeAll { + if (it.timestamp < (from + duration)) { + deleteFile(it.screenshot) + return@removeAll true + } + return@removeAll false + } + + return GeneratedVideo(videoFile, frameCount, videoDuration) + } + + private fun encode(frame: ReplayFrame) { + val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) + synchronized(encoderLock) { + encoder?.encode(bitmap) + } + bitmap.recycle() + } + + 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) + } + } + + fun cleanup() { + FileUtils.deleteRecursively(replayCacheDir) + } + + override fun close() { + synchronized(encoderLock) { + encoder?.release() + encoder = null + } + } +} + +internal data class ReplayFrame( + val screenshot: File, + val timestamp: Long +) + +internal 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 0000000000..26b35f9a15 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -0,0 +1,313 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Point +import android.graphics.Rect +import android.os.Build +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.view.WindowManager +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Integration +import io.sentry.ReplayRecording +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.ICurrentDateProvider +import java.io.Closeable +import java.io.File +import java.util.Date +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.LazyThreadSafetyMode.NONE +import kotlin.math.roundToInt + +class ReplayIntegration( + private val context: Context, + private val dateProvider: ICurrentDateProvider +) : Integration, Closeable, ScreenshotRecorderCallback { + + companion object { + const val VIDEO_SEGMENT_DURATION = 5_000L + const val VIDEO_BUFFER_DURATION = 30_000L + } + + private lateinit var options: SentryOptions + private var hub: IHub? = null + private var recorder: WindowRecorder? = null + private var cache: ReplayCache? = null + + // TODO: probably not everything has to be thread-safe here + private val isEnabled = AtomicBoolean(false) + private val isRecording = AtomicBoolean(false) + private val currentReplayId = AtomicReference() + private val segmentTimestamp = AtomicReference() + private val currentSegment = AtomicInteger(0) + private val saver = + Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + + private val screenBounds by lazy(NONE) { + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + 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) + } + } + + private val aspectRatio by lazy(NONE) { + screenBounds.bottom.toFloat() / screenBounds.right.toFloat() + } + + private val recorderConfig by lazy(NONE) { + ScreenshotRecorderConfig( + recordingWidth = (720 / aspectRatio).roundToInt(), + recordingHeight = 720, + scaleFactor = 720f / screenBounds.bottom + ) + } + + 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 + } + + // TODO: check for replaysSessionSampleRate and replaysOnErrorSampleRate + + this.hub = hub + recorder = WindowRecorder(options, recorderConfig, this) + isEnabled.set(true) + } + + fun start() { + if (!isEnabled.get()) { + options.logger.log( + DEBUG, + "Session replay is disabled due to conditions not met in Integration.register" + ) + return + } + + if (isRecording.getAndSet(true)) { + options.logger.log( + DEBUG, + "Session replay is already being recorded, not starting a new one" + ) + return + } + + currentSegment.set(0) + currentReplayId.set(SentryId()) + hub?.configureScope { it.replayId = currentReplayId.get() } + cache = ReplayCache(options, currentReplayId.get(), recorderConfig) + + recorder?.startRecording() + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) + } + + fun resume() { + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + recorder?.resume() + } + + fun pause() { + val now = dateProvider.currentTimeMillis + recorder?.pause() + + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + saver.submit { + val videoDuration = + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + } + } + + fun stop() { + if (!isEnabled.get()) { + options.logger.log( + DEBUG, + "Session replay is disabled due to conditions not met in Integration.register" + ) + return + } + + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + saver.submit { + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + cache?.cleanup() + } + + recorder?.stopRecording() + cache?.close() + currentSegment.set(0) + segmentTimestamp.set(null) + currentReplayId.set(null) + hub?.configureScope { it.replayId = null } + isRecording.set(false) + } + + override fun onScreenshotRecorded(bitmap: Bitmap) { + // 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 + saver.submit { + cache?.addFrame(bitmap, frameTimestamp) + + val now = dateProvider.currentTimeMillis + if (now - segmentTimestamp.get().time >= VIDEO_SEGMENT_DURATION) { + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + + val videoDuration = + createAndCaptureSegment( + VIDEO_SEGMENT_DURATION, + currentSegmentTimestamp, + replayId, + segmentId + ) + if (videoDuration != null) { + currentSegment.getAndIncrement() + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) + } + } + } + } + + private fun createAndCaptureSegment( + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int + ): Long? { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId + ) ?: return null + + val (video, frameCount, videoDuration) = generatedVideo + captureReplay( + video, + replayId, + currentSegmentTimestamp, + segmentId, + frameCount, + videoDuration + ) + return videoDuration + } + + private fun captureReplay( + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + frameCount: Int, + duration: Long + ) { + val replay = SentryReplayEvent().apply { + eventId = currentReplayId + replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) + if (segmentId == 0) { + replayStartTimestamp = segmentTimestamp + } + videoFile = video + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = listOf( + RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + height = recorderConfig.recordingHeight + width = recorderConfig.recordingWidth + }, + RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.duration = duration + this.frameCount = frameCount + size = video.length() + frameRate = recorderConfig.frameRate + height = recorderConfig.recordingHeight + width = recorderConfig.recordingWidth + // TODO: support non-fullscreen windows later + left = 0 + top = 0 + } + ) + } + + val hint = Hint().apply { replayRecording = recording } + hub?.captureReplay(replay, hint) + } + + override fun close() { + stop() + saver.gracefullyShutdown(options) + } + + private class ReplayExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayIntegration-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} + +/** + * Retrieves the [ReplayIntegration] from the list of integrations in [SentryOptions] + */ +fun IHub.getReplayIntegration(): ReplayIntegration? = + options.integrations.find { it is ReplayIntegration } as? ReplayIntegration + +fun ExecutorService.gracefullyShutdown(options: SentryOptions) { + synchronized(this) { + if (!isShutdown) { + shutdown() + } + try { + if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { + shutdownNow() + } + } catch (e: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 3b3a6758fc..767c3614ba 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -2,34 +2,35 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF import android.os.Handler import android.os.HandlerThread import android.os.Looper -import android.os.SystemClock -import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.measureTimeMillis -// TODO: use ILogger of Sentry and change level @TargetApi(26) internal class ScreenshotRecorder( - val encoder: SimpleVideoEncoder + val config: ScreenshotRecorderConfig, + val options: SentryOptions, + private val screenshotRecorderCallback: ScreenshotRecorderCallback ) : ViewTreeObserver.OnDrawListener { private var rootView: WeakReference? = null - private val thread = HandlerThread("SentryReplay").also { it.start() } + private val thread = HandlerThread("SentryReplayRecorder").also { it.start() } private val handler = Handler(thread.looper) private val bitmapToVH = WeakHashMap() private val maskingPaint = Paint() @@ -40,24 +41,32 @@ internal class ScreenshotRecorder( ) private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) private val prescaledMatrix = Matrix().apply { - preScale(encoder.muxerConfig.scaleFactor, encoder.muxerConfig.scaleFactor) + preScale(config.scaleFactor, config.scaleFactor) } + private val contentChanged = AtomicBoolean(false) + private val isCapturing = AtomicBoolean(true) + private var lastScreenshot: Bitmap? = null - companion object { - const val TAG = "ScreenshotRecorder" - } + fun capture() { + if (!isCapturing.get()) { + options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") + return + } - private var lastCapturedAtMs: Long? = null - override fun onDraw() { - // TODO: replace with Debouncer from sentry-core - val now = SystemClock.uptimeMillis() - if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 500L) { + if (!contentChanged.get() && lastScreenshot != null) { + options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") + + lastScreenshot?.let { + screenshotRecorderCallback.onScreenshotRecorded( + it.copy(ARGB_8888, false) + ) + } return } - lastCapturedAtMs = now 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 } @@ -68,76 +77,75 @@ internal class ScreenshotRecorder( Bitmap.Config.ARGB_8888 ) - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - bitmapToVH[bitmap] = rootNode - } - Log.e("TIME", time.toString()) - // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + bitmapToVH[bitmap] = rootNode + } + options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) + PixelCopy.request( window, bitmap, { copyResult: Int -> - Log.d(TAG, "PixelCopy result: $copyResult") if (copyResult != PixelCopy.SUCCESS) { - Log.e(TAG, "Failed to capture screenshot") + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) return@request } - Log.e("BITMAP CAPTURED", bitmap.toString()) val viewHierarchy = bitmapToVH[bitmap] - - var scaledBitmap: Bitmap? = null + val scaledBitmap: Bitmap if (viewHierarchy == null) { - Log.e(TAG, "Failed to determine view hierarchy, not capturing") + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") return@request } else { scaledBitmap = Bitmap.createScaledBitmap( bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, + config.recordingWidth, + config.recordingHeight, true ) val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - // TODO: check for view type rather than rely on absence of dominantColor here - val color = if (it.dominantColor == null) { - singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) - singlePixelBitmap.getPixel(0, 0) - } else { - it.dominantColor - } - - maskingPaint.setColor(color) - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) - } +// if (it.shouldRedact && (it.width > 0 && it.height > 0)) { +// it.visibleRect ?: return@traverse +// +// // TODO: check for view type rather than rely on absence of dominantColor here +// val color = if (it.dominantColor == null) { +// singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) +// singlePixelBitmap.getPixel(0, 0) +// } else { +// it.dominantColor +// } +// +// maskingPaint.setColor(color) +// canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) +// } } } -// val baos = ByteArrayOutputStream() -// scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) -// val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) - scaledBitmap?.let { - encoder.encode(it) - it.recycle() - } -// bmp.recycle() + val screenshot = scaledBitmap.copy(ARGB_8888, false) + screenshotRecorderCallback.onScreenshotRecorded(screenshot) + lastScreenshot = screenshot + contentChanged.set(false) + + scaledBitmap.recycle() bitmap.recycle() - Log.i(TAG, "Captured a screenshot") + bitmapToVH.remove(bitmap) }, handler ) } } + override fun onDraw() { + contentChanged.set(true) + } + fun bind(root: View) { // first unbind the current root unbind(rootView?.get()) @@ -152,9 +160,23 @@ internal class ScreenshotRecorder( 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() + bitmapToVH.clear() + isCapturing.set(false) thread.quitSafely() } @@ -188,3 +210,14 @@ internal class ScreenshotRecorder( parentNode.children = childNodes } } + +internal data class ScreenshotRecorderConfig( + val recordingWidth: Int, + val recordingHeight: Int, + val scaleFactor: Float, + val frameRate: Int = 2 +) + +interface ScreenshotRecorderCallback { + fun onScreenshotRecorded(bitmap: Bitmap) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 1a60d686b4..d23222368f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,31 +1,34 @@ package io.sentry.android.replay import android.annotation.TargetApi -import android.content.Context -import android.graphics.Point -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import android.view.View -import android.view.WindowManager -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder -import java.io.File +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import java.io.Closeable 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 -import kotlin.math.roundToInt @TargetApi(26) -class WindowRecorder { +internal class WindowRecorder( + private val options: SentryOptions, + private val recorderConfig: ScreenshotRecorderConfig, + private val screenshotRecorderCallback: ScreenshotRecorderCallback +) : Closeable { private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } - private var encoder: SimpleVideoEncoder? = null private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() private var recorder: ScreenshotRecorder? = null + private var capturingTask: ScheduledFuture<*>? = null + private val capturer = Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { @@ -42,53 +45,53 @@ class WindowRecorder { } } - fun startRecording(context: Context) { + fun startRecording() { if (isRecording.getAndSet(true)) { return } - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager // val (height, width) = (wm.currentWindowMetrics.bounds.bottom / // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() - // TODO: API level check - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val height: Int - val aspectRatio = if (VERSION.SDK_INT >= VERSION_CODES.R) { - height = wm.currentWindowMetrics.bounds.bottom - height.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() - } else { - val screenResolution = Point() - @Suppress("DEPRECATION") - wm.defaultDisplay.getRealSize(screenResolution) - height = screenResolution.y - height.toFloat() / screenResolution.x.toFloat() - } - val videoFile = File(context.cacheDir, "sentry-sr.mp4") - encoder = SimpleVideoEncoder( - MuxerConfig( - videoFile, - videoWidth = (720 / aspectRatio).roundToInt(), - videoHeight = 720, - scaleFactor = 720f / height, - frameRate = 2f, - bitrate = 500 * 1000 - ) - ).also { it.start() } - recorder = ScreenshotRecorder(encoder!!) + recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) rootViewsSpy.listeners += onRootViewsChangedListener + capturingTask = capturer.scheduleAtFixedRate({ + try { + recorder?.capture() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to capture a screenshot with exception:", e) + // TODO: I guess schedule the capturer again, cause it will stop executing the runnable? + } + }, 0L, 1000L / recorderConfig.frameRate, MILLISECONDS) } + fun resume() = recorder?.resume() + fun pause() = recorder?.pause() + fun stopRecording() { rootViewsSpy.listeners -= onRootViewsChangedListener rootViews.forEach { recorder?.unbind(it.get()) } recorder?.close() rootViews.clear() recorder = null - encoder?.startRelease() - encoder = null + capturingTask?.cancel(false) + capturingTask = null isRecording.set(false) } + + 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 + } + } + + override fun close() { + stopRecording() + capturer.gracefullyShutdown(options) + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 86ff440d02..ece684c46e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -20,6 +20,8 @@ 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 @@ -152,8 +154,12 @@ internal class RootViewsSpy private constructor() { companion object { fun install(): RootViewsSpy { return RootViewsSpy().apply { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } + // 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) } + } } } } 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 index bdedb888cd..db34b2dbea 100644 --- 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 @@ -32,9 +32,10 @@ package io.sentry.android.replay.video import android.media.MediaCodec import android.media.MediaFormat import android.media.MediaMuxer -import android.util.Log 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, private val fps: Float) : SimpleFrameMuxer { private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() @@ -50,7 +51,6 @@ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMux override fun start(videoFormat: MediaFormat) { videoTrackIndex = muxer.addTrack(videoFormat) - Log.i("SimpleMp4FrameMuxer", "start() videoFormat=$videoFormat videoTrackIndex=$videoTrackIndex") muxer.start() started = true } @@ -74,6 +74,7 @@ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMux } override fun getVideoTime(): Long { - return finalVideoTime + // have to add one sec as we calculate it 0-based above + return MILLISECONDS.convert(finalVideoTime + frameUsec, 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 index 4046eec37b..cdb15fc11f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -34,20 +34,25 @@ import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat -import android.os.Handler -import android.os.Looper import android.view.Surface +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryOptions +import io.sentry.android.replay.ScreenshotRecorderConfig import java.io.File +import java.nio.ByteBuffer + +private const val TIMEOUT_USEC = 100_000L @TargetApi(26) internal class SimpleVideoEncoder( + val options: SentryOptions, val muxerConfig: MuxerConfig ) { private val mediaFormat: MediaFormat = run { val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, - muxerConfig.videoWidth, - muxerConfig.videoHeight + muxerConfig.recorderConfig.recordingWidth, + muxerConfig.recorderConfig.recordingHeight ) // Set some properties. Failing to specify some of these can cause the MediaCodec @@ -72,84 +77,102 @@ internal class SimpleVideoEncoder( codec } + private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() private val frameMuxer = muxerConfig.frameMuxer + val duration get() = frameMuxer.getVideoTime() private var surface: Surface? = null fun start() { - mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) - mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) surface = mediaCodec.createInputSurface() mediaCodec.start() + drainCodec(false) } - private fun createMediaCodecCallback(): MediaCodec.Callback { - return object : MediaCodec.Callback() { - override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { - } - - override fun onOutputBufferAvailable( - codec: MediaCodec, - index: Int, - info: MediaCodec.BufferInfo - ) { - val encodedData = codec.getOutputBuffer(index)!! + 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) + } - var effectiveSize = info.size + /** + * 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") - if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + // 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. - effectiveSize = 0 + options.logger.log(DEBUG, "[Encoder]: ignoring BUFFER_FLAG_CODEC_CONFIG") + bufferInfo.size = 0 } - - if (effectiveSize != 0) { + if (bufferInfo.size != 0) { if (!frameMuxer.isStarted()) { throw RuntimeException("muxer hasn't started") } - frameMuxer.muxVideoFrame(encodedData, info) + frameMuxer.muxVideoFrame(encodedData, bufferInfo) + options.logger.log(DEBUG, "[Encoder]: sent ${bufferInfo.size} bytes to muxer") } - - mediaCodec.releaseOutputBuffer(index, false) - - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - actualRelease() - } - } - - override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - } - - override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - // should happen before receiving buffers, and should only happen once - if (frameMuxer.isStarted()) { - throw RuntimeException("format changed twice") + 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 } - val newFormat: MediaFormat = mediaCodec.outputFormat - // now that we have the Magic Goodies, start the muxer - frameMuxer.start(newFormat) } } } - 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) - } - - /** - * can only *start* releasing, since it is asynchronous - */ - fun startRelease() { - mediaCodec.signalEndOfInputStream() - } - - private fun actualRelease() { + fun release() { + drainCodec(true) mediaCodec.stop() mediaCodec.release() surface?.release() @@ -161,9 +184,7 @@ internal class SimpleVideoEncoder( @TargetApi(24) internal data class MuxerConfig( val file: File, - val videoWidth: Int, - val videoHeight: Int, - val scaleFactor: Float, + val recorderConfig: ScreenshotRecorderConfig, val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, val frameRate: Float, val bitrate: Int, 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 0000000000..6a03291105 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -0,0 +1,86 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayCacheTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + fun getSut( + dir: TemporaryFolder?, + replayId: SentryId, + frameRate: Int, + dateProvider: ICurrentDateProvider + ): ReplayCache { + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate) + options.run { + cacheDirPath = dir?.newFolder()?.absolutePath + } + return ReplayCache(options, replayId, recorderConfig, dateProvider) + } + } + + private val fixture = Fixture() + + @Test + fun `test`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1, + dateProvider = object : ICurrentDateProvider { + override fun getCurrentTimeMillis(): Long { + return 1 + } + } + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap) + + replayCache.createVideoOf(5000L, 0, 0) + replayCache.createVideoOf(5000L, 5000L, 1) + } + + @Test + fun `test2`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1, + dateProvider = object : ICurrentDateProvider { + var counter = 0 + override fun getCurrentTimeMillis(): Long { + return when (counter++) { + 0 -> 1 + 1 -> 1001 + else -> 1001 + } + } + } + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap) + replayCache.addFrame(bitmap) + + replayCache.createVideoOf(5000L, 0, 0) + } +} diff --git a/sentry-android/build.gradle.kts b/sentry-android/build.gradle.kts index 47b873ac49..81619b736f 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/api/sentry.api b/sentry/api/sentry.api index b6acae0cde..9e3a90946f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -664,6 +664,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; @@ -686,6 +687,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 @@ -1215,6 +1217,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; @@ -1237,6 +1240,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 @@ -1639,6 +1643,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; @@ -1661,6 +1666,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 @@ -4767,7 +4773,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, public fun equals (Ljava/lang/Object;)Z public fun getContainer ()Ljava/lang/String; public fun getDataUnknown ()Ljava/util/Map; - public fun getDuration ()I + public fun getDuration ()J public fun getEncoding ()Ljava/lang/String; public fun getFrameCount ()I public fun getFrameRate ()I @@ -4785,7 +4791,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, 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 setDuration (I)V + public fun setDuration (J)V public fun setEncoding (Ljava/lang/String;)V public fun setFrameCount (I)V public fun setFrameRate (I)V diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 3842fb2c3a..2d38371ead 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 + @Nullable + SentryId getReplayId(); + + /** + * Sets the Scope's current replay_id + * + * @param replayId the id of the current session replay + */ + @ApiStatus.Internal + void setReplayId(final @Nullable SentryId replayId); + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index c756fb49a3..660dca0b69 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 @Nullable SentryId getReplayId() { + return null; + } + + @Override + public void setReplayId(@Nullable SentryId replayId) {} + @Override public @Nullable Request getRequest() { return null; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 91c9fcd8cf..164d52dc2b 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 @Nullable SentryId replayId; + /** * 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 @Nullable SentryId getReplayId() { + return replayId; + } + + @Override + public void setReplayId(final @Nullable 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/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index ff162f4464..728b28d0e2 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -23,7 +23,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.Charset; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.Callable; import org.jetbrains.annotations.ApiStatus; @@ -360,7 +360,8 @@ public static SentryEnvelopeItem fromReplay( try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { - final Map replayPayload = new HashMap<>(); + // 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()); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index 5bea9e3c47..532177ff9f 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -26,7 +26,7 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private @NotNull String tag; private int segmentId; private long size; - private int duration; + private long duration; private @NotNull String encoding = REPLAY_ENCODING; private @NotNull String container = REPLAY_CONTAINER; private int height; @@ -72,11 +72,11 @@ public void setSize(final long size) { this.size = size; } - public int getDuration() { + public long getDuration() { return duration; } - public void setDuration(final int duration) { + public void setDuration(final long duration) { this.duration = duration; } @@ -380,7 +380,7 @@ private void deserializePayload( event.size = size == null ? 0 : size; break; case JsonKeys.DURATION: - event.duration = reader.nextInt(); + event.duration = reader.nextLong(); break; case JsonKeys.CONTAINER: final String container = reader.nextStringOrNull(); From 2e954f700be7c161ca4a47c45d27d72f045c8d6f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 19 Mar 2024 16:23:56 +0100 Subject: [PATCH 036/184] Uncomment redacting --- .../android/replay/ScreenshotRecorder.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 767c3614ba..87915f8505 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -6,6 +6,8 @@ import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF import android.os.Handler import android.os.HandlerThread import android.os.Looper @@ -111,20 +113,20 @@ internal class ScreenshotRecorder( val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { -// if (it.shouldRedact && (it.width > 0 && it.height > 0)) { -// it.visibleRect ?: return@traverse -// -// // TODO: check for view type rather than rely on absence of dominantColor here -// val color = if (it.dominantColor == null) { -// singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) -// singlePixelBitmap.getPixel(0, 0) -// } else { -// it.dominantColor -// } -// -// maskingPaint.setColor(color) -// canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) -// } + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + // TODO: check for view type rather than rely on absence of dominantColor here + val color = if (it.dominantColor == null) { + singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) + singlePixelBitmap.getPixel(0, 0) + } else { + it.dominantColor + } + + maskingPaint.setColor(color) + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + } } } From 8bc6219d87c40673be0e210a2932b1865bd82e8e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 20 Mar 2024 11:37:43 +0100 Subject: [PATCH 037/184] Update proguard rules --- sentry-android-core/proguard-rules.pro | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 67d7e7691d..a78a5a14a1 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.ReplayIntegrationKt +-keepnames class io.sentry.android.replay.ReplayIntegration +##---------------End: proguard configuration for sentry-android-replay ---------- From a5aa4bee6954015ce440d0b9ec1a7ca64e9ca7ed Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 20 Mar 2024 12:07:26 +0100 Subject: [PATCH 038/184] Add missing rule for AndroidTest --- .../sentry-uitest-android/proguard-rules.pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 49f7f0749d..02f5e80ba3 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 From f72e45ff601ad9ba8221c496c5ae079362fbb6bf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 21 Mar 2024 22:38:59 +0100 Subject: [PATCH 039/184] Add ReplayCache tests --- .../io/sentry/android/replay/ReplayCache.kt | 36 ++-- .../android/replay/ReplayIntegration.kt | 4 +- .../replay/video/SimpleMp4FrameMuxer.kt | 5 +- .../replay/video/SimpleVideoEncoder.kt | 10 +- .../sentry/android/replay/ReplayCacheTest.kt | 180 +++++++++++++++--- 5 files changed, 180 insertions(+), 55 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index cf6d6a93aa..da62dfb6b1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -10,20 +10,30 @@ import io.sentry.SentryOptions import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId -import io.sentry.util.FileUtils import java.io.Closeable import java.io.File internal class ReplayCache( private val options: SentryOptions, private val replayId: SentryId, - private val recorderConfig: ScreenshotRecorderConfig + private val recorderConfig: ScreenshotRecorderConfig, + private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile -> + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recorderConfig = recorderConfig, + frameRate = recorderConfig.frameRate.toFloat(), + bitrate = 20 * 1000 + ) + ).also { it.start() } + } ) : Closeable { private val encoderLock = Any() - private var encoder: SimpleVideoEncoder? = null + internal var encoder: SimpleVideoEncoder? = null - private val replayCacheDir: File? by lazy { + internal val replayCacheDir: File? by lazy { if (options.cacheDirPath.isNullOrEmpty()) { options.logger.log( WARNING, @@ -36,7 +46,7 @@ internal class ReplayCache( } // TODO: maybe account for multi-threaded access - private val frames = mutableListOf() + internal val frames = mutableListOf() fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { if (replayCacheDir == null) { @@ -66,17 +76,7 @@ internal class ReplayCache( // TODO: reuse instance of encoder and just change file path to create a different muxer val videoFile = File(replayCacheDir, "$segmentId.mp4") - encoder = synchronized(encoderLock) { - SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recorderConfig = recorderConfig, - frameRate = recorderConfig.frameRate.toFloat(), - bitrate = 20 * 1000 - ) - ) - }.also { it.start() } + encoder = synchronized(encoderLock) { encoderCreator(videoFile) } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 @@ -147,10 +147,6 @@ internal class ReplayCache( } } - fun cleanup() { - FileUtils.deleteRecursively(replayCacheDir) - } - override fun close() { synchronized(encoderLock) { encoder?.release() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 26b35f9a15..756b8638d0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -21,6 +21,7 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils import java.io.Closeable import java.io.File import java.util.Date @@ -161,9 +162,10 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() + val replayCacheDir = cache?.replayCacheDir saver.submit { createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) - cache?.cleanup() + FileUtils.deleteRecursively(replayCacheDir) } recorder?.stopRecording() 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 index db34b2dbea..8a21b0bec0 100644 --- 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 @@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS -class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { +class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) @@ -74,6 +74,9 @@ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMux } 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 + frameUsec, 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 index cdb15fc11f..e2561faa1b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -46,7 +46,8 @@ private const val TIMEOUT_USEC = 100_000L @TargetApi(26) internal class SimpleVideoEncoder( val options: SentryOptions, - val muxerConfig: MuxerConfig + val muxerConfig: MuxerConfig, + val onClose: (() -> Unit)? = null ) { private val mediaFormat: MediaFormat = run { val format = MediaFormat.createVideoFormat( @@ -68,7 +69,7 @@ internal class SimpleVideoEncoder( format } - private val mediaCodec: MediaCodec = run { + internal val mediaCodec: MediaCodec = run { // val codecs = MediaCodecList(REGULAR_CODECS) // val codecName = codecs.findEncoderForFormat(mediaFormat) // val codec = MediaCodec.createByCodecName(codecName) @@ -172,6 +173,7 @@ internal class SimpleVideoEncoder( } fun release() { + onClose?.invoke() drainCodec(true) mediaCodec.stop() mediaCodec.release() @@ -185,8 +187,8 @@ internal class SimpleVideoEncoder( internal data class MuxerConfig( val file: File, val recorderConfig: ScreenshotRecorderConfig, + val bitrate: Int = 20_000, + val frameRate: Float = recorderConfig.frameRate.toFloat(), val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, - val frameRate: Float, - val bitrate: Int, val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate) ) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 6a03291105..88d410fab1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -2,15 +2,23 @@ package io.sentry.android.replay import android.graphics.Bitmap 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 io.sentry.transport.ICurrentDateProvider 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]) @@ -21,66 +29,180 @@ class ReplayCacheTest { internal class Fixture { val options = SentryOptions() + var encoder: SimpleVideoEncoder? = null fun getSut( dir: TemporaryFolder?, - replayId: SentryId, + replayId: SentryId = SentryId(), frameRate: Int, - dateProvider: ICurrentDateProvider + framesToEncode: Int = 0 ): ReplayCache { val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, dateProvider) + return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recorderConfig = recorderConfig, + frameRate = recorderConfig.frameRate.toFloat(), + bitrate = 20 * 1000 + ), + 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 `test`() { + fun `when no cacheDirPath specified, does not store screenshots`() { val replayId = SentryId() val replayCache = fixture.getSut( - tmpDir, + null, replayId, - frameRate = 1, - dateProvider = object : ICurrentDateProvider { - override fun getCurrentTimeMillis(): Long { - return 1 - } - } + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) - replayCache.addFrame(bitmap) + replayCache.addFrame(bitmap, 1) - replayCache.createVideoOf(5000L, 0, 0) - replayCache.createVideoOf(5000L, 5000L, 1) + assertTrue(replayCache.frames.isEmpty()) } @Test - fun `test2`() { + 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) + + assertNull(video) + } + + @Test + fun `deletes frames after creating a video`() { + val replayCache = fixture.getSut( + tmpDir, frameRate = 1, - dateProvider = object : ICurrentDateProvider { - var counter = 0 - override fun getCurrentTimeMillis(): Long { - return when (counter++) { - 0 -> 1 - 1 -> 1001 - else -> 1001 - } - } - } + framesToEncode = 3 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + replayCache.createVideoOf(3000L, 0, 0) + 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) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + 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) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + 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) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + + val segment1 = replayCache.createVideoOf(5000L, 5000L, 1) + assertEquals(5, segment1!!.frameCount) + assertEquals(5000, segment1.duration) + 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) - replayCache.addFrame(bitmap) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 1501) - replayCache.createVideoOf(5000L, 0, 0) + val segment0 = replayCache.createVideoOf(3000L, 0, 0) + assertEquals(6, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } } From 545712ca5d298eca2e265f6a711bc95068e80846 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 22 Mar 2024 00:28:02 +0100 Subject: [PATCH 040/184] Add tests --- sentry-android-core/build.gradle.kts | 1 + .../io/sentry/android/core/SentryAndroid.java | 50 +++++++++---------- .../core/AndroidOptionsInitializerTest.kt | 27 +++++++++- .../android/core/AndroidProfilerTest.kt | 1 + .../core/AndroidTransactionProfilerTest.kt | 1 + .../android/core/LifecycleWatcherTest.kt | 8 --- .../sentry/android/core/SentryAndroidTest.kt | 25 ++++++++-- .../android/core/SentryInitProviderTest.kt | 1 + .../api/sentry-android-replay.api | 1 + .../android/replay/ReplayIntegration.kt | 2 + 10 files changed, 76 insertions(+), 41 deletions(-) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index da4851c92a..6ea33c7b74 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -104,6 +104,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/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 39a2019d1d..d6e11d15f5 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 @@ -227,44 +227,43 @@ private static void deduplicateIntegrations( } public static synchronized void startReplay() { - performReplayAction( - "starting", - (replay) -> { - replay.start(); - }); + if (!ensureReplayIntegration("starting")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).start(); } public static synchronized void stopReplay() { - performReplayAction( - "stopping", - (replay) -> { - replay.stop(); - }); + if (!ensureReplayIntegration("stopping")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).stop(); } public static synchronized void resumeReplay() { - performReplayAction( - "resuming", - (replay) -> { - replay.resume(); - }); + if (!ensureReplayIntegration("resuming")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).resume(); } public static synchronized void pauseReplay() { - performReplayAction( - "pausing", - (replay) -> { - replay.pause(); - }); + if (!ensureReplayIntegration("pausing")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).pause(); } - private static void performReplayAction( - final @NotNull String actionName, final @NotNull ReplayCallable action) { + private static boolean ensureReplayIntegration(final @NotNull String actionName) { final @NotNull IHub hub = Sentry.getCurrentHub(); if (isReplayAvailable) { final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); if (replay != null) { - action.call(replay); + return true; } else { hub.getOptions() .getLogger() @@ -279,9 +278,6 @@ private static void performReplayAction( SentryLevel.INFO, "Session Replay wasn't found on classpath, not " + actionName + " the replay"); } - } - - private interface ReplayCallable { - void call(final @NotNull ReplayIntegration replay); + return false; } } 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 6353e9dde8..94b0490f17 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,24 @@ 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 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 +656,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 a726b2c55b..2efb602075 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 405aa6dc98..9376ea79fb 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 be30993142..4b620813bf 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 @@ -123,7 +123,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 +166,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 +217,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( 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 bd5b3695fb..b543ae318a 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 @@ -26,6 +26,8 @@ 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.replay.getReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingOptionsObserver @@ -313,12 +315,26 @@ class SentryAndroidTest { } } + @Test + @Config(sdk = [26]) + fun `init starts session replay if app is in foreground`() { + initSentryWithForegroundImportance(true) { _ -> + assertTrue(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + } + } + + @Test + @Config(sdk = [26]) + fun `init does not start session replay if the app is in background`() { + initSentryWithForegroundImportance(false) { _ -> + assertFalse(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + } + } + private fun initSentryWithForegroundImportance( inForeground: Boolean, callback: (session: Session?) -> Unit ) { - val context = ContextUtilsTestHelper.createMockContext() - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) @@ -412,7 +428,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(19, options.integrations.size) + assertEquals(20, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -431,7 +447,8 @@ class SentryAndroidTest { it is SystemEventsBreadcrumbsIntegration || it is NetworkBreadcrumbsIntegration || it is TempSensorBreadcrumbsIntegration || - it is PhoneStateBreadcrumbsIntegration + it is PhoneStateBreadcrumbsIntegration || + 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 a83076efb0..5b546523d0 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-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 5af2ca943b..b3bc98e15b 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -12,6 +12,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr public static final field VIDEO_SEGMENT_DURATION J public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V + public final fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public final fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 756b8638d0..e1f0d2f7e3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -99,6 +99,8 @@ class ReplayIntegration( isEnabled.set(true) } + fun isRecording() = isRecording.get() + fun start() { if (!isEnabled.get()) { options.logger.log( From 2df34a345f90eaf10436f2954cb069e69ec0f090 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 14:10:40 +0100 Subject: [PATCH 041/184] Add SessionReplayOptions --- .../io/sentry/android/replay/ReplayCache.kt | 7 +- .../android/replay/ReplayIntegration.kt | 29 +------ .../android/replay/ScreenshotRecorder.kt | 36 +++++++- .../replay/video/SimpleVideoEncoder.kt | 11 +-- sentry/api/sentry.api | 20 +++++ .../java/io/sentry/ExperimentalOptions.java | 16 ++++ .../main/java/io/sentry/SentryOptions.java | 7 ++ .../java/io/sentry/SessionReplayOptions.java | 85 +++++++++++++++++++ 8 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ExperimentalOptions.java create mode 100644 sentry/src/main/java/io/sentry/SessionReplayOptions.java diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index da62dfb6b1..e591370444 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -20,12 +20,7 @@ internal class ReplayCache( private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile -> SimpleVideoEncoder( options, - MuxerConfig( - file = videoFile, - recorderConfig = recorderConfig, - frameRate = recorderConfig.frameRate.toFloat(), - bitrate = 20 * 1000 - ) + MuxerConfig(file = videoFile, recorderConfig = recorderConfig) ).also { it.start() } } ) : Closeable { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e1f0d2f7e3..fa5859745c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -2,12 +2,9 @@ package io.sentry.android.replay import android.content.Context import android.graphics.Bitmap -import android.graphics.Point -import android.graphics.Rect import android.os.Build import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import android.view.WindowManager import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -33,7 +30,6 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.LazyThreadSafetyMode.NONE -import kotlin.math.roundToInt class ReplayIntegration( private val context: Context, @@ -59,28 +55,11 @@ class ReplayIntegration( private val saver = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) - private val screenBounds by lazy(NONE) { - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - 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) - } - } - - private val aspectRatio by lazy(NONE) { - screenBounds.bottom.toFloat() / screenBounds.right.toFloat() - } - private val recorderConfig by lazy(NONE) { - ScreenshotRecorderConfig( - recordingWidth = (720 / aspectRatio).roundToInt(), - recordingHeight = 720, - scaleFactor = 720f / screenBounds.bottom + ScreenshotRecorderConfig.from( + context, + targetHeight = 720, + options._experimental.sessionReplayOptions ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 87915f8505..a7094e1074 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -1,13 +1,17 @@ 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.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.HandlerThread import android.os.Looper @@ -15,13 +19,16 @@ 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.SentryOptions +import io.sentry.SessionReplayOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.roundToInt import kotlin.system.measureTimeMillis @TargetApi(26) @@ -217,8 +224,33 @@ internal data class ScreenshotRecorderConfig( val recordingWidth: Int, val recordingHeight: Int, val scaleFactor: Float, - val frameRate: Int = 2 -) + val frameRate: Int, + val bitRate: Int +) { + companion object { + fun from(context: Context, targetHeight: Int, sessionReplayOptions: SessionReplayOptions): 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) + } + val aspectRatio = screenBounds.bottom.toFloat() / screenBounds.right.toFloat() + + return ScreenshotRecorderConfig( + recordingWidth = (targetHeight / aspectRatio).roundToInt(), + recordingHeight = targetHeight, + scaleFactor = targetHeight.toFloat() / screenBounds.bottom, + frameRate = sessionReplayOptions.frameRate, + bitRate = sessionReplayOptions.bitRate + ) + } + } +} interface ScreenshotRecorderCallback { fun onScreenshotRecorded(bitmap: Bitmap) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index e2561faa1b..73b8862434 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -62,8 +62,8 @@ internal class SimpleVideoEncoder( MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) - format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.bitrate) - format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) + format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.recorderConfig.bitRate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat()) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) format @@ -79,7 +79,7 @@ internal class SimpleVideoEncoder( } private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() - private val frameMuxer = muxerConfig.frameMuxer + private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.recorderConfig.frameRate.toFloat()) val duration get() = frameMuxer.getVideoTime() private var surface: Surface? = null @@ -187,8 +187,5 @@ internal class SimpleVideoEncoder( internal data class MuxerConfig( val file: File, val recorderConfig: ScreenshotRecorderConfig, - val bitrate: Int = 20_000, - val frameRate: Float = recorderConfig.frameRate.toFloat(), - val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, - val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate) + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC ) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9e3a90946f..756fd59204 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -305,6 +305,12 @@ public abstract interface class io/sentry/EventProcessor { 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 getSessionReplayOptions ()Lio/sentry/SessionReplayOptions; + public fun setSessionReplayOptions (Lio/sentry/SessionReplayOptions;)V +} + public final class io/sentry/ExternalOptions { public fun ()V public fun addBundleId (Ljava/lang/String;)V @@ -2315,6 +2321,7 @@ public class io/sentry/SentryOptions { public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; public final fun getViewHierarchyExporters ()Ljava/util/List; + public fun get_experimental ()Lio/sentry/ExperimentalOptions; public fun isAttachServerName ()Z public fun isAttachStacktrace ()Z public fun isAttachThreads ()Z @@ -2686,6 +2693,19 @@ public final class io/sentry/Session$State : java/lang/Enum { public static fun values ()[Lio/sentry/Session$State; } +public final class io/sentry/SessionReplayOptions { + public fun ()V + public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun getBitRate ()I + public fun getErrorReplayDuration ()J + public fun getErrorSampleRate ()Ljava/lang/Double; + public fun getFrameRate ()I + public fun getSessionSampleRate ()Ljava/lang/Double; + public fun getSessionSegmentDuration ()J + public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setSessionSampleRate (Ljava/lang/Double;)V +} + public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun (Ljava/lang/Runtime;)V 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 0000000000..8e7ade8ca9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -0,0 +1,16 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +public final class ExperimentalOptions { + private @NotNull SessionReplayOptions sessionReplayOptions = new SessionReplayOptions(); + + @NotNull + public SessionReplayOptions getSessionReplayOptions() { + return sessionReplayOptions; + } + + public void setSessionReplayOptions(final @NotNull SessionReplayOptions sessionReplayOptions) { + this.sessionReplayOptions = sessionReplayOptions; + } +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index d97b8c79d1..026f2c9e90 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -460,6 +460,8 @@ public class SentryOptions { */ private int profilingTracesHz = 101; + private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions(); + /** * Adds an event processor * @@ -2274,6 +2276,11 @@ public void setSessionFlushTimeoutMillis(final long sessionFlushTimeoutMillis) { this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis; } + @NotNull + public ExperimentalOptions get_experimental() { + return _experimental; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SessionReplayOptions.java b/sentry/src/main/java/io/sentry/SessionReplayOptions.java new file mode 100644 index 0000000000..88b815a7c0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SessionReplayOptions.java @@ -0,0 +1,85 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +public final class SessionReplayOptions { + + /** + * 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; + + /** + * Defines the quality of the session replay. Higher bit rates have better replay quality, but + * also affect the final payload size to transfer. The default value is 20kbps; + */ + private int bitRate = 20_000; + + /** + * 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. + */ + private int frameRate = 1; + + /** The maximum duration of replays for error events. */ + private long errorReplayDuration = 30_000L; + + /** The maximum duration of the segment of a session replay. */ + private long sessionSegmentDuration = 5000L; + + public SessionReplayOptions() {} + + public SessionReplayOptions( + final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { + this.sessionSampleRate = sessionSampleRate; + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getErrorSampleRate() { + return errorSampleRate; + } + + public void setErrorSampleRate(final @Nullable Double errorSampleRate) { + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getSessionSampleRate() { + return sessionSampleRate; + } + + public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { + this.sessionSampleRate = sessionSampleRate; + } + + @ApiStatus.Internal + public int getBitRate() { + return bitRate; + } + + @ApiStatus.Internal + public int getFrameRate() { + return frameRate; + } + + @ApiStatus.Internal + public long getErrorReplayDuration() { + return errorReplayDuration; + } + + @ApiStatus.Internal + public long getSessionSegmentDuration() { + return sessionSegmentDuration; + } +} From c02f1dba4f894b7e9029c13774b4581708a63087 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 14:57:44 +0100 Subject: [PATCH 042/184] Call listeners when installing RootViewsSpy --- .../src/main/java/io/sentry/android/replay/Windows.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index ece684c46e..e9c6761c75 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -139,6 +139,15 @@ internal class RootViewsSpy private constructor() { val listeners = CopyOnWriteArrayList() private val delegatingViewList = 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) From bee240b8608a9ee08c6920e4dd38eed79d4294ac Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 15:01:05 +0100 Subject: [PATCH 043/184] Call listeners when installing RootViewsSpy --- .../src/main/java/io/sentry/android/replay/Windows.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index ece684c46e..e9c6761c75 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -139,6 +139,15 @@ internal class RootViewsSpy private constructor() { val listeners = CopyOnWriteArrayList() private val delegatingViewList = 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) From d6bb9ab654f4b0f7d883f0572dc121932fdb1630 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 16:33:53 +0100 Subject: [PATCH 044/184] SessionReplayOptions -> SentryReplayOptions --- .../android/replay/ReplayIntegration.kt | 4 +-- .../android/replay/ScreenshotRecorder.kt | 8 ++--- sentry/api/sentry.api | 30 +++++++++---------- .../java/io/sentry/ExperimentalOptions.java | 10 +++---- ...yOptions.java => SentryReplayOptions.java} | 19 ++++++++++-- 5 files changed, 41 insertions(+), 30 deletions(-) rename sentry/src/main/java/io/sentry/{SessionReplayOptions.java => SentryReplayOptions.java} (77%) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index fa5859745c..a99c8a9c95 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -3,8 +3,6 @@ package io.sentry.android.replay import android.content.Context import android.graphics.Bitmap import android.os.Build -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -59,7 +57,7 @@ class ReplayIntegration( ScreenshotRecorderConfig.from( context, targetHeight = 720, - options._experimental.sessionReplayOptions + options._experimental.replayOptions ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index a7094e1074..7dfe26c478 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -23,7 +23,7 @@ import android.view.WindowManager import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions -import io.sentry.SessionReplayOptions +import io.sentry.SentryReplayOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap @@ -228,7 +228,7 @@ internal data class ScreenshotRecorderConfig( val bitRate: Int ) { companion object { - fun from(context: Context, targetHeight: Int, sessionReplayOptions: SessionReplayOptions): ScreenshotRecorderConfig { + fun from(context: Context, targetHeight: Int, sentryReplayOptions: 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) { @@ -245,8 +245,8 @@ internal data class ScreenshotRecorderConfig( recordingWidth = (targetHeight / aspectRatio).roundToInt(), recordingHeight = targetHeight, scaleFactor = targetHeight.toFloat() / screenBounds.bottom, - frameRate = sessionReplayOptions.frameRate, - bitRate = sessionReplayOptions.bitRate + frameRate = sentryReplayOptions.frameRate, + bitRate = sentryReplayOptions.bitRate ) } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 756fd59204..fcede26562 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -307,8 +307,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getSessionReplayOptions ()Lio/sentry/SessionReplayOptions; - public fun setSessionReplayOptions (Lio/sentry/SessionReplayOptions;)V + public fun getReplayOptions ()Lio/sentry/SentryReplayOptions; + public fun setReplayOptions (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { @@ -2540,6 +2540,19 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent 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 getBitRate ()I + public fun getErrorReplayDuration ()J + public fun getErrorSampleRate ()Ljava/lang/Double; + public fun getFrameRate ()I + public fun getSessionSampleRate ()Ljava/lang/Double; + public fun getSessionSegmentDuration ()J + public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setSessionSampleRate (Ljava/lang/Double;)V +} + public final class io/sentry/SentrySpanStorage { public fun get (Ljava/lang/String;)Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/SentrySpanStorage; @@ -2693,19 +2706,6 @@ public final class io/sentry/Session$State : java/lang/Enum { public static fun values ()[Lio/sentry/Session$State; } -public final class io/sentry/SessionReplayOptions { - public fun ()V - public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun getBitRate ()I - public fun getErrorReplayDuration ()J - public fun getErrorSampleRate ()Ljava/lang/Double; - public fun getFrameRate ()I - public fun getSessionSampleRate ()Ljava/lang/Double; - public fun getSessionSegmentDuration ()J - public fun setErrorSampleRate (Ljava/lang/Double;)V - public fun setSessionSampleRate (Ljava/lang/Double;)V -} - public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun (Ljava/lang/Runtime;)V diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 8e7ade8ca9..b22d283724 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -3,14 +3,14 @@ import org.jetbrains.annotations.NotNull; public final class ExperimentalOptions { - private @NotNull SessionReplayOptions sessionReplayOptions = new SessionReplayOptions(); + private @NotNull SentryReplayOptions replayOptions = new SentryReplayOptions(); @NotNull - public SessionReplayOptions getSessionReplayOptions() { - return sessionReplayOptions; + public SentryReplayOptions getReplayOptions() { + return replayOptions; } - public void setSessionReplayOptions(final @NotNull SessionReplayOptions sessionReplayOptions) { - this.sessionReplayOptions = sessionReplayOptions; + public void setReplayOptions(final @NotNull SentryReplayOptions replayOptions) { + this.replayOptions = replayOptions; } } diff --git a/sentry/src/main/java/io/sentry/SessionReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java similarity index 77% rename from sentry/src/main/java/io/sentry/SessionReplayOptions.java rename to sentry/src/main/java/io/sentry/SentryReplayOptions.java index 88b815a7c0..df98fc384f 100644 --- a/sentry/src/main/java/io/sentry/SessionReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,9 +1,10 @@ package io.sentry; +import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; -public final class SessionReplayOptions { +public final class SentryReplayOptions { /** * Indicates the percentage in which the replay for the session will be created. Specifying 0 @@ -37,9 +38,9 @@ public final class SessionReplayOptions { /** The maximum duration of the segment of a session replay. */ private long sessionSegmentDuration = 5000L; - public SessionReplayOptions() {} + public SentryReplayOptions() {} - public SessionReplayOptions( + public SentryReplayOptions( final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { this.sessionSampleRate = sessionSampleRate; this.errorSampleRate = errorSampleRate; @@ -51,6 +52,12 @@ public Double getErrorSampleRate() { } 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; } @@ -60,6 +67,12 @@ public Double getSessionSampleRate() { } 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; } From 7854e4fcdc67bf8f77ee4d47374be4a9c76a731f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 26 Mar 2024 11:11:10 +0100 Subject: [PATCH 045/184] Fix test --- .../test/java/io/sentry/android/replay/ReplayCacheTest.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 88d410fab1..dc4f5c29b5 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -36,7 +36,7 @@ class ReplayCacheTest { frameRate: Int, framesToEncode: Int = 0 ): ReplayCache { - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate) + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } @@ -45,9 +45,7 @@ class ReplayCacheTest { options, MuxerConfig( file = videoFile, - recorderConfig = recorderConfig, - frameRate = recorderConfig.frameRate.toFloat(), - bitrate = 20 * 1000 + recorderConfig = recorderConfig ), onClose = { encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) From 2cddcc441fe575b29a044d3f7759a97df32ab69e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 26 Mar 2024 11:49:16 +0100 Subject: [PATCH 046/184] Add AndroidManifest options for replays --- .../android/core/ManifestMetadataReader.java | 19 +++++++++ .../core/ManifestMetadataReaderTest.kt | 42 +++++++++++++++++++ 2 files changed, 61 insertions(+) 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 93bf928285..32ad9df290 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 @@ -100,6 +100,10 @@ final class ManifestMetadataReader { static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.replays.session-sample-rate"; + + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.replays.error-sample-rate"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -371,6 +375,21 @@ static void applyMetadata( options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); + + if (options.get_experimental().getReplayOptions().getSessionSampleRate() == null) { + final Double sessionSampleRate = + readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); + if (sessionSampleRate != -1) { + options.get_experimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); + } + } + + if (options.get_experimental().getReplayOptions().getErrorSampleRate() == null) { + final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (errorSampleRate != -1) { + options.get_experimental().getReplayOptions().setErrorSampleRate(errorSampleRate); + } + } } options 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 2cff98ed39..03f8e5291f 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 @@ -1370,4 +1370,46 @@ class ManifestMetadataReaderTest { // Assert assertFalse(fixture.options.isEnableAppStartProfiling) } + + @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.replayOptions.errorSampleRate) + } + + @Test + fun `applyMetadata does not override replays errorSampleRate from options`() { + // Arrange + val expectedSampleRate = 0.99f + fixture.options._experimental.replayOptions.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.replayOptions.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.replayOptions.errorSampleRate) + } } From ef3d62c61344ce7a48e12f8d814ee5d6d4454d84 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 11:34:51 +0100 Subject: [PATCH 047/184] Add buffer mode and link replays with events/transactions --- .../api/sentry-android-core.api | 4 - .../core/AndroidOptionsInitializer.java | 5 +- .../sentry/android/core/LifecycleWatcher.java | 8 +- .../io/sentry/android/core/SentryAndroid.java | 59 +-------- .../api/sentry-android-replay.api | 17 +-- .../io/sentry/android/replay/ReplayCache.kt | 18 ++- .../android/replay/ReplayIntegration.kt | 125 +++++++++++++++--- sentry/api/sentry.api | 28 +++- sentry/src/main/java/io/sentry/Baggage.java | 27 +++- .../java/io/sentry/NoOpReplayController.java | 29 ++++ .../main/java/io/sentry/ReplayController.java | 17 +++ .../src/main/java/io/sentry/SentryClient.java | 47 +++++-- .../main/java/io/sentry/SentryOptions.java | 11 ++ .../java/io/sentry/SentryReplayOptions.java | 8 ++ .../src/main/java/io/sentry/SentryTracer.java | 8 +- .../src/main/java/io/sentry/TraceContext.java | 22 ++- .../sentry/TraceContextSerializationTest.kt | 1 + 17 files changed, 310 insertions(+), 124 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/NoOpReplayController.java create mode 100644 sentry/src/main/java/io/sentry/ReplayController.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 55278c6356..51eb48f1b2 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,10 +245,6 @@ public final class io/sentry/android/core/SentryAndroid { public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V public static fun init (Landroid/content/Context;Lio/sentry/Sentry$OptionsConfiguration;)V - public static fun pauseReplay ()V - public static fun resumeReplay ()V - public static fun startReplay ()V - public static fun stopReplay ()V } public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/SentryDateProvider { 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 b58051cee7..27c9d6e674 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 @@ -299,7 +299,10 @@ static void installDefaultIntegrations( options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); if (isReplayAvailable) { - options.addIntegration(new ReplayIntegration(context, CurrentDateProvider.getInstance())); + final ReplayIntegration replay = + new ReplayIntegration(context, CurrentDateProvider.getInstance()); + options.addIntegration(replay); + options.setReplayController(replay); } } 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 ff281d2beb..bcdb49e3e6 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 @@ -93,10 +93,10 @@ private void startSession() { addSessionBreadcrumb("start"); hub.startSession(); } - SentryAndroid.startReplay(); + hub.getOptions().getReplayController().start(); } else if (!isFreshSession.getAndSet(false)) { // only resume if it's not a fresh session, which has been started in SentryAndroid.init - SentryAndroid.resumeReplay(); + hub.getOptions().getReplayController().resume(); } this.lastUpdatedSession.set(currentTimeMillis); } @@ -108,7 +108,7 @@ public void onStop(final @NotNull LifecycleOwner owner) { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); - SentryAndroid.pauseReplay(); + hub.getOptions().getReplayController().pause(); scheduleEndSession(); AppState.getInstance().setInBackground(true); @@ -127,7 +127,7 @@ public void run() { addSessionBreadcrumb("end"); hub.endSession(); } - SentryAndroid.stopReplay(); + hub.getOptions().getReplayController().stop(); } }; 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 d6e11d15f5..676bb2173a 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 @@ -15,8 +15,6 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; -import io.sentry.android.replay.ReplayIntegration; -import io.sentry.android.replay.ReplayIntegrationKt; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -160,7 +158,7 @@ public static synchronized void init( hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); hub.startSession(); } - startReplay(); + hub.getOptions().getReplayController().start(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); @@ -225,59 +223,4 @@ private static void deduplicateIntegrations( } } } - - public static synchronized void startReplay() { - if (!ensureReplayIntegration("starting")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).start(); - } - - public static synchronized void stopReplay() { - if (!ensureReplayIntegration("stopping")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).stop(); - } - - public static synchronized void resumeReplay() { - if (!ensureReplayIntegration("resuming")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).resume(); - } - - public static synchronized void pauseReplay() { - if (!ensureReplayIntegration("pausing")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).pause(); - } - - private static boolean ensureReplayIntegration(final @NotNull String actionName) { - final @NotNull IHub hub = Sentry.getCurrentHub(); - if (isReplayAvailable) { - final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); - if (replay != null) { - return true; - } else { - hub.getOptions() - .getLogger() - .log( - SentryLevel.INFO, - "Session Replay wasn't registered yet, not " + actionName + " the replay"); - } - } else { - hub.getOptions() - .getLogger() - .log( - SentryLevel.INFO, - "Session Replay wasn't found on classpath, not " + actionName + " the replay"); - } - return false; - } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b3bc98e15b..82cfece004 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,22 +6,17 @@ public final class io/sentry/android/replay/BuildConfig { public fun ()V } -public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { - public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; - public static final field VIDEO_BUFFER_DURATION J - public static final field VIDEO_SEGMENT_DURATION J +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V public final fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V - public final fun pause ()V + public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V - public final fun resume ()V - public final fun start ()V - public final fun stop ()V -} - -public final class io/sentry/android/replay/ReplayIntegration$Companion { + public fun resume ()V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun start ()V + public fun stop ()V } public final class io/sentry/android/replay/ReplayIntegrationKt { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index e591370444..e271d8dd9a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -113,13 +113,7 @@ internal class ReplayCache( encoder = null } - frames.removeAll { - if (it.timestamp < (from + duration)) { - deleteFile(it.screenshot) - return@removeAll true - } - return@removeAll false - } + rotate(until = (from + duration)) return GeneratedVideo(videoFile, frameCount, videoDuration) } @@ -142,6 +136,16 @@ internal class ReplayCache( } } + 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() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index a99c8a9c95..da14c72bd4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -7,18 +7,26 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.Integration +import io.sentry.ReplayController import io.sentry.ReplayRecording +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.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable import java.io.File +import java.security.SecureRandom import java.util.Date import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -32,19 +40,16 @@ import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider -) : Integration, Closeable, ScreenshotRecorderCallback { - - companion object { - const val VIDEO_SEGMENT_DURATION = 5_000L - const val VIDEO_BUFFER_DURATION = 30_000L - } +) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: WindowRecorder? = null private var cache: ReplayCache? = null + private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here + private val isFullSession = AtomicBoolean(false) private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) private val currentReplayId = AtomicReference() @@ -61,6 +66,13 @@ class ReplayIntegration( ) } + private fun sample(rate: Double?): Boolean { + if (rate != null) { + return !(rate < random.nextDouble()) // bad luck + } + return false + } + override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -69,16 +81,26 @@ class ReplayIntegration( return } - // TODO: check for replaysSessionSampleRate and replaysOnErrorSampleRate + if (!options._experimental.replayOptions.isSessionReplayEnabled && + !options._experimental.replayOptions.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, no sample rate specified") + return + } this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) + isFullSession.set(sample(options._experimental.replayOptions.sessionSampleRate)) + + addIntegrationToSdkVersion(javaClass) + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } fun isRecording() = isRecording.get() - fun start() { + override fun start() { if (!isEnabled.get()) { options.logger.log( DEBUG, @@ -97,7 +119,11 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) - hub?.configureScope { it.replayId = currentReplayId.get() } + if (isFullSession.get()) { + // 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() } + } cache = ReplayCache(options, currentReplayId.get(), recorderConfig) recorder?.startRecording() @@ -105,29 +131,76 @@ class ReplayIntegration( // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } - fun resume() { + override fun resume() { segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } - fun pause() { + override fun sendReplayForEvent(event: SentryEvent) { + if (isFullSession.get()) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + return + } + + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + return + } + + if (!sample(options._experimental.replayOptions.errorSampleRate)) { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) + return + } + + val errorReplayDuration = options._experimental.replayOptions.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() + saver.submit { + val videoDuration = + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + // since we're switching to session mode, even if the video is not sent for an error + // we still set the timestamp to now, because session is technically started "now" + segmentTimestamp.set(DateUtils.getDateTime(now)) + } + + hub?.configureScope { it.replayId = currentReplayId.get() } + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + isFullSession.set(true) + } + + override fun pause() { val now = dateProvider.currentTimeMillis recorder?.pause() + if (!isFullSession.get()) { + return + } + val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() saver.submit { val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) if (videoDuration != null) { currentSegment.getAndIncrement() } } } - fun stop() { + override fun stop() { if (!isEnabled.get()) { options.logger.log( DEBUG, @@ -143,7 +216,10 @@ class ReplayIntegration( val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir saver.submit { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + // we don't flush the segment, but we still wanna clean up the folder for buffer mode + if (isFullSession.get()) { + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) + } FileUtils.deleteRecursively(replayCacheDir) } @@ -164,23 +240,28 @@ class ReplayIntegration( cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis - if (now - segmentTimestamp.get().time >= VIDEO_SEGMENT_DURATION) { + if (isFullSession.get() && + (now - segmentTimestamp.get().time >= options._experimental.replayOptions.sessionSegmentDuration) + ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() val replayId = currentReplayId.get() val videoDuration = createAndCaptureSegment( - VIDEO_SEGMENT_DURATION, + options._experimental.replayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId + segmentId, + SESSION ) if (videoDuration != null) { currentSegment.getAndIncrement() // set next segment timestamp as close to the previous one as possible to avoid gaps segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } + } else if (!isFullSession.get()) { + cache?.rotate(now - options._experimental.replayOptions.errorReplayDuration) } } } @@ -189,7 +270,8 @@ class ReplayIntegration( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, - segmentId: Int + segmentId: Int, + replayType: ReplayType ): Long? { val generatedVideo = cache?.createVideoOf( duration, @@ -204,7 +286,8 @@ class ReplayIntegration( currentSegmentTimestamp, segmentId, frameCount, - videoDuration + videoDuration, + replayType ) return videoDuration } @@ -215,7 +298,8 @@ class ReplayIntegration( segmentTimestamp: Date, segmentId: Int, frameCount: Int, - duration: Long + duration: Long, + replayType: ReplayType ) { val replay = SentryReplayEvent().apply { eventId = currentReplayId @@ -225,6 +309,7 @@ class ReplayIntegration( if (segmentId == 0) { replayStartTimestamp = segmentTimestamp } + this.replayType = replayType videoFile = video } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fcede26562..f2ad30d938 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; @@ -1201,6 +1204,15 @@ 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/NoOpReplayController : io/sentry/ReplayController { + public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun pause ()V + public fun resume ()V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;)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 @@ -1591,6 +1603,14 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public abstract interface class io/sentry/ReplayController { + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;)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 @@ -2300,6 +2320,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; @@ -2406,6 +2427,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 @@ -2549,6 +2571,8 @@ public final class io/sentry/SentryReplayOptions { public fun getFrameRate ()I 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 setSessionSampleRate (Ljava/lang/Double;)V } @@ -2899,6 +2923,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; @@ -2920,6 +2945,7 @@ 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; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 8e19fceaf8..c6a1ee5630 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; } @@ -345,6 +346,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) { @@ -373,6 +384,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()); @@ -384,6 +396,9 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); + if (replayId != null) { + setReplayId(replayId.toString()); + } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); setSampled(StringUtils.toString(sampled(samplingDecision))); } @@ -397,6 +412,10 @@ public void setValuesFromScope( setPublicKey(new Dsn(options.getDsn()).getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); + final @Nullable SentryId replayId = scope.getReplayId(); + if (replayId != null) { + setReplayId(replayId.toString()); + } setUserSegment(user != null ? getSegment(user) : null); setTransaction(null); setSampleRate(null); @@ -468,6 +487,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) { @@ -481,7 +501,8 @@ public TraceContext toTraceContext() { getUserSegment(), getTransaction(), getSampleRate(), - getSampled()); + getSampled(), + replayIdString == null ? null : new SentryId(replayIdString)); traceContext.setUnknown(getUnknown()); return traceContext; } else { @@ -500,6 +521,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( @@ -511,6 +533,7 @@ public static final class DSCKeys { USER_SEGMENT, TRANSACTION, SAMPLE_RATE, - SAMPLED); + SAMPLED, + REPLAY_ID); } } 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 0000000000..0a11e71423 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -0,0 +1,29 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +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 void sendReplayForEvent(@NotNull SentryEvent event) {} +} 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 0000000000..2ccc28cb82 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -0,0 +1,17 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public interface ReplayController { + void start(); + + void stop(); + + void pause(); + + void resume(); + + void sendReplayForEvent(@NotNull SentryEvent event); +} diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 129d415a8a..a6c3c4f0d4 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -191,6 +191,10 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } + if (event != null) { + options.getReplayController().sendReplayForEvent(event); + } + try { @Nullable TraceContext traceContext = null; if (HintUtils.hasType(hint, Backfillable.class)) { @@ -227,23 +231,42 @@ 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); } } } + } - return sentryId; + private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable SentryId replayId = scope.getReplayId(); + if (replayId != null) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(replayId); + } + } + } } @Override diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 026f2c9e90..50f724591a 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -462,6 +462,8 @@ public class SentryOptions { private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions(); + private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); + /** * Adds an event processor * @@ -2281,6 +2283,15 @@ public ExperimentalOptions get_experimental() { return _experimental; } + public @NotNull ReplayController getReplayController() { + return replayController; + } + + public void setReplayController(final @Nullable ReplayController replayController) { + this.replayController = + replayController != null ? replayController : NoOpReplayController.getInstance(); + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index df98fc384f..d702d6256b 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -51,6 +51,10 @@ 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( @@ -66,6 +70,10 @@ 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( diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8c1536cbbf..320f79680b 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -582,12 +582,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/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index df799aaa07..da34382d51 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, null); + this(traceId, publicKey, null, null, null, null, null, null, null, null); } TraceContext( @@ -38,7 +39,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; @@ -48,6 +50,7 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.transaction = transaction; this.sampleRate = sampleRate; this.sampled = sampled; + this.replayId = replayId; } @SuppressWarnings("UnusedMethod") @@ -96,6 +99,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 */ @@ -198,6 +205,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 @@ -227,6 +235,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); @@ -253,6 +264,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) { @@ -288,6 +300,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<>(); @@ -320,7 +335,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/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index e79e5ebf8c..f2a674d554 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -62,6 +62,7 @@ class TraceContextSerializationTest { id = "user-id" others = mapOf("segment" to "pro") }, + SentryId(), SentryOptions().apply { dsn = dsnString environment = "prod" From f7ac74f08c79c67b4fcfb22a9ed565d26a5fb413 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 12:32:53 +0100 Subject: [PATCH 048/184] Pass hint to captureReplay --- .../api/sentry-android-replay.api | 2 +- .../android/replay/ReplayIntegration.kt | 23 ++++++++++--------- sentry/api/sentry.api | 4 ++-- .../java/io/sentry/NoOpReplayController.java | 2 +- .../main/java/io/sentry/ReplayController.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 2 +- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 82cfece004..1e107ca495 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -14,7 +14,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun start ()V public fun stop ()V } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index da14c72bd4..bedc8bab81 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -136,7 +136,7 @@ class ReplayIntegration( recorder?.resume() } - override fun sendReplayForEvent(event: SentryEvent) { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { if (isFullSession.get()) { options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) return @@ -164,7 +164,7 @@ class ReplayIntegration( val replayId = currentReplayId.get() saver.submit { val videoDuration = - createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER) + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -193,7 +193,7 @@ class ReplayIntegration( val replayId = currentReplayId.get() saver.submit { val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -218,7 +218,7 @@ class ReplayIntegration( saver.submit { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) } FileUtils.deleteRecursively(replayCacheDir) } @@ -252,8 +252,7 @@ class ReplayIntegration( options._experimental.replayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId, - SESSION + segmentId ) if (videoDuration != null) { currentSegment.getAndIncrement() @@ -271,7 +270,8 @@ class ReplayIntegration( currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, - replayType: ReplayType + replayType: ReplayType = SESSION, + hint: Hint? = null ): Long? { val generatedVideo = cache?.createVideoOf( duration, @@ -287,7 +287,8 @@ class ReplayIntegration( segmentId, frameCount, videoDuration, - replayType + replayType, + hint ) return videoDuration } @@ -299,7 +300,8 @@ class ReplayIntegration( segmentId: Int, frameCount: Int, duration: Long, - replayType: ReplayType + replayType: ReplayType, + hint: Hint? = null ) { val replay = SentryReplayEvent().apply { eventId = currentReplayId @@ -337,8 +339,7 @@ class ReplayIntegration( ) } - val hint = Hint().apply { replayRecording = recording } - hub?.captureReplay(replay, hint) + hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording }) } override fun close() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f2ad30d938..28ffd71885 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1208,7 +1208,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public static fun getInstance ()Lio/sentry/NoOpReplayController; public fun pause ()V public fun resume ()V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun start ()V public fun stop ()V } @@ -1606,7 +1606,7 @@ public final class io/sentry/PropagationContext { public abstract interface class io/sentry/ReplayController { public abstract fun pause ()V public abstract fun resume ()V - public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public abstract fun start ()V public abstract fun stop ()V } diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 0a11e71423..d052fba8b4 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -25,5 +25,5 @@ public void pause() {} public void resume() {} @Override - public void sendReplayForEvent(@NotNull SentryEvent event) {} + public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 2ccc28cb82..a45a0ecda2 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -13,5 +13,5 @@ public interface ReplayController { void resume(); - void sendReplayForEvent(@NotNull SentryEvent event); + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a6c3c4f0d4..a7af22a615 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -192,7 +192,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } if (event != null) { - options.getReplayController().sendReplayForEvent(event); + options.getReplayController().sendReplayForEvent(event, hint); } try { From 5faeb4ef0594c501af51bcb54d9147c3ff2ae0c2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 14:00:37 +0100 Subject: [PATCH 049/184] Better error handling --- .../android/replay/ScreenshotRecorder.kt | 118 ++++++++++-------- 1 file changed, 67 insertions(+), 51 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 87915f8505..df161551a4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -17,6 +17,7 @@ import android.view.ViewGroup import android.view.ViewTreeObserver import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO +import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference @@ -72,7 +73,12 @@ internal class ScreenshotRecorder( return } - val window = root.phoneWindow ?: return + val window = root.phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not capturing screenshot") + return + } + val bitmap = Bitmap.createBitmap( root.width, root.height, @@ -88,59 +94,69 @@ internal class ScreenshotRecorder( } options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) - PixelCopy.request( - window, - bitmap, - { copyResult: Int -> - if (copyResult != PixelCopy.SUCCESS) { - options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) - return@request - } - - val viewHierarchy = bitmapToVH[bitmap] - val scaledBitmap: Bitmap - - if (viewHierarchy == null) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - return@request - } else { - scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - config.recordingWidth, - config.recordingHeight, - true - ) - val canvas = Canvas(scaledBitmap) - canvas.setMatrix(prescaledMatrix) - viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - // TODO: check for view type rather than rely on absence of dominantColor here - val color = if (it.dominantColor == null) { - singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) - singlePixelBitmap.getPixel(0, 0) - } else { - it.dominantColor - } + try { + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + if (copyResult != PixelCopy.SUCCESS) { + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + bitmap.recycle() + bitmapToVH.remove(bitmap) + return@request + } - maskingPaint.setColor(color) - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + val viewHierarchy = bitmapToVH[bitmap] + val scaledBitmap: Bitmap + + if (viewHierarchy == null) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + bitmap.recycle() + bitmapToVH.remove(bitmap) + return@request + } else { + scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + config.recordingWidth, + config.recordingHeight, + true + ) + val canvas = Canvas(scaledBitmap) + canvas.setMatrix(prescaledMatrix) + viewHierarchy.traverse { + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + // TODO: check for view type rather than rely on absence of dominantColor here + val color = if (it.dominantColor == null) { + singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) + singlePixelBitmap.getPixel(0, 0) + } else { + it.dominantColor + } + + maskingPaint.setColor(color) + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + } } } - } - - val screenshot = scaledBitmap.copy(ARGB_8888, false) - screenshotRecorderCallback.onScreenshotRecorded(screenshot) - lastScreenshot = screenshot - contentChanged.set(false) - - scaledBitmap.recycle() - bitmap.recycle() - bitmapToVH.remove(bitmap) - }, - handler - ) + + val screenshot = scaledBitmap.copy(ARGB_8888, false) + screenshotRecorderCallback.onScreenshotRecorded(screenshot) + lastScreenshot = screenshot + contentChanged.set(false) + + scaledBitmap.recycle() + bitmap.recycle() + bitmapToVH.remove(bitmap) + }, + handler + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture replay recording", e) + bitmap.recycle() + bitmapToVH.remove(bitmap) + } } } From 65d35ececa849a9a268431632b7555a3f183b8fa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 14:03:01 +0100 Subject: [PATCH 050/184] recycler lastScreenshot before re-assigning --- .../src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index df161551a4..66f1ede82d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -143,6 +143,7 @@ internal class ScreenshotRecorder( val screenshot = scaledBitmap.copy(ARGB_8888, false) screenshotRecorderCallback.onScreenshotRecorded(screenshot) + lastScreenshot?.recycle() lastScreenshot = screenshot contentChanged.set(false) From 0f4e718fc20c2001efed3ac4ec1f1947f4366a46 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 16:59:32 +0100 Subject: [PATCH 051/184] Expose ReplayCache as public api --- .../api/sentry-android-replay.api | 41 +++++++++++++++++++ .../io/sentry/android/replay/ReplayCache.kt | 32 +++++++++++---- .../android/replay/ScreenshotRecorder.kt | 2 +- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b3bc98e15b..7f45c6d8f7 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,6 +6,29 @@ public final class io/sentry/android/replay/BuildConfig { public fun ()V } +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 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 (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; +} + public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; public static final field VIDEO_BUFFER_DURATION J @@ -33,6 +56,24 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V } +public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public fun (IIFI)V + public synthetic fun (IIFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()F + public final fun component4 ()I + public final fun copy (IIFI)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getFrameRate ()I + public final fun getRecordingHeight ()I + public final fun getRecordingWidth ()I + public final fun getScaleFactor ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index da62dfb6b1..7be5e901dc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -13,11 +13,18 @@ import io.sentry.protocol.SentryId import java.io.Closeable import java.io.File -internal class ReplayCache( +public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, private val recorderConfig: ScreenshotRecorderConfig, - private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile -> + private val encoderCreator: (File) -> SimpleVideoEncoder +) : Closeable { + + public constructor( + options: SentryOptions, + replayId: SentryId, + recorderConfig: ScreenshotRecorderConfig + ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile -> SimpleVideoEncoder( options, MuxerConfig( @@ -27,11 +34,10 @@ internal class ReplayCache( bitrate = 20 * 1000 ) ).also { it.start() } - } -) : Closeable { + }) private val encoderLock = Any() - internal var encoder: SimpleVideoEncoder? = null + private var encoder: SimpleVideoEncoder? = null internal val replayCacheDir: File? by lazy { if (options.cacheDirPath.isNullOrEmpty()) { @@ -48,7 +54,7 @@ internal class ReplayCache( // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() - fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { + internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { if (replayCacheDir == null) { return } @@ -61,11 +67,20 @@ internal class ReplayCache( it.flush() } + addFrame(screenshot, frameTimestamp) + } + + public fun addFrame(screenshot: File, frameTimestamp: Long) { val frame = ReplayFrame(screenshot, frameTimestamp) frames += frame } - fun createVideoOf(duration: Long, from: Long, segmentId: Int): GeneratedVideo? { + public fun createVideoOf( + duration: Long, + from: Long, + segmentId: Int, + videoFile: File = File(replayCacheDir, "$segmentId.mp4") + ): GeneratedVideo? { if (frames.isEmpty()) { options.logger.log( DEBUG, @@ -75,7 +90,6 @@ internal class ReplayCache( } // TODO: reuse instance of encoder and just change file path to create a different muxer - val videoFile = File(replayCacheDir, "$segmentId.mp4") encoder = synchronized(encoderLock) { encoderCreator(videoFile) } val step = 1000 / recorderConfig.frameRate.toLong() @@ -160,7 +174,7 @@ internal data class ReplayFrame( val timestamp: Long ) -internal data class GeneratedVideo( +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/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 66f1ede82d..16f2544918 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -230,7 +230,7 @@ internal class ScreenshotRecorder( } } -internal data class ScreenshotRecorderConfig( +public data class ScreenshotRecorderConfig( val recordingWidth: Int, val recordingHeight: Int, val scaleFactor: Float, From fd6e6333944bf921fd056ef00fb568688fd806ce Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 19:40:30 +0100 Subject: [PATCH 052/184] Fix redacting out of sync --- .../android/replay/ScreenshotRecorder.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 16f2544918..b403ceb4fa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -21,8 +21,8 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference -import java.util.WeakHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlin.system.measureTimeMillis @TargetApi(26) @@ -35,7 +35,7 @@ internal class ScreenshotRecorder( private var rootView: WeakReference? = null private val thread = HandlerThread("SentryReplayRecorder").also { it.start() } private val handler = Handler(thread.looper) - private val bitmapToVH = WeakHashMap() + private val pendingViewHierarchy = AtomicReference() private val maskingPaint = Paint() private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( 1, @@ -51,6 +51,8 @@ internal class ScreenshotRecorder( private var lastScreenshot: Bitmap? = null fun capture() { + val viewHierarchy = pendingViewHierarchy.get() + if (!isCapturing.get()) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") return @@ -87,13 +89,6 @@ internal class ScreenshotRecorder( // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - bitmapToVH[bitmap] = rootNode - } - options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) - try { PixelCopy.request( window, @@ -102,17 +97,14 @@ internal class ScreenshotRecorder( if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) bitmap.recycle() - bitmapToVH.remove(bitmap) return@request } - val viewHierarchy = bitmapToVH[bitmap] val scaledBitmap: Bitmap if (viewHierarchy == null) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() - bitmapToVH.remove(bitmap) return@request } else { scaledBitmap = Bitmap.createScaledBitmap( @@ -149,19 +141,30 @@ internal class ScreenshotRecorder( scaledBitmap.recycle() bitmap.recycle() - bitmapToVH.remove(bitmap) }, handler ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) bitmap.recycle() - bitmapToVH.remove(bitmap) } } } 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 + } + + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + pendingViewHierarchy.set(rootNode) + } + options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) + contentChanged.set(true) } @@ -194,7 +197,7 @@ internal class ScreenshotRecorder( unbind(rootView?.get()) rootView?.clear() lastScreenshot?.recycle() - bitmapToVH.clear() + pendingViewHierarchy.set(null) isCapturing.set(false) thread.quitSafely() } From 1a5c4da90015d260cdd9733e705416b255c553c5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 20:22:58 +0100 Subject: [PATCH 053/184] _experimental -> experimental --- .../android/core/ManifestMetadataReader.java | 8 ++++---- .../android/core/ManifestMetadataReaderTest.kt | 8 ++++---- .../api/sentry-android-replay.api | 14 ++++++++++---- .../io/sentry/android/replay/ReplayIntegration.kt | 2 +- sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/SentryOptions.java | 6 +++--- 6 files changed, 23 insertions(+), 17 deletions(-) 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 32ad9df290..fd7bc1d1cd 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 @@ -376,18 +376,18 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.get_experimental().getReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getReplayOptions().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options.get_experimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); + options.getExperimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); } } - if (options.get_experimental().getReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getReplayOptions().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.get_experimental().getReplayOptions().setErrorSampleRate(errorSampleRate); + options.getExperimental().getReplayOptions().setErrorSampleRate(errorSampleRate); } } } 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 03f8e5291f..df7544beee 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 @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options._experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) } @Test fun `applyMetadata does not override replays errorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options._experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options._experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) } @Test @@ -1410,6 +1410,6 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options._experimental.replayOptions.errorSampleRate) + assertNull(fixture.options.experimental.replayOptions.errorSampleRate) } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 7f45c6d8f7..4bdc382eae 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -57,15 +57,17 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb } public final class io/sentry/android/replay/ScreenshotRecorderConfig { - public fun (IIFI)V - public synthetic fun (IIFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; + public fun (IIFII)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()F public final fun component4 ()I - public final fun copy (IIFI)Lio/sentry/android/replay/ScreenshotRecorderConfig; - public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public final fun component5 ()I + public final fun copy (IIFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIIILjava/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 @@ -74,6 +76,10 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig { public fun toString ()Ljava/lang/String; } +public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { + public final fun from (Landroid/content/Context;ILio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index a99c8a9c95..fc816d1f78 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -57,7 +57,7 @@ class ReplayIntegration( ScreenshotRecorderConfig.from( context, targetHeight = 720, - options._experimental.replayOptions + options.experimental.replayOptions ) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fcede26562..d3fd8c4735 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2268,6 +2268,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; @@ -2321,7 +2322,6 @@ public class io/sentry/SentryOptions { public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; public final fun getViewHierarchyExporters ()Ljava/util/List; - public fun get_experimental ()Lio/sentry/ExperimentalOptions; public fun isAttachServerName ()Z public fun isAttachStacktrace ()Z public fun isAttachThreads ()Z diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 026f2c9e90..26e8a7c038 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -460,7 +460,7 @@ public class SentryOptions { */ private int profilingTracesHz = 101; - private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions(); + private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); /** * Adds an event processor @@ -2277,8 +2277,8 @@ public void setSessionFlushTimeoutMillis(final long sessionFlushTimeoutMillis) { } @NotNull - public ExperimentalOptions get_experimental() { - return _experimental; + public ExperimentalOptions getExperimental() { + return experimental; } /** The BeforeSend callback */ From da37c898f70bb13f712a6cfd22626addd37aabf8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 20:40:36 +0100 Subject: [PATCH 054/184] Merge conflicts --- .../api/sentry-android-replay.api | 1 + .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index a1641def95..0a064b803f 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -27,6 +27,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public fun close ()V public final fun createVideoOf (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun rotate (J)V } public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 267bbb0c01..ea4b365dfd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -81,8 +81,8 @@ class ReplayIntegration( return } - if (!options._experimental.replayOptions.isSessionReplayEnabled && - !options._experimental.replayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.replayOptions.isSessionReplayEnabled && + !options.experimental.replayOptions.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return @@ -91,7 +91,7 @@ class ReplayIntegration( this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) - isFullSession.set(sample(options._experimental.replayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.replayOptions.sessionSampleRate)) addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() @@ -147,12 +147,12 @@ class ReplayIntegration( return } - if (!sample(options._experimental.replayOptions.errorSampleRate)) { + if (!sample(options.experimental.replayOptions.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options._experimental.replayOptions.errorReplayDuration + val errorReplayDuration = options.experimental.replayOptions.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 @@ -241,7 +241,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options._experimental.replayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.replayOptions.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -249,7 +249,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options._experimental.replayOptions.sessionSegmentDuration, + options.experimental.replayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -260,7 +260,7 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (!isFullSession.get()) { - cache?.rotate(now - options._experimental.replayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.replayOptions.errorReplayDuration) } } } From c53a975af35370d834f3829d6cf23cd405dc092c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 21:43:19 +0200 Subject: [PATCH 055/184] Fix tests --- sentry/src/test/java/io/sentry/JsonSerializerTest.kt | 8 ++++---- .../test/java/io/sentry/TraceContextSerializationTest.kt | 3 ++- sentry/src/test/resources/json/trace_state.json | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index a894fcfff3..7214b3643e 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/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index f2a674d554..876ec12831 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() diff --git a/sentry/src/test/resources/json/trace_state.json b/sentry/src/test/resources/json/trace_state.json index 17a95fdc33..6ca0e48e61 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" } From 18eb67ebf6a7ea188175fb819fe67c7a76d7a561 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:13:32 +0200 Subject: [PATCH 056/184] Add more tests --- .../sentry/android/replay/ReplayCacheTest.kt | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 88d410fab1..fab5f28ac8 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -1,6 +1,7 @@ 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 @@ -123,8 +124,15 @@ class ReplayCacheTest { 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) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) - replayCache.createVideoOf(3000L, 0, 0) assertTrue(replayCache.frames.isEmpty()) assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) } @@ -143,6 +151,7 @@ class ReplayCacheTest { val segment0 = replayCache.createVideoOf(5000L, 0, 0) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } @@ -161,6 +170,7 @@ class ReplayCacheTest { val segment0 = replayCache.createVideoOf(5000L, 0, 0) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } @@ -184,6 +194,7 @@ class ReplayCacheTest { val segment1 = replayCache.createVideoOf(5000L, 5000L, 1) assertEquals(5, segment1!!.frameCount) assertEquals(5000, segment1.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "1.mp4"), segment1.video) } @@ -203,6 +214,34 @@ class ReplayCacheTest { val segment0 = replayCache.createVideoOf(3000L, 0, 0) 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, 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) + } } From a7ae2b7e5ec44b0a971157dd8acce1041df605a7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:13:59 +0200 Subject: [PATCH 057/184] Improve ReplayCache logic --- .../io/sentry/android/replay/ReplayCache.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 7be5e901dc..f5c8fc715d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -97,22 +97,23 @@ public class ReplayCache internal constructor( var lastFrame: ReplayFrame = frames.first() for (timestamp in from until (from + (duration)) step step) { val iter = frames.iterator() - val frameCountBefore = frameCount while (iter.hasNext()) { val frame = iter.next() if (frame.timestamp in (timestamp..timestamp + step)) { - frameCount++ - encode(frame) 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 + } } - // if the frame count hasn't changed we just replicate the last known frame to respect - // the video duration. - if (frameCountBefore == frameCount) { + // 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++ - encode(lastFrame) } } @@ -143,12 +144,18 @@ public class ReplayCache internal constructor( return GeneratedVideo(videoFile, frameCount, videoDuration) } - private fun encode(frame: ReplayFrame) { - val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) - synchronized(encoderLock) { - encoder?.encode(bitmap) + 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 } - bitmap.recycle() } private fun deleteFile(file: File) { From bf14d83367dfb6c7e40a3cfe24db59380ed6ae09 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:14:11 +0200 Subject: [PATCH 058/184] frameUsec -> frameDurationUsec --- .../io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 8a21b0bec0..cf30f9e49f 100644 --- 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 @@ -38,7 +38,7 @@ import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { - private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() + private val frameDurationUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) @@ -59,7 +59,7 @@ class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { // 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 = frameUsec * videoFrames++ + finalVideoTime = frameDurationUsec * videoFrames++ bufferInfo.presentationTimeUs = finalVideoTime // encodedData.position(bufferInfo.offset) @@ -78,6 +78,6 @@ class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { return 0 } // have to add one sec as we calculate it 0-based above - return MILLISECONDS.convert(finalVideoTime + frameUsec, MICROSECONDS) + return MILLISECONDS.convert(finalVideoTime + frameDurationUsec, MICROSECONDS) } } From 82fe21ad444ffda8e6aa788d4d035df6df60182e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:15:34 +0200 Subject: [PATCH 059/184] bottom/right -> height/width --- .../src/main/java/io/sentry/android/replay/ReplayIntegration.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e1f0d2f7e3..b827751477 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -73,7 +73,7 @@ class ReplayIntegration( } private val aspectRatio by lazy(NONE) { - screenBounds.bottom.toFloat() / screenBounds.right.toFloat() + screenBounds.height().toFloat() / screenBounds.width().toFloat() } private val recorderConfig by lazy(NONE) { From de56e35a53b4e74bfac3f33c5a223a27edb87b5b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 23:55:54 +0200 Subject: [PATCH 060/184] add todos --- .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index b827751477..6d0b9ca0dd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -102,6 +102,7 @@ class ReplayIntegration( fun isRecording() = isRecording.get() fun start() { + // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { options.logger.log( DEBUG, @@ -124,11 +125,13 @@ class ReplayIntegration( cache = ReplayCache(options, currentReplayId.get(), recorderConfig) recorder?.startRecording() + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } fun resume() { + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } From fa8c5271abd8cfa0320bf28c55ea362a431b4db0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 23:58:29 +0200 Subject: [PATCH 061/184] duration -> durationMs --- .../sentry/android/replay/ReplayIntegration.kt | 2 +- sentry/api/sentry.api | 4 ++-- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 18 +++++++++--------- .../rrweb/RRWebVideoEventSerializationTest.kt | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 6d0b9ca0dd..81f0b605da 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -265,7 +265,7 @@ class ReplayIntegration( RRWebVideoEvent().apply { this.timestamp = segmentTimestamp.time this.segmentId = segmentId - this.duration = duration + this.durationMs = duration this.frameCount = frameCount size = video.length() frameRate = recorderConfig.frameRate diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9e3a90946f..3ffc0cda8f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4773,7 +4773,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, public fun equals (Ljava/lang/Object;)Z public fun getContainer ()Ljava/lang/String; public fun getDataUnknown ()Ljava/util/Map; - public fun getDuration ()J + public fun getDurationMs ()J public fun getEncoding ()Ljava/lang/String; public fun getFrameCount ()I public fun getFrameRate ()I @@ -4791,7 +4791,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, 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 setDuration (J)V + public fun setDurationMs (J)V public fun setEncoding (Ljava/lang/String;)V public fun setFrameCount (I)V public fun setFrameRate (I)V diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index 532177ff9f..1ba9f19c72 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -26,7 +26,7 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private @NotNull String tag; private int segmentId; private long size; - private long duration; + private long durationMs; private @NotNull String encoding = REPLAY_ENCODING; private @NotNull String container = REPLAY_CONTAINER; private int height; @@ -72,12 +72,12 @@ public void setSize(final long size) { this.size = size; } - public long getDuration() { - return duration; + public long getDurationMs() { + return durationMs; } - public void setDuration(final long duration) { - this.duration = duration; + public void setDurationMs(final long durationMs) { + this.durationMs = durationMs; } @NotNull @@ -189,7 +189,7 @@ public boolean equals(Object o) { RRWebVideoEvent that = (RRWebVideoEvent) o; return segmentId == that.segmentId && size == that.size - && duration == that.duration + && durationMs == that.durationMs && height == that.height && width == that.width && frameCount == that.frameCount @@ -209,7 +209,7 @@ public int hashCode() { tag, segmentId, size, - duration, + durationMs, encoding, container, height, @@ -279,7 +279,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.beginObject(); writer.name(JsonKeys.SEGMENT_ID).value(segmentId); writer.name(JsonKeys.SIZE).value(size); - writer.name(JsonKeys.DURATION).value(duration); + writer.name(JsonKeys.DURATION).value(durationMs); writer.name(JsonKeys.ENCODING).value(encoding); writer.name(JsonKeys.CONTAINER).value(container); writer.name(JsonKeys.HEIGHT).value(height); @@ -380,7 +380,7 @@ private void deserializePayload( event.size = size == null ? 0 : size; break; case JsonKeys.DURATION: - event.duration = reader.nextLong(); + event.durationMs = reader.nextLong(); break; case JsonKeys.CONTAINER: final String container = reader.nextStringOrNull(); diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt index 79bfd02456..17a790b5cd 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt @@ -17,7 +17,7 @@ class RRWebVideoEventSerializationTest { tag = "video" segmentId = 0 size = 4_000_000L - duration = 5000 + durationMs = 5000 height = 1920 width = 1080 frameCount = 5 From dfbb9927fd7661cd9f9e405ef998084eb4d75c26 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 00:13:34 +0200 Subject: [PATCH 062/184] replaId non-nullable --- .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 6 +++--- sentry/src/main/java/io/sentry/IScope.java | 4 ++-- sentry/src/main/java/io/sentry/NoOpScope.java | 4 ++-- sentry/src/main/java/io/sentry/Scope.java | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 81f0b605da..e2244d0bf3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -53,7 +53,7 @@ class ReplayIntegration( // TODO: probably not everything has to be thread-safe here private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) - private val currentReplayId = AtomicReference() + private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() private val currentSegment = AtomicInteger(0) private val saver = @@ -177,8 +177,8 @@ class ReplayIntegration( cache?.close() currentSegment.set(0) segmentTimestamp.set(null) - currentReplayId.set(null) - hub?.configureScope { it.replayId = null } + currentReplayId.set(SentryId.EMPTY_ID) + hub?.configureScope { it.replayId = SentryId.EMPTY_ID } isRecording.set(false) } diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 2d38371ead..4b5930bb54 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -91,7 +91,7 @@ public interface IScope { * @return the id of the current session replay */ @ApiStatus.Internal - @Nullable + @NotNull SentryId getReplayId(); /** @@ -100,7 +100,7 @@ public interface IScope { * @param replayId the id of the current session replay */ @ApiStatus.Internal - void setReplayId(final @Nullable SentryId replayId); + void setReplayId(final @NotNull SentryId replayId); /** * Returns the Scope's request diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index 660dca0b69..d2be23eba8 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -70,8 +70,8 @@ public void setUser(@Nullable User user) {} public void setScreen(@Nullable String screen) {} @Override - public @Nullable SentryId getReplayId() { - return null; + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; } @Override diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 164d52dc2b..161502a9d3 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -82,7 +82,7 @@ public final class Scope implements IScope { private @NotNull PropagationContext propagationContext; /** Scope's session replay id */ - private @Nullable SentryId replayId; + private @NotNull SentryId replayId = SentryId.EMPTY_ID; /** * Scope's ctor @@ -318,12 +318,12 @@ public void setScreen(final @Nullable String screen) { } @Override - public @Nullable SentryId getReplayId() { + public @NotNull SentryId getReplayId() { return replayId; } @Override - public void setReplayId(final @Nullable SentryId replayId) { + public void setReplayId(final @NotNull SentryId replayId) { this.replayId = replayId; // TODO: set to contexts and notify observers to persist this as well From ad7d78d9530689315d815d64c823cc9e83a2e7db Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 00:39:38 +0200 Subject: [PATCH 063/184] More conflicts --- sentry/src/main/java/io/sentry/Baggage.java | 8 ++++---- sentry/src/main/java/io/sentry/SentryClient.java | 2 +- sentry/src/main/java/io/sentry/SentryTracer.java | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index c6a1ee5630..fbeddfe1ff 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -384,7 +384,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 SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -396,7 +396,7 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); - if (replayId != null) { + if (!SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); @@ -408,12 +408,12 @@ 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()); - final @Nullable SentryId replayId = scope.getReplayId(); - if (replayId != null) { + if (!SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } setUserSegment(user != null ? getSegment(user) : null); diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a7af22a615..e0a1177646 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -259,7 +259,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { final @Nullable SentryId replayId = scope.getReplayId(); - if (replayId != null) { + if (!SentryId.EMPTY_ID.equals(replayId)) { if (HintUtils.hasType(hint, TransactionEnd.class)) { final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); if (sentrySdkHint instanceof DiskFlushNotification) { diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 320f79680b..fe8d4d0150 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -591,7 +591,7 @@ private void updateBaggageValues() { baggage.setValuesFromTransaction( this, userAtomicReference.get(), - replayId.get(), + replayId.get() == null ? SentryId.EMPTY_ID : replayId.get(), hub.getOptions(), this.getSamplingDecision()); baggage.freeze(); From ab00547fefe40593c87d2b1ed3d252d09470b3b0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 00:40:56 +0200 Subject: [PATCH 064/184] More conflicts --- sentry/src/main/java/io/sentry/Baggage.java | 4 ++-- sentry/src/main/java/io/sentry/SentryTracer.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index fbeddfe1ff..53bd10248e 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -384,7 +384,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, - final @NotNull SentryId replayId, + final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -396,7 +396,7 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); - if (!SentryId.EMPTY_ID.equals(replayId)) { + if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index fe8d4d0150..320f79680b 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -591,7 +591,7 @@ private void updateBaggageValues() { baggage.setValuesFromTransaction( this, userAtomicReference.get(), - replayId.get() == null ? SentryId.EMPTY_ID : replayId.get(), + replayId.get(), hub.getOptions(), this.getSamplingDecision()); baggage.freeze(); From 7f78fee236caa6c8a4005883708d57ba777938b4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 01:03:01 +0200 Subject: [PATCH 065/184] Fix tests --- .../android/core/LifecycleWatcherTest.kt | 3 ++ .../sentry/android/core/SentryAndroidTest.kt | 1 + .../android/replay/ReplayIntegration.kt | 29 +++++++++++++------ sentry/src/test/java/io/sentry/BaggageTest.kt | 8 ++--- .../json/sentry_envelope_header.json | 3 +- 5 files changed, 29 insertions(+), 15 deletions(-) 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 4b620813bf..2adcc9ee6e 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 @@ -7,6 +7,7 @@ import io.sentry.IHub import io.sentry.IScope 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 +35,7 @@ class LifecycleWatcherTest { val ownerMock = mock() val hub = mock() val dateProvider = mock() + val options = SentryOptions() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -47,6 +49,7 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + whenever(hub.options).thenReturn(options) return LifecycleWatcher( hub, 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 b543ae318a..a2d27bb4b5 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 @@ -342,6 +342,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true + options.experimental.replayOptions.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index f93d35d648..257ab8e047 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -55,8 +55,11 @@ class ReplayIntegration( private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() private val currentSegment = AtomicInteger(0) - private val saver = + + // TODO: surround with try-catch on the calling site + private val saver by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( @@ -103,10 +106,6 @@ class ReplayIntegration( override fun start() { // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { - options.logger.log( - DEBUG, - "Session replay is disabled due to conditions not met in Integration.register" - ) return } @@ -134,12 +133,20 @@ class ReplayIntegration( } override fun resume() { + if (!isEnabled.get()) { + return + } + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + if (!isEnabled.get()) { + return + } + if (isFullSession.get()) { options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) return @@ -183,6 +190,10 @@ class ReplayIntegration( } override fun pause() { + if (!isEnabled.get()) { + return + } + val now = dateProvider.currentTimeMillis recorder?.pause() @@ -205,10 +216,6 @@ class ReplayIntegration( override fun stop() { if (!isEnabled.get()) { - options.logger.log( - DEBUG, - "Session replay is disabled due to conditions not met in Integration.register" - ) return } @@ -346,6 +353,10 @@ class ReplayIntegration( } override fun close() { + if (!isEnabled.get()) { + return + } + stop() saver.gracefullyShutdown(options) } diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index eb1cfa0383..c24731e92a 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/resources/json/sentry_envelope_header.json b/sentry/src/test/resources/json/sentry_envelope_header.json index 14c144f820..5f6b3b25e7 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" } From 9f252bc9e13464e8e4a22243a2d7371595d8a528 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 13:24:43 +0200 Subject: [PATCH 066/184] Address PR review --- .../android/core/ManifestMetadataReader.java | 15 +++++++++------ .../android/core/ManifestMetadataReaderTest.kt | 8 ++++---- .../io/sentry/android/replay/ReplayIntegration.kt | 2 +- sentry/api/sentry.api | 4 ++-- .../main/java/io/sentry/ExperimentalOptions.java | 14 +++++++++----- 5 files changed, 25 insertions(+), 18 deletions(-) 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 fd7bc1d1cd..d41915ab08 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 @@ -100,9 +100,9 @@ final class ManifestMetadataReader { static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; - static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.replays.session-sample-rate"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; - static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.replays.error-sample-rate"; + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -376,18 +376,21 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.getExperimental().getReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getSessionReplayOptions().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options.getExperimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); + options + .getExperimental() + .getSessionReplayOptions() + .setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getSessionReplayOptions().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.getExperimental().getReplayOptions().setErrorSampleRate(errorSampleRate); + options.getExperimental().getSessionReplayOptions().setErrorSampleRate(errorSampleRate); } } } 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 df7544beee..b46743ef7e 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 @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) } @Test fun `applyMetadata does not override replays errorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.sessionReplayOptions.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) } @Test @@ -1410,6 +1410,6 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.replayOptions.errorSampleRate) + assertNull(fixture.options.experimental.sessionReplayOptions.errorSampleRate) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d80b1ecc8a..25c63f5179 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -57,7 +57,7 @@ class ReplayIntegration( ScreenshotRecorderConfig.from( context, targetHeight = 720, - options.experimental.replayOptions + options.experimental.sessionReplayOptions ) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8e0327eace..d639b765ff 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -307,8 +307,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getReplayOptions ()Lio/sentry/SentryReplayOptions; - public fun setReplayOptions (Lio/sentry/SentryReplayOptions;)V + public fun getSessionReplayOptions ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplayOptions (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index b22d283724..0d12bf844b 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -2,15 +2,19 @@ import org.jetbrains.annotations.NotNull; +/** + * Experimental options for new features, these options are going to be promoted to SentryOptions + * before GA + */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions replayOptions = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); @NotNull - public SentryReplayOptions getReplayOptions() { - return replayOptions; + public SentryReplayOptions getSessionReplayOptions() { + return sessionReplayOptions; } - public void setReplayOptions(final @NotNull SentryReplayOptions replayOptions) { - this.replayOptions = replayOptions; + public void setSessionReplayOptions(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplayOptions = sessionReplayOptions; } } From 27b15d7235d50cef4c25c546acf58a8929287274 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 15:38:49 +0200 Subject: [PATCH 067/184] Add kdoc --- .../io/sentry/android/replay/ReplayCache.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index f5c8fc715d..fd07d74354 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -13,6 +13,19 @@ 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, @@ -54,6 +67,16 @@ public class ReplayCache internal constructor( // 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 @@ -70,11 +93,36 @@ public class ReplayCache internal constructor( 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 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, From 957f0cf80b5ec2318ea218b86a01c4d718ea9d16 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 16:00:06 +0200 Subject: [PATCH 068/184] Add kdoc --- .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 0af51a6144..db1f691260 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -205,6 +205,11 @@ public class ReplayCache internal constructor( } } + /** + * 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) { From da3560dfb426d49c765ffb69671ce92c748c8265 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 16:11:04 +0200 Subject: [PATCH 069/184] Fix tests --- .../io/sentry/android/core/SentryAndroidTest.kt | 2 +- .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++-------- .../src/test/java/io/sentry/SentryClientTest.kt | 5 +++++ 3 files changed, 14 insertions(+), 9 deletions(-) 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 a2d27bb4b5..a4b213800f 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 @@ -342,7 +342,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.replayOptions.errorSampleRate = 1.0 + options.experimental.sessionReplayOptions.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 1caf9ca8d7..bc46b02207 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -84,8 +84,8 @@ class ReplayIntegration( return } - if (!options.experimental.replayOptions.isSessionReplayEnabled && - !options.experimental.replayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.sessionReplayOptions.isSessionReplayEnabled && + !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return @@ -94,7 +94,7 @@ class ReplayIntegration( this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) - isFullSession.set(sample(options.experimental.replayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() @@ -157,12 +157,12 @@ class ReplayIntegration( return } - if (!sample(options.experimental.replayOptions.errorSampleRate)) { + if (!sample(options.experimental.sessionReplayOptions.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options.experimental.replayOptions.errorReplayDuration + val errorReplayDuration = options.experimental.sessionReplayOptions.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 @@ -251,7 +251,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options.experimental.replayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.sessionReplayOptions.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -259,7 +259,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options.experimental.replayOptions.sessionSegmentDuration, + options.experimental.sessionReplayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -270,7 +270,7 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (!isFullSession.get()) { - cache?.rotate(now - options.experimental.replayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) } } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 0733e6ea45..003b874326 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -2286,6 +2286,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()) @@ -2358,6 +2359,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()) @@ -2426,6 +2428,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) @@ -2438,6 +2442,7 @@ class SentryClientTest { check { assertNotNull(it.header.traceContext) assertEquals(scopePropagationContext.traceId, it.header.traceContext!!.traceId) + assertEquals(replayId, it.header.traceContext!!.replayId) }, any() ) From 1a77d171df1fc96bc7e371784b1d976e7b8a1adc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 17:00:09 +0200 Subject: [PATCH 070/184] Add comment for experimental options --- sentry/src/main/java/io/sentry/ExperimentalOptions.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 0d12bf844b..7a9234efae 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -4,7 +4,9 @@ /** * Experimental options for new features, these options are going to be promoted to SentryOptions - * before GA + * before GA. + *

+ * Beware that experimental options can change at any time. */ public final class ExperimentalOptions { private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); From b88b1b9163a42054a960b465a8d02f9f6f7320a1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 17:12:19 +0200 Subject: [PATCH 071/184] Do not run recorder if full session was not sampled --- .../java/io/sentry/android/replay/ReplayIntegration.kt | 9 ++++++++- sentry/src/main/java/io/sentry/ExperimentalOptions.java | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index bc46b02207..a64353e09a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -91,10 +91,17 @@ class ReplayIntegration( return } + isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) + if (!isFullSession.get() && + !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") + return + } + this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) - isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 7a9234efae..ebd1adabb2 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -5,8 +5,8 @@ /** * 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. + * + *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); From 4d533fb0dbcb146fb0a6843b5f53158411b4dac2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 22:53:36 +0200 Subject: [PATCH 072/184] Add more tests --- .../sentry/android/core/LifecycleWatcher.java | 3 +- .../core/AndroidOptionsInitializerTest.kt | 7 +++ .../android/core/LifecycleWatcherTest.kt | 56 +++++++++++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 51 +++++++++++++++++ .../test/java/io/sentry/SentryTracerTest.kt | 7 +++ 5 files changed, 123 insertions(+), 1 deletion(-) 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 bcdb49e3e6..81e77a75fb 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 @@ -94,10 +94,11 @@ private void startSession() { hub.startSession(); } hub.getOptions().getReplayController().start(); - } else if (!isFreshSession.getAndSet(false)) { + } 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); } 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 94b0490f17..9bded25087 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 @@ -491,6 +491,13 @@ class AndroidOptionsInitializerTest { 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) 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 2adcc9ee6e..388bfbe274 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,6 +5,7 @@ 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 @@ -36,6 +37,7 @@ class LifecycleWatcherTest { val hub = mock() val dateProvider = mock() val options = SentryOptions() + val replayController = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -49,6 +51,7 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + options.setReplayController(replayController) whenever(hub.options).thenReturn(options) return LifecycleWatcher( @@ -73,6 +76,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -82,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 @@ -91,6 +96,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -99,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 @@ -113,6 +120,7 @@ class LifecycleWatcherTest { assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() + verify(fixture.replayController, never()).stop() } @Test @@ -244,6 +252,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub, never()).startSession() + verify(fixture.replayController, never()).start() } @Test @@ -270,6 +279,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -285,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/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 003b874326..eddacbf939 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1,6 +1,7 @@ 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 @@ -2272,6 +2273,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") @@ -2584,6 +2620,21 @@ class SentryClientTest { ) } + @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() diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 37a3d09cca..a91f81f3cb 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) @@ -590,6 +593,7 @@ class SentryTracerTest { assertEquals(transaction.name, it.transaction) // assertEquals("user-id", it.userId) assertEquals("pro", it.userSegment) + assertEquals(replayId, it.replayId) } } @@ -658,6 +662,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val header = transaction.toBaggageHeader(null) assertNotNull(header) { @@ -671,6 +677,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")) } } From 42762640b206d1ffd7eadbab52d73b8e6a96c21c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 00:00:25 +0200 Subject: [PATCH 073/184] Add session deadline of 1h --- .../sentry/android/replay/ReplayIntegration.kt | 9 +++++++++ .../src/main/AndroidManifest.xml | 2 ++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/SentryReplayOptions.java | 16 ++++++++++++---- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index a64353e09a..35eed08675 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -34,6 +34,7 @@ import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.LazyThreadSafetyMode.NONE @@ -54,6 +55,7 @@ class ReplayIntegration( private val isRecording = AtomicBoolean(false) private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() + private val replayStartTimestamp = AtomicLong() private val currentSegment = AtomicInteger(0) // TODO: surround with try-catch on the calling site @@ -136,6 +138,7 @@ class ReplayIntegration( recorder?.startRecording() // 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) } @@ -243,6 +246,7 @@ class ReplayIntegration( recorder?.stopRecording() cache?.close() currentSegment.set(0) + replayStartTimestamp.set(0) segmentTimestamp.set(null) currentReplayId.set(SentryId.EMPTY_ID) hub?.configureScope { it.replayId = SentryId.EMPTY_ID } @@ -276,6 +280,11 @@ class ReplayIntegration( // set next segment timestamp as close to the previous one as possible to avoid gaps segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } + } else if (isFullSession.get() && + (now - replayStartTimestamp.get() >= options.experimental.sessionReplayOptions.sessionDuration) + ) { + stop() + options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } else if (!isFullSession.get()) { cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e4fd0aefd9..23b469ff20 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -156,5 +156,7 @@ + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 3e65667941..2c116ffb21 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2569,6 +2569,7 @@ public final class io/sentry/SentryReplayOptions { public fun getErrorReplayDuration ()J public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I + public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J public fun isSessionReplayEnabled ()Z diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d702d6256b..51ed1a3a72 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -22,22 +22,25 @@ public final class SentryReplayOptions { /** * Defines the quality of the session replay. Higher bit rates have better replay quality, but - * also affect the final payload size to transfer. The default value is 20kbps; + * also affect the final payload size to transfer, defaults to 20kbps. */ private int bitRate = 20_000; /** * 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. + * 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. */ + /** 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. */ + /** 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( @@ -103,4 +106,9 @@ public long getErrorReplayDuration() { public long getSessionSegmentDuration() { return sessionSegmentDuration; } + + @ApiStatus.Internal + public long getSessionDuration() { + return sessionDuration; + } } From 3fe5e0fb1989f19518b9a39962f359a75cd2b5f6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 00:17:23 +0200 Subject: [PATCH 074/184] Clean up older replays when starting a new one --- .../android/replay/ReplayIntegration.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 35eed08675..8c944506bf 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -59,7 +59,7 @@ class ReplayIntegration( private val currentSegment = AtomicInteger(0) // TODO: surround with try-catch on the calling site - private val saver by lazy { + private val replayExecutor by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } @@ -128,6 +128,18 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) + replayExecutor.submit { + // 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 + } + } + } if (isFullSession.get()) { // 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 @@ -182,7 +194,7 @@ class ReplayIntegration( } val segmentId = currentSegment.get() val replayId = currentReplayId.get() - saver.submit { + replayExecutor.submit { val videoDuration = createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) if (videoDuration != null) { @@ -215,7 +227,7 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() - saver.submit { + replayExecutor.submit { val videoDuration = createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) if (videoDuration != null) { @@ -235,7 +247,7 @@ class ReplayIntegration( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir - saver.submit { + replayExecutor.submit { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) @@ -257,7 +269,7 @@ class ReplayIntegration( // 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 - saver.submit { + replayExecutor.submit { cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis @@ -374,7 +386,7 @@ class ReplayIntegration( } stop() - saver.gracefullyShutdown(options) + replayExecutor.gracefullyShutdown(options) } private class ReplayExecutorServiceThreadFactory : ThreadFactory { From ca9f9d40a06ee955be641fc4435f59a2acc3fb20 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 00:23:38 +0200 Subject: [PATCH 075/184] Remove unnecessary extension fun --- .../test/java/io/sentry/android/core/SentryAndroidTest.kt | 5 ++--- sentry-android-replay/api/sentry-android-replay.api | 5 ++--- sentry/api/sentry.api | 2 ++ sentry/src/main/java/io/sentry/NoOpReplayController.java | 5 +++++ sentry/src/main/java/io/sentry/ReplayController.java | 2 ++ 5 files changed, 13 insertions(+), 6 deletions(-) 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 a4b213800f..f8f266b149 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 @@ -27,7 +27,6 @@ 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.replay.getReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingOptionsObserver @@ -319,7 +318,7 @@ class SentryAndroidTest { @Config(sdk = [26]) fun `init starts session replay if app is in foreground`() { initSentryWithForegroundImportance(true) { _ -> - assertTrue(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + assertTrue(Sentry.getCurrentHub().options.replayController.isRecording()) } } @@ -327,7 +326,7 @@ class SentryAndroidTest { @Config(sdk = [26]) fun `init does not start session replay if the app is in background`() { initSentryWithForegroundImportance(false) { _ -> - assertFalse(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + assertFalse(Sentry.getCurrentHub().options.replayController.isRecording()) } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 0a064b803f..3b320c91b7 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -33,7 +33,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V - public final fun isRecording ()Z + public fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V @@ -44,8 +44,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr } public final class io/sentry/android/replay/ReplayIntegrationKt { - public static final fun getReplayIntegration (Lio/sentry/IHub;)Lio/sentry/android/replay/ReplayIntegration; - public static final fun gracefullyShutdown (Ljava/util/concurrent/ExecutorService;Lio/sentry/SentryOptions;)V + public static final fun submitSafely (Ljava/util/concurrent/ExecutorService;Lio/sentry/ILogger;Ljava/lang/Runnable;)Ljava/util/concurrent/Future; } public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2c116ffb21..d3200dc76a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1206,6 +1206,7 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun isRecording ()Z public fun pause ()V public fun resume ()V public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V @@ -1604,6 +1605,7 @@ public final class io/sentry/PropagationContext { } public abstract interface class io/sentry/ReplayController { + public abstract fun isRecording ()Z public abstract fun pause ()V public abstract fun resume ()V public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index d052fba8b4..1353e01a58 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -24,6 +24,11 @@ public void pause() {} @Override public void resume() {} + @Override + public boolean isRecording() { + return false; + } + @Override public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index a45a0ecda2..d2b7f7eb16 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -13,5 +13,7 @@ public interface ReplayController { void resume(); + boolean isRecording(); + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); } From ea417e426d48b10b21c85fc5e38bf5c0a21ca5ed Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 12:12:18 +0200 Subject: [PATCH 076/184] Safe executors --- .../api/sentry-android-replay.api | 4 -- .../android/replay/ReplayIntegration.kt | 42 ++++-------- .../sentry/android/replay/WindowRecorder.kt | 24 ++++--- .../sentry/android/replay/util/Executors.kt | 67 +++++++++++++++++++ 4 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 3b320c91b7..cda49e0fd1 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -43,10 +43,6 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr public fun stop ()V } -public final class io/sentry/android/replay/ReplayIntegrationKt { - public static final fun submitSafely (Ljava/util/concurrent/ExecutorService;Lio/sentry/ILogger;Ljava/lang/Runnable;)Ljava/util/concurrent/Future; -} - public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 8c944506bf..3079cb47fd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -18,6 +18,8 @@ import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent @@ -28,10 +30,8 @@ import java.io.Closeable import java.io.File import java.security.SecureRandom import java.util.Date -import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong @@ -43,6 +43,10 @@ class ReplayIntegration( private val dateProvider: ICurrentDateProvider ) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { + internal companion object { + private const val TAG = "ReplayIntegration" + } + private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: WindowRecorder? = null @@ -110,7 +114,7 @@ class ReplayIntegration( .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } - fun isRecording() = isRecording.get() + override fun isRecording() = isRecording.get() override fun start() { // TODO: add lifecycle state instead and manage it in start/pause/resume/stop @@ -128,7 +132,7 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { // clean up old replays options.cacheDirPath?.let { cacheDir -> File(cacheDir).listFiles { dir, name -> @@ -194,7 +198,7 @@ class ReplayIntegration( } val segmentId = currentSegment.get() val replayId = currentReplayId.get() - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { val videoDuration = createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) if (videoDuration != null) { @@ -227,7 +231,7 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.pause") { val videoDuration = createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) if (videoDuration != null) { @@ -247,7 +251,7 @@ class ReplayIntegration( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.stop") { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) @@ -269,7 +273,7 @@ class ReplayIntegration( // 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.submit { + replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis @@ -398,25 +402,3 @@ class ReplayIntegration( } } } - -/** - * Retrieves the [ReplayIntegration] from the list of integrations in [SentryOptions] - */ -fun IHub.getReplayIntegration(): ReplayIntegration? = - options.integrations.find { it is ReplayIntegration } as? ReplayIntegration - -fun ExecutorService.gracefullyShutdown(options: SentryOptions) { - synchronized(this) { - if (!isShutdown) { - shutdown() - } - try { - if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { - shutdownNow() - } - } catch (e: InterruptedException) { - shutdownNow() - Thread.currentThread().interrupt() - } - } -} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index d23222368f..743b5f5d89 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -2,8 +2,9 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.view.View -import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.scheduleAtFixedRateSafely import java.io.Closeable import java.lang.ref.WeakReference import java.util.concurrent.Executors @@ -20,6 +21,10 @@ internal class WindowRecorder( private val screenshotRecorderCallback: ScreenshotRecorderCallback ) : Closeable { + internal companion object { + private const val TAG = "WindowRecorder" + } + private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } @@ -57,14 +62,15 @@ internal class WindowRecorder( recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) rootViewsSpy.listeners += onRootViewsChangedListener - capturingTask = capturer.scheduleAtFixedRate({ - try { - recorder?.capture() - } catch (e: Throwable) { - options.logger.log(ERROR, "Failed to capture a screenshot with exception:", e) - // TODO: I guess schedule the capturer again, cause it will stop executing the runnable? - } - }, 0L, 1000L / recorderConfig.frameRate, MILLISECONDS) + capturingTask = capturer.scheduleAtFixedRateSafely( + options, + "$TAG.capture", + 0L, + 1000L / recorderConfig.frameRate, + MILLISECONDS + ) { + recorder?.capture() + } } fun resume() = recorder?.resume() 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 0000000000..093416f9bb --- /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 + } +} From 0c5e4b0370aab4196ac7bf331288ad4dd9a01f30 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 10:44:56 +0200 Subject: [PATCH 077/184] Fix crashing MediaCodec and use density to determine recording resolution --- .../api/sentry-android-replay.api | 14 ++-- .../android/replay/ReplayIntegration.kt | 1 - .../android/replay/ScreenshotRecorder.kt | 52 ++++++++++----- .../sentry/android/replay/WindowRecorder.kt | 5 -- .../replay/video/SimpleVideoEncoder.kt | 65 +++++++++++++++---- .../java/io/sentry/SentryReplayOptions.java | 2 +- 6 files changed, 100 insertions(+), 39 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index cda49e0fd1..32f1891af6 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -49,26 +49,28 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb public final class io/sentry/android/replay/ScreenshotRecorderConfig { public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; - public fun (IIFII)V + public fun (IIFFII)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()F - public final fun component4 ()I + public final fun component4 ()F public final fun component5 ()I - public final fun copy (IIFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; - public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + 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 getScaleFactor ()F + 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;ILio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 3079cb47fd..94a6ccf1a4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -70,7 +70,6 @@ class ReplayIntegration( private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( context, - targetHeight = 720, options.experimental.sessionReplayOptions ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index e92f2a0574..7cba3bf54a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -30,7 +30,6 @@ import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt -import kotlin.system.measureTimeMillis @TargetApi(26) internal class ScreenshotRecorder( @@ -51,14 +50,14 @@ internal class ScreenshotRecorder( ) private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) private val prescaledMatrix = Matrix().apply { - preScale(config.scaleFactor, config.scaleFactor) + preScale(config.scaleFactorX, config.scaleFactorY) } private val contentChanged = AtomicBoolean(false) private val isCapturing = AtomicBoolean(true) private var lastScreenshot: Bitmap? = null fun capture() { - val viewHierarchy = pendingViewHierarchy.get() + val viewHierarchy = pendingViewHierarchy.getAndSet(null) if (!isCapturing.get()) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") @@ -165,12 +164,9 @@ internal class ScreenshotRecorder( return } - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - pendingViewHierarchy.set(rootNode) - } - options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + pendingViewHierarchy.set(rootNode) contentChanged.set(true) } @@ -243,12 +239,29 @@ internal class ScreenshotRecorder( public data class ScreenshotRecorderConfig( val recordingWidth: Int, val recordingHeight: Int, - val scaleFactor: Float, + val scaleFactorX: Float, + val scaleFactorY: Float, val frameRate: Int, val bitRate: Int ) { companion object { - fun from(context: Context, targetHeight: Int, sentryReplayOptions: SentryReplayOptions): ScreenshotRecorderConfig { + /** + * 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, + sentryReplayOptions: 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) { @@ -259,12 +272,21 @@ public data class ScreenshotRecorderConfig( wm.defaultDisplay.getRealSize(screenBounds) Rect(0, 0, screenBounds.x, screenBounds.y) } - val aspectRatio = screenBounds.height().toFloat() / screenBounds.width().toFloat() + + // use the baseline density of 1x (mdpi) + val (height, width) = + (screenBounds.height() / context.resources.displayMetrics.density) + .roundToInt() + .adjustToBlockSize() to + (screenBounds.width() / context.resources.displayMetrics.density) + .roundToInt() + .adjustToBlockSize() return ScreenshotRecorderConfig( - recordingWidth = (targetHeight / aspectRatio).roundToInt(), - recordingHeight = targetHeight, - scaleFactor = targetHeight.toFloat() / screenBounds.height(), + recordingWidth = width, + recordingHeight = height, + scaleFactorX = width.toFloat() / screenBounds.width(), + scaleFactorY = height.toFloat() / screenBounds.height(), frameRate = sentryReplayOptions.frameRate, bitRate = sentryReplayOptions.bitRate ) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 743b5f5d89..d5e11936de 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -55,11 +55,6 @@ internal class WindowRecorder( return } -// val (height, width) = (wm.currentWindowMetrics.bounds.bottom / -// context.resources.displayMetrics.density).roundToInt() to -// (wm.currentWindowMetrics.bounds.right / -// context.resources.displayMetrics.density).roundToInt() - recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) rootViewsSpy.listeners += onRootViewsChangedListener capturingTask = capturer.scheduleAtFixedRateSafely( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 73b8862434..630637bfbf 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -40,6 +40,7 @@ import io.sentry.SentryOptions import io.sentry.android.replay.ScreenshotRecorderConfig import java.io.File import java.nio.ByteBuffer +import kotlin.LazyThreadSafetyMode.NONE private const val TIMEOUT_USEC = 100_000L @@ -49,35 +50,77 @@ internal class SimpleVideoEncoder( val muxerConfig: MuxerConfig, val onClose: (() -> Unit)? = null ) { - private val mediaFormat: MediaFormat = run { + + internal val mediaCodec: MediaCodec = run { + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + + codec + } + + private val mediaFormat: MediaFormat by lazy(NONE) { + val videoCapabilities = mediaCodec.codecInfo + .getCapabilitiesForType(muxerConfig.mimeType) + .videoCapabilities + + var bitRate = muxerConfig.recorderConfig.bitRate + 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) + } + + // 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.recorderConfig.recordingWidth, muxerConfig.recorderConfig.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, muxerConfig.recorderConfig.bitRate) + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat()) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) format } - internal val mediaCodec: MediaCodec = run { -// val codecs = MediaCodecList(REGULAR_CODECS) -// val codecName = codecs.findEncoderForFormat(mediaFormat) -// val codec = MediaCodec.createByCodecName(codecName) - val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) - - codec - } - private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.recorderConfig.frameRate.toFloat()) val duration get() = frameMuxer.getVideoTime() diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 51ed1a3a72..c8dc7df7a2 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -24,7 +24,7 @@ public final class SentryReplayOptions { * 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 20kbps. */ - private int bitRate = 20_000; + private int bitRate = 100_000; /** * Number of frames per second of the replay. The bigger the number, the more accurate the replay From 3e0894d29834f6597fecd036c2383e301f615088 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 13:38:48 +0200 Subject: [PATCH 078/184] Add redact options and align naming --- .../android/core/ManifestMetadataReader.java | 35 +++++++++++++++---- .../core/ManifestMetadataReaderTest.kt | 35 ++++++++++++++++--- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../api/sentry-android-replay.api | 2 +- .../android/replay/ReplayIntegration.kt | 22 ++++++------ .../android/replay/ScreenshotRecorder.kt | 10 +++--- .../replay/viewhierarchy/ViewHierarchyNode.kt | 9 ++--- sentry/api/sentry.api | 8 +++-- .../java/io/sentry/ExperimentalOptions.java | 10 +++--- .../java/io/sentry/SentryReplayOptions.java | 34 ++++++++++++++++++ 10 files changed, 127 insertions(+), 40 deletions(-) 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 d41915ab08..2f0d363c01 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,10 @@ final class ManifestMetadataReader { 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() {} @@ -376,23 +380,40 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.getExperimental().getSessionReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options - .getExperimental() - .getSessionReplayOptions() - .setSessionSampleRate(sessionSampleRate); + options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getSessionReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getSessionReplay().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.getExperimental().getSessionReplayOptions().setErrorSampleRate(errorSampleRate); + 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/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index b46743ef7e..d57855444f 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 @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) + 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.sessionReplayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.sessionReplay.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) } @Test @@ -1410,6 +1410,33 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplayOptions.errorSampleRate) + 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 f8f266b149..17d76a752b 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 @@ -341,7 +341,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.sessionReplayOptions.errorSampleRate = 1.0 + options.experimental.sessionReplay.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 32f1891af6..53e9771e13 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -118,6 +118,6 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { - public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun fromView (Landroid/view/View;Lio/sentry/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 94a6ccf1a4..d8df5f830a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -70,7 +70,7 @@ class ReplayIntegration( private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( context, - options.experimental.sessionReplayOptions + options.experimental.sessionReplay ) } @@ -89,16 +89,16 @@ class ReplayIntegration( return } - if (!options.experimental.sessionReplayOptions.isSessionReplayEnabled && - !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.sessionReplay.isSessionReplayEnabled && + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return } - isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.sessionReplay.sessionSampleRate)) if (!isFullSession.get() && - !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") return @@ -182,12 +182,12 @@ class ReplayIntegration( return } - if (!sample(options.experimental.sessionReplayOptions.errorSampleRate)) { + if (!sample(options.experimental.sessionReplay.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options.experimental.sessionReplayOptions.errorReplayDuration + 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 @@ -277,7 +277,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options.experimental.sessionReplayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -285,7 +285,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options.experimental.sessionReplayOptions.sessionSegmentDuration, + options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -296,12 +296,12 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (isFullSession.get() && - (now - replayStartTimestamp.get() >= options.experimental.sessionReplayOptions.sessionDuration) + (now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration) ) { stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } else if (!isFullSession.get()) { - cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.sessionReplay.errorReplayDuration) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 7cba3bf54a..aaa7200abb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -164,7 +164,7 @@ internal class ScreenshotRecorder( return } - val rootNode = ViewHierarchyNode.fromView(root) + val rootNode = ViewHierarchyNode.fromView(root, options) root.traverse(rootNode) pendingViewHierarchy.set(rootNode) @@ -227,7 +227,7 @@ internal class ScreenshotRecorder( for (i in 0 until childCount) { val child = getChildAt(i) if (child != null) { - val childNode = ViewHierarchyNode.fromView(child) + val childNode = ViewHierarchyNode.fromView(child, options) childNodes.add(childNode) child.traverse(childNode) } @@ -260,7 +260,7 @@ public data class ScreenshotRecorderConfig( fun from( context: Context, - sentryReplayOptions: SentryReplayOptions + 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 @@ -287,8 +287,8 @@ public data class ScreenshotRecorderConfig( recordingHeight = height, scaleFactorX = width.toFloat() / screenBounds.width(), scaleFactorY = height.toFloat() / screenBounds.height(), - frameRate = sentryReplayOptions.frameRate, - bitRate = sentryReplayOptions.bitRate + frameRate = sessionReplay.frameRate, + bitRate = sessionReplay.bitRate ) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 9b6a068f05..3db9731171 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -14,6 +14,7 @@ import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView import android.widget.TextView +import io.sentry.SentryOptions // TODO: merge with ViewHierarchyNode from sentry-core maybe? @TargetApi(26) @@ -49,14 +50,14 @@ data class ViewHierarchyNode( // TODO: check if this works on RN private fun Int.toOpaque() = this or 0xFF000000.toInt() - fun fromView(view: View): ViewHierarchyNode { + fun fromView(view: View, options: SentryOptions): ViewHierarchyNode { // TODO: Extract redacting into its own class/function // TODO: extract redacting into a separate thread? var shouldRedact = false var dominantColor: Int? = null var rect: Rect? = null - when (view) { - is TextView -> { + when { + view is TextView && options.experimental.sessionReplay.redactAllText -> { // TODO: API level check // TODO: perhaps this is heavy, might reconsider val nodeInfo = if (VERSION.SDK_INT >= VERSION_CODES.R) { @@ -101,7 +102,7 @@ data class ViewHierarchyNode( } } - is ImageView -> { + view is ImageView && options.experimental.sessionReplay.redactAllImages -> { shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) if (shouldRedact) { rect = Rect() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d3200dc76a..47307d418e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -310,8 +310,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getSessionReplayOptions ()Lio/sentry/SentryReplayOptions; - public fun setSessionReplayOptions (Lio/sentry/SentryReplayOptions;)V + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { @@ -2571,12 +2571,16 @@ public final class io/sentry/SentryReplayOptions { public fun getErrorReplayDuration ()J public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I + 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 setRedactAllImages (Z)V + public fun setRedactAllText (Z)V public fun setSessionSampleRate (Ljava/lang/Double;)V } diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index ebd1adabb2..f587996bd8 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -9,14 +9,14 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); @NotNull - public SentryReplayOptions getSessionReplayOptions() { - return sessionReplayOptions; + public SentryReplayOptions getSessionReplay() { + return sessionReplay; } - public void setSessionReplayOptions(final @NotNull SentryReplayOptions sessionReplayOptions) { - this.sessionReplayOptions = sessionReplayOptions; + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index c8dc7df7a2..fb5848513c 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -20,6 +20,24 @@ public final class SentryReplayOptions { */ 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. Higher bit rates have better replay quality, but * also affect the final payload size to transfer, defaults to 20kbps. @@ -87,6 +105,22 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { 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 int getBitRate() { return bitRate; From 1fc9aa22e31eaffaf8b48dfac8fb73672276894a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 15:53:25 +0200 Subject: [PATCH 079/184] Fix tests --- .../src/test/java/io/sentry/android/replay/ReplayCacheTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 1290689d53..91addc206a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -37,7 +37,7 @@ class ReplayCacheTest { frameRate: Int, framesToEncode: Int = 0 ): ReplayCache { - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate = frameRate, bitRate = 20_000) + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } From ad98acd0e7cc31e2e913854a269ffa8f7709d08c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 16:19:04 +0200 Subject: [PATCH 080/184] Fix tests --- .../replay/video/SimpleVideoEncoder.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 630637bfbf..35d3c90541 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -58,17 +58,22 @@ internal class SimpleVideoEncoder( } private val mediaFormat: MediaFormat by lazy(NONE) { - val videoCapabilities = mediaCodec.codecInfo - .getCapabilitiesForType(muxerConfig.mimeType) - .videoCapabilities - var bitRate = muxerConfig.recorderConfig.bitRate - 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) + + 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() From 227c22a2f9e02b45a0705964b5a980a0316b97cc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 22:22:55 +0200 Subject: [PATCH 081/184] WIP --- .../java/io/sentry/android/replay/ReplayIntegration.kt | 10 +++++++++- .../src/main/AndroidManifest.xml | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d8df5f830a..4b7025352a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -1,6 +1,8 @@ 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 io.sentry.DateUtils @@ -41,7 +43,7 @@ import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider -) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { +) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { internal companion object { private const val TAG = "ReplayIntegration" @@ -392,6 +394,12 @@ class ReplayIntegration( replayExecutor.gracefullyShutdown(options) } + override fun onConfigurationChanged(newConfig: Configuration) { + + } + + override fun onLowMemory() = Unit + private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 23b469ff20..b837cc4f79 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -157,6 +157,7 @@ + From 0fca8ad26641c251aa92ba172fd79ef40caa509c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 22:24:29 +0200 Subject: [PATCH 082/184] Try-catch release of encoder --- .../android/replay/video/SimpleVideoEncoder.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 35d3c90541..7d662b7231 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -221,13 +221,17 @@ internal class SimpleVideoEncoder( } fun release() { - onClose?.invoke() - drainCodec(true) - mediaCodec.stop() - mediaCodec.release() - surface?.release() + try { + onClose?.invoke() + drainCodec(true) + mediaCodec.stop() + mediaCodec.release() + surface?.release() - frameMuxer.release() + frameMuxer.release() + } catch (e: Throwable) { + options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } } } From 5e376226668e2a85812a1aed99b7367c682bba08 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 16:58:52 +0200 Subject: [PATCH 083/184] Support orientation change for session mode --- .../io/sentry/android/replay/ReplayCache.kt | 20 +++- .../android/replay/ReplayIntegration.kt | 104 +++++++++++++----- .../sentry/android/replay/WindowRecorder.kt | 3 +- .../replay/video/SimpleVideoEncoder.kt | 15 ++- .../sentry/android/replay/ReplayCacheTest.kt | 23 ++-- 5 files changed, 118 insertions(+), 47 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index db1f691260..8f8dc97de7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -30,17 +30,23 @@ public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, private val recorderConfig: ScreenshotRecorderConfig, - private val encoderCreator: (File) -> SimpleVideoEncoder + private val encoderCreator: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder ) : Closeable { public constructor( options: SentryOptions, replayId: SentryId, recorderConfig: ScreenshotRecorderConfig - ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile -> + ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> SimpleVideoEncoder( options, - MuxerConfig(file = videoFile, recorderConfig = recorderConfig) + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) ).also { it.start() } }) @@ -113,6 +119,10 @@ public class ReplayCache internal constructor( * @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 @@ -122,6 +132,8 @@ public class ReplayCache internal constructor( duration: Long, from: Long, segmentId: Int, + height: Int, + width: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { if (frames.isEmpty()) { @@ -133,7 +145,7 @@ public class ReplayCache internal constructor( } // TODO: reuse instance of encoder and just change file path to create a different muxer - encoder = synchronized(encoderLock) { encoderCreator(videoFile) } + encoder = synchronized(encoderLock) { encoderCreator(videoFile, height, width) } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 4b7025352a..71aba44e46 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -38,7 +38,6 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference -import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, @@ -69,12 +68,7 @@ class ReplayIntegration( Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - private val recorderConfig by lazy(NONE) { - ScreenshotRecorderConfig.from( - context, - options.experimental.sessionReplay - ) - } + private lateinit var recorderConfig: ScreenshotRecorderConfig private fun sample(rate: Double?): Boolean { if (rate != null) { @@ -107,9 +101,15 @@ class ReplayIntegration( } this.hub = hub - recorder = WindowRecorder(options, recorderConfig, this) + recorder = WindowRecorder(options, 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) @@ -150,9 +150,10 @@ class ReplayIntegration( // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { it.replayId = currentReplayId.get() } } + recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) cache = ReplayCache(options, currentReplayId.get(), recorderConfig) - recorder?.startRecording() + recorder?.startRecording(recorderConfig) // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -174,17 +175,25 @@ class ReplayIntegration( return } - if (isFullSession.get()) { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) return } - if (!(event.isErrored || event.isCrashed)) { - options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + val sampled = sample(options.experimental.sessionReplay.errorSampleRate) + + // only tag event if it's a session mode or buffer mode that got sampled + if (isFullSession.get() || sampled) { + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + } + + if (isFullSession.get()) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) return } - if (!sample(options.experimental.sessionReplay.errorSampleRate)) { + if (!sampled) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } @@ -199,9 +208,11 @@ class ReplayIntegration( } val segmentId = currentSegment.get() val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { val videoDuration = - createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER, hint) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -211,8 +222,6 @@ class ReplayIntegration( } hub?.configureScope { it.replayId = currentReplayId.get() } - // don't ask me why - event.setTag("replayId", currentReplayId.get().toString()) isFullSession.set(true) } @@ -232,9 +241,11 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.pause") { val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -252,10 +263,12 @@ class ReplayIntegration( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.stop") { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) } FileUtils.deleteRecursively(replayCacheDir) } @@ -274,6 +287,8 @@ class ReplayIntegration( // 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?.addFrame(bitmap, frameTimestamp) @@ -290,7 +305,9 @@ class ReplayIntegration( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId + segmentId, + height, + width ) if (videoDuration != null) { currentSegment.getAndIncrement() @@ -313,13 +330,17 @@ class ReplayIntegration( currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, + height: Int, + width: Int, replayType: ReplayType = SESSION, hint: Hint? = null ): Long? { val generatedVideo = cache?.createVideoOf( duration, currentSegmentTimestamp.time, - segmentId + segmentId, + height, + width ) ?: return null val (video, frameCount, videoDuration) = generatedVideo @@ -328,6 +349,8 @@ class ReplayIntegration( replayId, currentSegmentTimestamp, segmentId, + height, + width, frameCount, videoDuration, replayType, @@ -341,6 +364,8 @@ class ReplayIntegration( currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, + height: Int, + width: Int, frameCount: Int, duration: Long, replayType: ReplayType, @@ -363,8 +388,8 @@ class ReplayIntegration( payload = listOf( RRWebMetaEvent().apply { this.timestamp = segmentTimestamp.time - height = recorderConfig.recordingHeight - width = recorderConfig.recordingWidth + this.height = height + this.width = width }, RRWebVideoEvent().apply { this.timestamp = segmentTimestamp.time @@ -373,8 +398,8 @@ class ReplayIntegration( this.frameCount = frameCount size = video.length() frameRate = recorderConfig.frameRate - height = recorderConfig.recordingHeight - width = recorderConfig.recordingWidth + this.height = height + this.width = width // TODO: support non-fullscreen windows later left = 0 top = 0 @@ -390,12 +415,41 @@ class ReplayIntegration( return } + try { + context.unregisterComponentCallbacks(this) + } catch (ignored: Throwable) { + } stop() replayExecutor.gracefullyShutdown(options) } override fun onConfigurationChanged(newConfig: Configuration) { + if (!isEnabled.get()) { + return + } + + recorder?.stopRecording() + + if (isFullSession.get()) { + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { + val videoDuration = + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + } + } + // refresh config based on new device configuration + recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + recorder?.startRecording(recorderConfig) } override fun onLowMemory() = Unit diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index d5e11936de..58c6f15ab5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -17,7 +17,6 @@ import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, - private val recorderConfig: ScreenshotRecorderConfig, private val screenshotRecorderCallback: ScreenshotRecorderCallback ) : Closeable { @@ -50,7 +49,7 @@ internal class WindowRecorder( } } - fun startRecording() { + fun startRecording(recorderConfig: ScreenshotRecorderConfig) { if (isRecording.getAndSet(true)) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 630637bfbf..6f0809edce 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -62,7 +62,7 @@ internal class SimpleVideoEncoder( .getCapabilitiesForType(muxerConfig.mimeType) .videoCapabilities - var bitRate = muxerConfig.recorderConfig.bitRate + var bitRate = muxerConfig.bitRate if (!videoCapabilities.bitrateRange.contains(bitRate)) { options.logger.log( DEBUG, @@ -96,8 +96,8 @@ internal class SimpleVideoEncoder( val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, - muxerConfig.recorderConfig.recordingWidth, - muxerConfig.recorderConfig.recordingHeight + muxerConfig.recordingWidth, + muxerConfig.recordingHeight ) // this allows reducing bitrate on newer devices, where they enforce higher quality in VBR @@ -115,14 +115,14 @@ internal class SimpleVideoEncoder( MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) - format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat()) + 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.recorderConfig.frameRate.toFloat()) + private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.frameRate.toFloat()) val duration get() = frameMuxer.getVideoTime() private var surface: Surface? = null @@ -229,6 +229,9 @@ internal class SimpleVideoEncoder( @TargetApi(24) internal data class MuxerConfig( val file: File, - val recorderConfig: ScreenshotRecorderConfig, + 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/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 91addc206a..1100b484ba 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -41,12 +41,15 @@ class ReplayCacheTest { options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile -> + return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> encoder = SimpleVideoEncoder( options, MuxerConfig( file = videoFile, - recorderConfig = recorderConfig + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate ), onClose = { encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) @@ -107,7 +110,7 @@ class ReplayCacheTest { frameRate = 1 ) - val video = replayCache.createVideoOf(5000L, 0, 0) + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200) assertNull(video) } @@ -125,7 +128,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 2001) - val segment0 = replayCache.createVideoOf(3000L, 0, 0) + 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 } @@ -146,7 +149,7 @@ class ReplayCacheTest { val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0) + 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 } @@ -165,7 +168,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 3001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0) + 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 } @@ -184,12 +187,12 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 5001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0) + 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) + 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 } @@ -209,7 +212,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 1501) - val segment0 = replayCache.createVideoOf(3000L, 0, 0) + 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 } @@ -235,7 +238,7 @@ class ReplayCacheTest { } replayCache.addFrame(screenshot, frameTimestamp = 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, videoFile = video) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) From d0b4d5ced75983b3c3086e0ce813d9dd07b809ed Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 22:17:46 +0200 Subject: [PATCH 084/184] WIP --- .../android/replay/ReplayIntegration.kt | 228 ++---------------- .../replay/capture/BaseCaptureStrategy.kt | 181 ++++++++++++++ .../replay/capture/BufferCaptureStrategy.kt | 103 ++++++++ .../android/replay/capture/CaptureStrategy.kt | 25 ++ .../replay/capture/SessionCaptureStrategy.kt | 73 ++++++ .../io/sentry/android/replay/util/Sampling.kt | 10 + 6 files changed, 410 insertions(+), 210 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 71aba44e46..1c7bd7a449 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -20,7 +20,11 @@ import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION +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.gracefullyShutdown +import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent @@ -32,51 +36,26 @@ import java.io.Closeable import java.io.File import java.security.SecureRandom import java.util.Date -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider ) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { - internal companion object { - private const val TAG = "ReplayIntegration" - } - private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: WindowRecorder? = null - private var cache: ReplayCache? = null private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here private val isFullSession = AtomicBoolean(false) private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) - private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) - private val segmentTimestamp = AtomicReference() - private val replayStartTimestamp = AtomicLong() - private val currentSegment = AtomicInteger(0) - - // TODO: surround with try-catch on the calling site - private val replayExecutor by lazy { - Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) - } + private var captureStrategy: CaptureStrategy? = null private lateinit var recorderConfig: ScreenshotRecorderConfig - private fun sample(rate: Double?): Boolean { - if (rate != null) { - return !(rate < random.nextDouble()) // bad luck - } - return false - } - override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -92,7 +71,7 @@ class ReplayIntegration( return } - isFullSession.set(sample(options.experimental.sessionReplay.sessionSampleRate)) + isFullSession.set(random.sample(options.experimental.sessionReplay.sessionSampleRate)) if (!isFullSession.get() && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { @@ -103,6 +82,11 @@ class ReplayIntegration( this.hub = hub recorder = WindowRecorder(options, this) isEnabled.set(true) + captureStrategy = if (isFullSession.get()) { + SessionCaptureStrategy(options, hub, dateProvider) + } else { + BufferCaptureStrategy(options, hub, dateProvider, random) + } try { context.registerComponentCallbacks(this) @@ -131,33 +115,9 @@ class ReplayIntegration( return } - currentSegment.set(0) - currentReplayId.set(SentryId()) - 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 - } - } - } - if (isFullSession.get()) { - // 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() } - } recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) - cache = ReplayCache(options, currentReplayId.get(), recorderConfig) - + captureStrategy?.start() recorder?.startRecording(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() { @@ -165,8 +125,7 @@ class ReplayIntegration( return } - // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + captureStrategy?.resume() recorder?.resume() } @@ -180,49 +139,9 @@ class ReplayIntegration( return } - val sampled = sample(options.experimental.sessionReplay.errorSampleRate) - - // only tag event if it's a session mode or buffer mode that got sampled - if (isFullSession.get() || sampled) { - // don't ask me why - event.setTag("replayId", currentReplayId.get().toString()) - } - - if (isFullSession.get()) { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) - return - } - - if (!sampled) { - options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.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 - replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { - val videoDuration = - createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER, hint) - if (videoDuration != null) { - currentSegment.getAndIncrement() - } - // since we're switching to session mode, even if the video is not sent for an error - // we still set the timestamp to now, because session is technically started "now" - segmentTimestamp.set(DateUtils.getDateTime(now)) - } - - hub?.configureScope { it.replayId = currentReplayId.get() } + captureStrategy?.sendReplayForEvent(event, hint) isFullSession.set(true) + captureStrategy = captureStrategy?.convert() } override fun pause() { @@ -230,26 +149,8 @@ class ReplayIntegration( return } - val now = dateProvider.currentTimeMillis recorder?.pause() - - if (!isFullSession.get()) { - return - } - - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "$TAG.pause") { - val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) - if (videoDuration != null) { - currentSegment.getAndIncrement() - } - } + captureStrategy?.pause() } override fun stop() { @@ -325,91 +226,6 @@ class ReplayIntegration( } } - private fun createAndCaptureSegment( - duration: Long, - currentSegmentTimestamp: Date, - replayId: SentryId, - segmentId: Int, - height: Int, - width: Int, - replayType: ReplayType = SESSION, - hint: Hint? = null - ): Long? { - val generatedVideo = cache?.createVideoOf( - duration, - currentSegmentTimestamp.time, - segmentId, - height, - width - ) ?: return null - - val (video, frameCount, videoDuration) = generatedVideo - captureReplay( - video, - replayId, - currentSegmentTimestamp, - segmentId, - height, - width, - frameCount, - videoDuration, - replayType, - hint - ) - return videoDuration - } - - private fun captureReplay( - video: File, - currentReplayId: SentryId, - segmentTimestamp: Date, - segmentId: Int, - height: Int, - width: Int, - frameCount: Int, - duration: Long, - replayType: ReplayType, - hint: Hint? = null - ) { - val replay = SentryReplayEvent().apply { - eventId = currentReplayId - replayId = currentReplayId - this.segmentId = segmentId - this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) - if (segmentId == 0) { - replayStartTimestamp = segmentTimestamp - } - this.replayType = replayType - videoFile = video - } - - val recording = ReplayRecording().apply { - this.segmentId = segmentId - payload = listOf( - RRWebMetaEvent().apply { - this.timestamp = segmentTimestamp.time - this.height = height - this.width = width - }, - 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 - } - ) - } - - hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording }) - } - override fun close() { if (!isEnabled.get()) { return @@ -420,7 +236,8 @@ class ReplayIntegration( } catch (ignored: Throwable) { } stop() - replayExecutor.gracefullyShutdown(options) + captureStrategy?.close() + captureStrategy = null } override fun onConfigurationChanged(newConfig: Configuration) { @@ -453,13 +270,4 @@ class ReplayIntegration( } override fun onLowMemory() = Unit - - private class ReplayExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayIntegration-" + cnt++) - ret.setDaemon(true) - return ret - } - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt new file mode 100644 index 0000000000..751d679b80 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -0,0 +1,181 @@ +package io.sentry.android.replay.capture + +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.ReplayRecording +import io.sentry.SentryEvent +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.ReplayIntegration +import io.sentry.android.replay.ReplayIntegration.Companion +import io.sentry.android.replay.ReplayIntegration.ReplayExecutorServiceThreadFactory +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +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.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +abstract class BaseCaptureStrategy( + private val options: SentryOptions, + private val dateProvider: ICurrentDateProvider +) : CaptureStrategy { + + internal companion object { + private const val TAG = "CaptureStrategy" + } + + protected var cache: ReplayCache? = null + protected val currentReplayId = AtomicReference(SentryId.EMPTY_ID) + protected val segmentTimestamp = AtomicReference() + protected val replayStartTimestamp = AtomicLong() + protected val currentSegment = AtomicInteger(0) + + protected val replayExecutor by lazy { + 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 = ReplayCache(options, currentReplayId.get(), 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()) + } + + protected fun createAndCaptureSegment( + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType = SESSION, + hint: Hint? = null + ): Long? { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return null + + val (video, frameCount, videoDuration) = generatedVideo + captureReplay( + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + videoDuration, + replayType, + hint + ) + return videoDuration + } + + private fun captureReplay( + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + duration: Long, + replayType: ReplayType, + hint: Hint? = null + ) { + val replay = SentryReplayEvent().apply { + eventId = currentReplayId + replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) + if (segmentId == 0) { + replayStartTimestamp = segmentTimestamp + } + this.replayType = replayType + videoFile = video + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = listOf( + RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + }, + 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 + } + ) + } + +// hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording }) + } + + override fun close() { + replayExecutor.gracefullyShutdown(options) + } + + private class ReplayExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayIntegration-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} 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 0000000000..0816acc6f7 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -0,0 +1,103 @@ +package io.sentry.android.replay.capture + +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryEvent +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +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 java.security.SecureRandom +import java.util.concurrent.TimeUnit.MILLISECONDS + +class BufferCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + private val random: SecureRandom +) : BaseCaptureStrategy(options, dateProvider) { + + internal companion object { + private const val TAG = "BufferCaptureStrategy" + } + + override fun stop() { + TODO("Not yet implemented") + } + + override fun pause() { + TODO("Not yet implemented") + } + + override fun resume() { + TODO("Not yet implemented") + } + + override fun isRecording(): Boolean { + TODO("Not yet implemented") + } + + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) + + if (sampled) { + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + } else { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.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 + try { + replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { + val videoDuration = + createAndCaptureSegment( + now - currentSegmentTimestamp.time, + currentSegmentTimestamp, + replayId, + segmentId, + height, + width, + BUFFER, + hint + ) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + }?.get(options.flushTimeoutMillis, MILLISECONDS) + } catch (e: Throwable) { + options.logger.log(INFO, "$TAG.send_replay_for_event future failed", e) + } + } + + override fun onConfigurationChanged() { + TODO("Not yet implemented") + } + + override fun convert(): CaptureStrategy { + cache?.close() + val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider) + captureStrategy.start(segmentId = currentSegment.get(), replayId = currentReplayId.get(), cleanupOldReplays = false) + return captureStrategy + } + + override fun close() { + TODO("Not yet implemented") + } +} 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 0000000000..b5be66198c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -0,0 +1,25 @@ +package io.sentry.android.replay.capture + +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.protocol.SentryId + +interface CaptureStrategy { + fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + + fun stop() + + fun pause() + + fun resume() + + fun isRecording(): Boolean + + fun sendReplayForEvent(event: SentryEvent, hint: Hint) + + fun onConfigurationChanged() + + 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 0000000000..ba65e764fc --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -0,0 +1,73 @@ +package io.sentry.android.replay.capture + +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryEvent +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryOptions +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider + +class SessionCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider +) : BaseCaptureStrategy(options, dateProvider) { + + 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() } + } + + override fun stop() { + TODO("Not yet implemented") + } + + override fun pause() { + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.pause") { + val videoDuration = + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + } + } + + override fun resume() { + super.resume() + } + + override fun isRecording(): Boolean { + TODO("Not yet implemented") + } + + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + } + + override fun onConfigurationChanged() { + TODO("Not yet implemented") + } + + override fun convert(): CaptureStrategy = this + + override fun close() { + TODO("Not yet implemented") + } +} 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 0000000000..a9e39ee438 --- /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 + +fun SecureRandom.sample(rate: Double?): Boolean { + if (rate != null) { + return !(rate < this.nextDouble()) // bad luck + } + return false +} From 236ee2c9c7140b57f8f7d9dfbfdd2a95b7f9a625 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 22:22:16 +0200 Subject: [PATCH 085/184] Spotless --- sentry-android-replay/api/sentry-android-replay.api | 8 +++++--- .../io/sentry/android/replay/video/SimpleVideoEncoder.kt | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 53e9771e13..582210ab90 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -25,15 +25,17 @@ 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 (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; - public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + 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 : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V 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 pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 0721986ad0..54a3bc1f89 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -37,7 +37,6 @@ import android.media.MediaFormat import android.view.Surface import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions -import io.sentry.android.replay.ScreenshotRecorderConfig import java.io.File import java.nio.ByteBuffer import kotlin.LazyThreadSafetyMode.NONE From e9bf0b392728cae058e8430aa8b3ee6b546fef2b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 22:34:24 +0200 Subject: [PATCH 086/184] TODO --- .../src/main/java/io/sentry/android/replay/ReplayIntegration.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 71aba44e46..914376f7e5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -430,6 +430,7 @@ class ReplayIntegration( recorder?.stopRecording() + // TODO: support buffer mode and breadcrumb/rrweb_event if (isFullSession.get()) { val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp.get() From 71837c18d77c90be4f91f2f81f949d7961a784e7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 12:04:19 +0200 Subject: [PATCH 087/184] Update sentry/src/main/java/io/sentry/SentryReplayOptions.java Co-authored-by: Markus Hintersteiner --- sentry/src/main/java/io/sentry/SentryReplayOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index c8dc7df7a2..3613061572 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -22,7 +22,7 @@ public final class SentryReplayOptions { /** * 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 20kbps. + * also affect the final payload size to transfer, defaults to 100kbps. */ private int bitRate = 100_000; From 59b63e06df037e0bf2eb9ab7d8ab7285f0f90b21 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 12:36:46 +0200 Subject: [PATCH 088/184] More gates --- .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++-------- .../java/io/sentry/android/replay/Windows.kt | 17 ++++++++--------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 914376f7e5..eee0398804 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -161,7 +161,7 @@ class ReplayIntegration( } override fun resume() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -171,7 +171,7 @@ class ReplayIntegration( } override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -182,8 +182,9 @@ class ReplayIntegration( val sampled = sample(options.experimental.sessionReplay.errorSampleRate) + val replayId = currentReplayId.get() // only tag event if it's a session mode or buffer mode that got sampled - if (isFullSession.get() || sampled) { + if (!replayId.equals(SentryId.EMPTY_ID) && (isFullSession.get() || sampled)) { // don't ask me why event.setTag("replayId", currentReplayId.get().toString()) } @@ -207,7 +208,6 @@ class ReplayIntegration( DateUtils.getDateTime(now - errorReplayDuration) } val segmentId = currentSegment.get() - val replayId = currentReplayId.get() val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { @@ -221,12 +221,12 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(now)) } - hub?.configureScope { it.replayId = currentReplayId.get() } + hub?.configureScope { it.replayId = replayId } isFullSession.set(true) } override fun pause() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -253,7 +253,7 @@ class ReplayIntegration( } override fun stop() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -424,7 +424,7 @@ class ReplayIntegration( } override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index e9c6761c75..98badf4ff7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -136,18 +136,17 @@ internal fun interface OnRootViewsChangedListener { */ internal class RootViewsSpy private constructor() { - val listeners = CopyOnWriteArrayList() - - private val delegatingViewList = object : ArrayList() { - override fun addAll(elements: Collection): Boolean { - listeners.forEach { listener -> - elements.forEach { element -> - listener.onRootViewsChanged(element, true) - } + 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.addAll(elements) + return super.add(element) } + } + private val delegatingViewList: ArrayList = object : ArrayList() { override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } return super.add(element) From 21239ca9fb29e8c1e9e3e502a78ea9ce8db6f2fc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 13:17:24 +0200 Subject: [PATCH 089/184] Revert addAll --- .../src/main/java/io/sentry/android/replay/Windows.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 98badf4ff7..8ef595f193 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -147,6 +147,15 @@ internal class RootViewsSpy private constructor() { } 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) From 9cafe4365102fc66327b396a25a939840d711524 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 14:14:24 +0200 Subject: [PATCH 090/184] Fix conflicts --- sentry/api/sentry.api | 6 ++++-- sentry/src/main/java/io/sentry/JsonObjectReader.java | 1 + sentry/src/main/java/io/sentry/ObjectReader.java | 3 +++ sentry/src/main/java/io/sentry/protocol/Geo.java | 3 ++- .../src/main/java/io/sentry/protocol/MetricSummary.java | 4 ++-- sentry/src/main/java/io/sentry/util/MapObjectReader.java | 6 ++++++ .../src/test/java/io/sentry/util/MapObjectReaderTest.kt | 8 ++++++++ 7 files changed, 26 insertions(+), 5 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c78e9f73da..02c49a4ef0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1451,6 +1451,7 @@ public abstract interface class io/sentry/ObjectReader : java/io/Closeable { 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 @@ -4346,8 +4347,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 { @@ -5406,6 +5407,7 @@ public final class io/sentry/util/MapObjectReader : io/sentry/ObjectReader { 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 diff --git a/sentry/src/main/java/io/sentry/JsonObjectReader.java b/sentry/src/main/java/io/sentry/JsonObjectReader.java index c348b533e2..f9fe184184 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectReader.java +++ b/sentry/src/main/java/io/sentry/JsonObjectReader.java @@ -137,6 +137,7 @@ public void nextUnknown(ILogger logger, Map unknown, String name return map; } + @Override public @Nullable Map> nextMapOfListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java index 6c2210897e..6ea43926b0 100644 --- a/sentry/src/main/java/io/sentry/ObjectReader.java +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -36,6 +36,9 @@ public interface ObjectReader extends Closeable { @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; diff --git a/sentry/src/main/java/io/sentry/protocol/Geo.java b/sentry/src/main/java/io/sentry/protocol/Geo.java index 6042b72d1d..c9094223ab 100644 --- a/sentry/src/main/java/io/sentry/protocol/Geo.java +++ b/sentry/src/main/java/io/sentry/protocol/Geo.java @@ -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(@NotNull ObjectReader 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/MetricSummary.java b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java index db4f0b6ba5..f4a8b6de53 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/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index 6cb05989b2..cd44ee0777 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -53,6 +53,12 @@ public Map nextMapOrNull( return nextValueOrNull(); } + @Override + public @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { + return nextValueOrNull(); + } + @Nullable @Override public T nextOrNull( diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt index ab52919c43..06124c88e7 100644 --- a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -68,6 +68,7 @@ class MapObjectReaderTest { 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")) @@ -90,6 +91,13 @@ class MapObjectReaderTest { 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() From 6c9baea53d35f3286ff9e0eeefa789df1dd69c82 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 14:38:11 +0200 Subject: [PATCH 091/184] fix test --- .../src/test/java/io/sentry/android/core/SentryAndroidTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 21f09c9aff..4f56f6a024 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 @@ -429,7 +429,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 || From 2057e2203798cec104cd907662d4fc8ec89f82ef Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 9 Apr 2024 13:39:06 +0000 Subject: [PATCH 092/184] release: 7.8.0-alpha.0 --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1eb550a1..8daa1ef48d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 7.8.0-alpha.0 + +- No documented changes. + ## 7.8.0 ### Features diff --git a/gradle.properties b/gradle.properties index cb85c1fd8b..98b65810b4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.8.0 +versionName=7.8.0-alpha.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From f8f56989f7f44f818348d96bfe9faf57d1d72ce8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Apr 2024 14:46:31 +0200 Subject: [PATCH 093/184] Introduce CaptureStrategy for buffer and session modes --- .../android/replay/ReplayIntegration.kt | 131 +++------------ .../sentry/android/replay/WindowRecorder.kt | 4 +- .../replay/capture/BaseCaptureStrategy.kt | 77 ++++++--- .../replay/capture/BufferCaptureStrategy.kt | 154 +++++++++++++----- .../android/replay/capture/CaptureStrategy.kt | 13 +- .../replay/capture/SessionCaptureStrategy.kt | 129 +++++++++++---- 6 files changed, 301 insertions(+), 207 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 94764436ce..4889f383c8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -5,37 +5,24 @@ import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build -import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.Integration import io.sentry.ReplayController -import io.sentry.ReplayRecording 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.SentryReplayEvent -import io.sentry.SentryReplayEvent.ReplayType -import io.sentry.SentryReplayEvent.ReplayType.BUFFER -import io.sentry.SentryReplayEvent.ReplayType.SESSION 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.gracefullyShutdown import io.sentry.android.replay.util.sample -import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId -import io.sentry.rrweb.RRWebMetaEvent -import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider -import io.sentry.util.FileUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable -import java.io.File import java.security.SecureRandom -import java.util.Date import java.util.concurrent.atomic.AtomicBoolean class ReplayIntegration( @@ -49,7 +36,6 @@ class ReplayIntegration( private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here - private val isFullSession = AtomicBoolean(false) private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null @@ -71,22 +57,9 @@ class ReplayIntegration( return } - isFullSession.set(random.sample(options.experimental.sessionReplay.sessionSampleRate)) - if (!isFullSession.get() && - !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled - ) { - options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") - return - } - this.hub = hub recorder = WindowRecorder(options, this) isEnabled.set(true) - captureStrategy = if (isFullSession.get()) { - SessionCaptureStrategy(options, hub, dateProvider) - } else { - BufferCaptureStrategy(options, hub, dateProvider, random) - } try { context.registerComponentCallbacks(this) @@ -115,7 +88,19 @@ class ReplayIntegration( 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 = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy = if (isFullSession) { + SessionCaptureStrategy(options, hub, dateProvider, recorderConfig) + } else { + BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random) + } + captureStrategy?.start() recorder?.startRecording(recorderConfig) } @@ -139,8 +124,12 @@ class ReplayIntegration( return } - captureStrategy?.sendReplayForEvent(event, hint) - isFullSession.set(true) + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) { + options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", event.eventId) + return + } + + captureStrategy?.sendReplayForEvent(event, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement()}) captureStrategy = captureStrategy?.convert() } @@ -158,72 +147,14 @@ class ReplayIntegration( return } - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() - val replayCacheDir = cache?.replayCacheDir - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "$TAG.stop") { - // we don't flush the segment, but we still wanna clean up the folder for buffer mode - if (isFullSession.get()) { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) - } - FileUtils.deleteRecursively(replayCacheDir) - } - recorder?.stopRecording() - cache?.close() - currentSegment.set(0) - replayStartTimestamp.set(0) - segmentTimestamp.set(null) - currentReplayId.set(SentryId.EMPTY_ID) - hub?.configureScope { it.replayId = SentryId.EMPTY_ID } + captureStrategy?.stop() isRecording.set(false) + captureStrategy = null } override fun onScreenshotRecorded(bitmap: Bitmap) { - // 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?.addFrame(bitmap, frameTimestamp) - - val now = dateProvider.currentTimeMillis - if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration) - ) { - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val replayId = currentReplayId.get() - - val videoDuration = - createAndCaptureSegment( - options.experimental.sessionReplay.sessionSegmentDuration, - currentSegmentTimestamp, - replayId, - segmentId, - height, - width - ) - if (videoDuration != null) { - currentSegment.getAndIncrement() - // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) - } - } else if (isFullSession.get() && - (now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration) - ) { - stop() - options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") - } else if (!isFullSession.get()) { - cache?.rotate(now - options.experimental.sessionReplay.errorReplayDuration) - } - } + captureStrategy?.onScreenshotRecorded(bitmap) } override fun close() { @@ -247,26 +178,10 @@ class ReplayIntegration( recorder?.stopRecording() - // TODO: support buffer mode and breadcrumb/rrweb_event - if (isFullSession.get()) { - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { - val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) - if (videoDuration != null) { - currentSegment.getAndIncrement() - } - } - } - // refresh config based on new device configuration recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy?.onConfigurationChanged(recorderConfig) + recorder?.startRecording(recorderConfig) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 58c6f15ab5..83a49199ef 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -32,7 +32,9 @@ internal class WindowRecorder( private val rootViews = ArrayList>() private var recorder: ScreenshotRecorder? = null private var capturingTask: ScheduledFuture<*>? = null - private val capturer = Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + private val capturer by lazy { + Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + } private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 751d679b80..91a8270d5e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -2,16 +2,14 @@ package io.sentry.android.replay.capture import io.sentry.DateUtils import io.sentry.Hint +import io.sentry.IHub import io.sentry.ReplayRecording -import io.sentry.SentryEvent 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.ReplayIntegration -import io.sentry.android.replay.ReplayIntegration.Companion -import io.sentry.android.replay.ReplayIntegration.ReplayExecutorServiceThreadFactory +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 @@ -22,6 +20,7 @@ import io.sentry.util.FileUtils import java.io.File import java.util.Date 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 @@ -29,7 +28,9 @@ import java.util.concurrent.atomic.AtomicReference abstract class BaseCaptureStrategy( private val options: SentryOptions, - private val dateProvider: ICurrentDateProvider + private val dateProvider: ICurrentDateProvider, + protected var recorderConfig: ScreenshotRecorderConfig, + executor: ScheduledExecutorService? = null ) : CaptureStrategy { internal companion object { @@ -37,13 +38,13 @@ abstract class BaseCaptureStrategy( } protected var cache: ReplayCache? = null - protected val currentReplayId = AtomicReference(SentryId.EMPTY_ID) protected val segmentTimestamp = AtomicReference() protected val replayStartTimestamp = AtomicLong() - protected val currentSegment = AtomicInteger(0) + override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) + override val currentSegment = AtomicInteger(0) - protected val replayExecutor by lazy { - Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + protected val replayExecutor: ScheduledExecutorService by lazy { + executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { @@ -81,7 +82,17 @@ abstract class BaseCaptureStrategy( segmentTimestamp.set(DateUtils.getCurrentDateTime()) } - protected fun createAndCaptureSegment( + 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, @@ -89,18 +100,17 @@ abstract class BaseCaptureStrategy( height: Int, width: Int, replayType: ReplayType = SESSION, - hint: Hint? = null - ): Long? { + ): ReplaySegment { val generatedVideo = cache?.createVideoOf( duration, currentSegmentTimestamp.time, segmentId, height, width - ) ?: return null + ) ?: return ReplaySegment.Failed val (video, frameCount, videoDuration) = generatedVideo - captureReplay( + return buildReplay( video, replayId, currentSegmentTimestamp, @@ -110,12 +120,10 @@ abstract class BaseCaptureStrategy( frameCount, videoDuration, replayType, - hint ) - return videoDuration } - private fun captureReplay( + private fun buildReplay( video: File, currentReplayId: SentryId, segmentTimestamp: Date, @@ -125,16 +133,13 @@ abstract class BaseCaptureStrategy( frameCount: Int, duration: Long, replayType: ReplayType, - hint: Hint? = null - ) { + ): ReplaySegment { val replay = SentryReplayEvent().apply { eventId = currentReplayId replayId = currentReplayId this.segmentId = segmentId this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) - if (segmentId == 0) { - replayStartTimestamp = segmentTimestamp - } + replayStartTimestamp = segmentTimestamp this.replayType = replayType videoFile = video } @@ -163,7 +168,11 @@ abstract class BaseCaptureStrategy( ) } -// hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording }) + return ReplaySegment.Created(videoDuration = duration, replay = replay, recording = recording) + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + this.recorderConfig = recorderConfig } override fun close() { @@ -178,4 +187,26 @@ abstract class BaseCaptureStrategy( 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 + } + } + } + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 0816acc6f7..9d1fc4c81b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -1,47 +1,45 @@ package io.sentry.android.replay.capture +import android.graphics.Bitmap import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.SentryEvent +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.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 -import java.util.concurrent.TimeUnit.MILLISECONDS class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - private val random: SecureRandom -) : BaseCaptureStrategy(options, dateProvider) { + recorderConfig: ScreenshotRecorderConfig, + private val random: SecureRandom, +) : BaseCaptureStrategy(options, dateProvider, recorderConfig) { + + private val bufferedSegments = mutableListOf() internal companion object { private const val TAG = "BufferCaptureStrategy" } override fun stop() { - TODO("Not yet implemented") - } - - override fun pause() { - TODO("Not yet implemented") - } - - override fun resume() { - TODO("Not yet implemented") - } - - override fun isRecording(): Boolean { - TODO("Not yet implemented") + val replayCacheDir = cache?.replayCacheDir + replayExecutor.submitSafely(options, "${TAG}.stop") { + FileUtils.deleteRecursively(replayCacheDir) + } + super.stop() } - override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) { val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) if (sampled) { @@ -64,40 +62,110 @@ class BufferCaptureStrategy( val replayId = currentReplayId.get() val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth - try { - replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { - val videoDuration = - createAndCaptureSegment( - now - currentSegmentTimestamp.time, - currentSegmentTimestamp, - replayId, - segmentId, - height, - width, - BUFFER, - hint - ) - if (videoDuration != null) { - currentSegment.getAndIncrement() + 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) + + // 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(bitmap: Bitmap) { + // 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?.addFrame(bitmap, 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) } - }?.get(options.flushTimeoutMillis, MILLISECONDS) + } + } + } + + 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(INFO, "$TAG.send_replay_for_event future failed", e) + options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) } } - override fun onConfigurationChanged() { - TODO("Not yet implemented") + 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 { - cache?.close() - val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider) + // 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 close() { - TODO("Not yet implemented") - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index b5be66198c..c9738569d8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -1,10 +1,17 @@ package io.sentry.android.replay.capture +import android.graphics.Bitmap import io.sentry.Hint import io.sentry.SentryEvent +import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference interface CaptureStrategy { + val currentSegment: AtomicInteger + val currentReplayId: AtomicReference + fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) fun stop() @@ -13,11 +20,11 @@ interface CaptureStrategy { fun resume() - fun isRecording(): Boolean + fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) - fun sendReplayForEvent(event: SentryEvent, hint: Hint) + fun onScreenshotRecorded(bitmap: Bitmap) - fun onConfigurationChanged() + fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) fun convert(): CaptureStrategy diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index ba65e764fc..ac5d0a53d5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -1,19 +1,27 @@ package io.sentry.android.replay.capture +import android.graphics.Bitmap +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.SentryEvent import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +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 class SessionCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, - private val dateProvider: ICurrentDateProvider -) : BaseCaptureStrategy(options, dateProvider) { + private val dateProvider: ICurrentDateProvider, + recorderConfig: ScreenshotRecorderConfig, + executor: ScheduledExecutorService? = null +) : BaseCaptureStrategy(options, dateProvider, recorderConfig, executor) { internal companion object { private const val TAG = "SessionCaptureStrategy" @@ -26,48 +34,111 @@ class SessionCaptureStrategy( hub?.configureScope { it.replayId = currentReplayId.get() } } - override fun stop() { - TODO("Not yet implemented") - } - override fun pause() { - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "$TAG.pause") { - val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) - if (videoDuration != null) { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + currentSegment.getAndIncrement() } } + super.pause() } - override fun resume() { - super.resume() - } - - override fun isRecording(): Boolean { - TODO("Not yet implemented") + 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(event: SentryEvent, hint: Hint) { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) { // don't ask me why event.setTag("replayId", currentReplayId.get().toString()) - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + if (!event.isCrashed) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + } else { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, capturing last segment for crashed event %s", event.eventId) + createCurrentSegment("send_replay_for_event") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub, hint) + } + } + } + } + + override fun onScreenshotRecorded(bitmap: Bitmap) { + // 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?.addFrame(bitmap, 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() { - TODO("Not yet implemented") + 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 - override fun close() { - TODO("Not yet implemented") + 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 + 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) + } } } From 5c59bf755d0bf49322729dda910da12cf5a01771 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Apr 2024 15:05:29 +0200 Subject: [PATCH 094/184] Formatting --- .../io/sentry/android/replay/ReplayIntegration.kt | 2 +- .../android/replay/capture/BaseCaptureStrategy.kt | 12 ++++++------ .../android/replay/capture/BufferCaptureStrategy.kt | 8 ++++---- .../sentry/android/replay/capture/CaptureStrategy.kt | 2 +- .../android/replay/capture/SessionCaptureStrategy.kt | 2 +- .../java/io/sentry/android/replay/util/Sampling.kt | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 4889f383c8..b5cece07c1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -129,7 +129,7 @@ class ReplayIntegration( return } - captureStrategy?.sendReplayForEvent(event, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement()}) + captureStrategy?.sendReplayForEvent(event, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) captureStrategy = captureStrategy?.convert() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 91a8270d5e..1aefae1a39 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -26,7 +26,7 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference -abstract class BaseCaptureStrategy( +internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val dateProvider: ICurrentDateProvider, protected var recorderConfig: ScreenshotRecorderConfig, @@ -52,7 +52,7 @@ abstract class BaseCaptureStrategy( currentReplayId.set(replayId) if (cleanupOldReplays) { - replayExecutor.submitSafely(options, "${TAG}.replays_cleanup") { + replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { // clean up old replays options.cacheDirPath?.let { cacheDir -> File(cacheDir).listFiles { dir, name -> @@ -99,7 +99,7 @@ abstract class BaseCaptureStrategy( segmentId: Int, height: Int, width: Int, - replayType: ReplayType = SESSION, + replayType: ReplayType = SESSION ): ReplaySegment { val generatedVideo = cache?.createVideoOf( duration, @@ -119,7 +119,7 @@ abstract class BaseCaptureStrategy( width, frameCount, videoDuration, - replayType, + replayType ) } @@ -132,7 +132,7 @@ abstract class BaseCaptureStrategy( width: Int, frameCount: Int, duration: Long, - replayType: ReplayType, + replayType: ReplayType ): ReplaySegment { val replay = SentryReplayEvent().apply { eventId = currentReplayId @@ -194,7 +194,7 @@ abstract class BaseCaptureStrategy( val videoDuration: Long, val replay: SentryReplayEvent, val recording: ReplayRecording - ): ReplaySegment() { + ) : ReplaySegment() { fun capture(hub: IHub?, hint: Hint = Hint()) { hub?.captureReplay(replay, hint.apply { replayRecording = recording }) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 9d1fc4c81b..9e4f027ba9 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -17,12 +17,12 @@ import io.sentry.util.FileUtils import java.io.File import java.security.SecureRandom -class BufferCaptureStrategy( +internal class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, - private val random: SecureRandom, + private val random: SecureRandom ) : BaseCaptureStrategy(options, dateProvider, recorderConfig) { private val bufferedSegments = mutableListOf() @@ -33,7 +33,7 @@ class BufferCaptureStrategy( override fun stop() { val replayCacheDir = cache?.replayCacheDir - replayExecutor.submitSafely(options, "${TAG}.stop") { + replayExecutor.submitSafely(options, "$TAG.stop") { FileUtils.deleteRecursively(replayCacheDir) } super.stop() @@ -150,7 +150,7 @@ class BufferCaptureStrategy( val replayId = currentReplayId.get() val height = this.recorderConfig.recordingHeight val width = this.recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "${TAG}.onConfigurationChanged") { + replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { val segment = createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) if (segment is ReplaySegment.Created) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index c9738569d8..8e0e47dcf8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -8,7 +8,7 @@ import io.sentry.protocol.SentryId import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference -interface CaptureStrategy { +internal interface CaptureStrategy { val currentSegment: AtomicInteger val currentReplayId: AtomicReference diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index ac5d0a53d5..0d06823803 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -15,7 +15,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.util.concurrent.ScheduledExecutorService -class SessionCaptureStrategy( +internal class SessionCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, 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 index a9e39ee438..8acb6b00a6 100644 --- 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 @@ -2,7 +2,7 @@ package io.sentry.android.replay.util import java.security.SecureRandom -fun SecureRandom.sample(rate: Double?): Boolean { +internal fun SecureRandom.sample(rate: Double?): Boolean { if (rate != null) { return !(rate < this.nextDouble()) // bad luck } From 247f1c9848083ccf90ace6cafbe9655d26e254e3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 09:07:25 +0200 Subject: [PATCH 095/184] WIP --- .../replay/capture/BaseCaptureStrategy.kt | 62 ++-- .../replay/capture/BufferCaptureStrategy.kt | 2 +- .../replay/capture/SessionCaptureStrategy.kt | 2 +- .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 293 ++++++++++++++++++ 4 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 1aefae1a39..6004576e8c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -13,6 +13,8 @@ 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.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider @@ -28,6 +30,7 @@ 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 @@ -134,38 +137,55 @@ internal abstract class BaseCaptureStrategy( 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 = DateUtils.getDateTime(segmentTimestamp.time + duration) + this.timestamp = endTimestamp replayStartTimestamp = segmentTimestamp this.replayType = replayType videoFile = video } - val recording = ReplayRecording().apply { + 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 - payload = listOf( - RRWebMetaEvent().apply { - this.timestamp = segmentTimestamp.time - this.height = height - this.width = width - }, - 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 + 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 + } + + hub?.configureScope { scope -> + scope.breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before(endTimestamp)) { + recordingPayload += RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time + breadcrumbType = breadcrumb.type + category = breadcrumb.category + message = breadcrumb.message + data = breadcrumb.data + } } - ) + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = recordingPayload } return ReplaySegment.Created(videoDuration = duration, replay = replay, recording = recording) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 9e4f027ba9..e7cb39f48a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -23,7 +23,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, private val random: SecureRandom -) : BaseCaptureStrategy(options, dateProvider, recorderConfig) { +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig) { private val bufferedSegments = mutableListOf() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 0d06823803..5e32b57b6f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -21,7 +21,7 @@ internal class SessionCaptureStrategy( private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null -) : BaseCaptureStrategy(options, dateProvider, recorderConfig, executor) { +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor) { internal companion object { private const val TAG = "SessionCaptureStrategy" 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 0000000000..d22848f2c9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -0,0 +1,293 @@ +package io.sentry.rrweb; + +import io.sentry.Breadcrumb; +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.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 long breadcrumbTimestamp; + private @Nullable String breadcrumbType; + private @Nullable String category; + private @Nullable String message; + 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 long getBreadcrumbTimestamp() { + return breadcrumbTimestamp; + } + + public void setBreadcrumbTimestamp(final long 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 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"; + } + + @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(breadcrumbTimestamp); + if (category != null) { + writer.name(JsonKeys.CATEGORY).value(category); + } + if (message != null) { + writer.name(JsonKeys.MESSAGE).value(message); + } + if (data != null) { + writer.name(Breadcrumb.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.nextLong(); + break; + case JsonKeys.CATEGORY: + event.category = reader.nextStringOrNull(); + break; + case JsonKeys.MESSAGE: + event.message = reader.nextStringOrNull(); + 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 +} From c2be16f71ad4b14063992ac1c80e63f4f3175294 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 22:29:45 +0200 Subject: [PATCH 096/184] Expose public API for flutter --- .../api/sentry-android-replay.api | 12 + sentry-android-replay/build.gradle.kts | 1 + .../java/io/sentry/android/replay/Recorder.kt | 18 ++ .../io/sentry/android/replay/ReplayCache.kt | 6 +- .../android/replay/ReplayIntegration.kt | 51 +++- .../android/replay/ScreenshotRecorder.kt | 27 +- .../sentry/android/replay/WindowRecorder.kt | 19 +- .../replay/capture/BaseCaptureStrategy.kt | 5 +- .../replay/capture/BufferCaptureStrategy.kt | 12 +- .../android/replay/capture/CaptureStrategy.kt | 4 +- .../replay/capture/SessionCaptureStrategy.kt | 11 +- .../sentry/android/replay/ReplayCacheTest.kt | 2 +- .../ReplayIntegrationWithRecorderTest.kt | 231 ++++++++++++++++++ .../java/io/sentry/NoOpReplayController.java | 6 + .../main/java/io/sentry/ReplayController.java | 4 + 15 files changed, 366 insertions(+), 43 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 582210ab90..f80f29d816 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -21,6 +21,13 @@ public final class io/sentry/android/replay/GeneratedVideo { 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 @@ -32,11 +39,15 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, 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 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 pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V @@ -47,6 +58,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 319386ee2b..bd9b5d961b 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { testImplementation(Config.TestLibs.androidxJunit) testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.awaitility) } tasks.withType { 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 0000000000..6cf86b6a7e --- /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 index 8f8dc97de7..f49abfaa84 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -30,14 +30,14 @@ public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, private val recorderConfig: ScreenshotRecorderConfig, - private val encoderCreator: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder + private val encoderProvider: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder ) : Closeable { public constructor( options: SentryOptions, replayId: SentryId, recorderConfig: ScreenshotRecorderConfig - ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> + ) : this(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> SimpleVideoEncoder( options, MuxerConfig( @@ -145,7 +145,7 @@ public class ReplayCache internal constructor( } // TODO: reuse instance of encoder and just change file path to create a different muxer - encoder = synchronized(encoderLock) { encoderCreator(videoFile, height, width) } + encoder = synchronized(encoderLock) { encoderProvider(videoFile, height, width) } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index b5cece07c1..7d52d1779f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -22,17 +22,30 @@ 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 -class ReplayIntegration( +public class ReplayIntegration( private val context: Context, - private val dateProvider: ICurrentDateProvider + 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, 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: WindowRecorder? = null + private var recorder: Recorder? = null private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here @@ -58,7 +71,7 @@ class ReplayIntegration( } this.hub = hub - recorder = WindowRecorder(options, this) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this) isEnabled.set(true) try { @@ -94,15 +107,15 @@ class ReplayIntegration( return } - recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = if (isFullSession) { - SessionCaptureStrategy(options, hub, dateProvider, recorderConfig) + SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random) + BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) } captureStrategy?.start() - recorder?.startRecording(recorderConfig) + recorder?.start(recorderConfig) } override fun resume() { @@ -133,6 +146,8 @@ class ReplayIntegration( captureStrategy = captureStrategy?.convert() } + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + override fun pause() { if (!isEnabled.get() || !isRecording.get()) { return @@ -147,14 +162,22 @@ class ReplayIntegration( return } - recorder?.stopRecording() + recorder?.stop() captureStrategy?.stop() isRecording.set(false) captureStrategy = null } override fun onScreenshotRecorded(bitmap: Bitmap) { - captureStrategy?.onScreenshotRecorded(bitmap) + captureStrategy?.onScreenshotRecorded { frameTimeStamp -> + addFrame(bitmap, frameTimeStamp) + } + } + + override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { + captureStrategy?.onScreenshotRecorded { _ -> + addFrame(screenshot, frameTimestamp) + } } override fun close() { @@ -169,6 +192,8 @@ class ReplayIntegration( stop() captureStrategy?.close() captureStrategy = null + recorder?.close() + recorder = null } override fun onConfigurationChanged(newConfig: Configuration) { @@ -176,13 +201,13 @@ class ReplayIntegration( return } - recorder?.stopRecording() + recorder?.stop() // refresh config based on new device configuration - recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy?.onConfigurationChanged(recorderConfig) - recorder?.startRecording(recorderConfig) + recorder?.start(recorderConfig) } override fun onLowMemory() = Unit diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index aaa7200abb..c9b6846529 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -26,6 +26,7 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import java.io.File import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -35,7 +36,7 @@ import kotlin.math.roundToInt internal class ScreenshotRecorder( val config: ScreenshotRecorderConfig, val options: SentryOptions, - private val screenshotRecorderCallback: ScreenshotRecorderCallback + private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { private var rootView: WeakReference? = null @@ -68,7 +69,7 @@ internal class ScreenshotRecorder( options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") lastScreenshot?.let { - screenshotRecorderCallback.onScreenshotRecorded( + screenshotRecorderCallback?.onScreenshotRecorded( it.copy(ARGB_8888, false) ) } @@ -140,7 +141,7 @@ internal class ScreenshotRecorder( } val screenshot = scaledBitmap.copy(ARGB_8888, false) - screenshotRecorderCallback.onScreenshotRecorded(screenshot) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) lastScreenshot?.recycle() lastScreenshot = screenshot contentChanged.set(false) @@ -294,6 +295,24 @@ public data class ScreenshotRecorderConfig( } } -interface ScreenshotRecorderCallback { +/** + * 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 index 83a49199ef..ceadfcf573 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -5,7 +5,6 @@ import android.view.View import io.sentry.SentryOptions import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.scheduleAtFixedRateSafely -import java.io.Closeable import java.lang.ref.WeakReference import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture @@ -17,8 +16,8 @@ import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, - private val screenshotRecorderCallback: ScreenshotRecorderCallback -) : Closeable { + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null +) : Recorder { internal companion object { private const val TAG = "WindowRecorder" @@ -51,7 +50,7 @@ internal class WindowRecorder( } } - fun startRecording(recorderConfig: ScreenshotRecorderConfig) { + override fun start(recorderConfig: ScreenshotRecorderConfig) { if (isRecording.getAndSet(true)) { return } @@ -69,10 +68,14 @@ internal class WindowRecorder( } } - fun resume() = recorder?.resume() - fun pause() = recorder?.pause() + override fun resume() { + recorder?.resume() + } + override fun pause() { + recorder?.pause() + } - fun stopRecording() { + override fun stop() { rootViewsSpy.listeners -= onRootViewsChangedListener rootViews.forEach { recorder?.unbind(it.get()) } recorder?.close() @@ -93,7 +96,7 @@ internal class WindowRecorder( } override fun close() { - stopRecording() + stop() capturer.gracefullyShutdown(options) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6004576e8c..3609928a8d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -33,7 +33,8 @@ internal abstract class BaseCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, protected var recorderConfig: ScreenshotRecorderConfig, - executor: ScheduledExecutorService? = null + executor: ScheduledExecutorService? = null, + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : CaptureStrategy { internal companion object { @@ -72,7 +73,7 @@ internal abstract class BaseCaptureStrategy( } } - cache = ReplayCache(options, currentReplayId.get(), recorderConfig) + cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId, recorderConfig) // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index e7cb39f48a..0c5dfc7314 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -1,6 +1,5 @@ package io.sentry.android.replay.capture -import android.graphics.Bitmap import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -9,9 +8,11 @@ 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 @@ -22,8 +23,9 @@ internal class BufferCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, - private val random: SecureRandom -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig) { + private val random: SecureRandom, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) { private val bufferedSegments = mutableListOf() @@ -91,12 +93,12 @@ internal class BufferCaptureStrategy( } } - override fun onScreenshotRecorded(bitmap: Bitmap) { + 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?.addFrame(bitmap, frameTimestamp) + cache?.store(frameTimestamp) val now = dateProvider.currentTimeMillis val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 8e0e47dcf8..530ddb3ef3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -1,8 +1,8 @@ package io.sentry.android.replay.capture -import android.graphics.Bitmap import io.sentry.Hint import io.sentry.SentryEvent +import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId import java.util.concurrent.atomic.AtomicInteger @@ -22,7 +22,7 @@ internal interface CaptureStrategy { fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) - fun onScreenshotRecorded(bitmap: Bitmap) + fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 5e32b57b6f..771b760b88 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -1,6 +1,5 @@ package io.sentry.android.replay.capture -import android.graphics.Bitmap import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -8,6 +7,7 @@ import io.sentry.SentryEvent 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 @@ -20,8 +20,9 @@ internal class SessionCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, - executor: ScheduledExecutorService? = null -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor) { + executor: ScheduledExecutorService? = null, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor, replayCacheProvider) { internal companion object { private const val TAG = "SessionCaptureStrategy" @@ -72,14 +73,14 @@ internal class SessionCaptureStrategy( } } - override fun onScreenshotRecorded(bitmap: Bitmap) { + 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?.addFrame(bitmap, frameTimestamp) + cache?.store(frameTimestamp) val now = dateProvider.currentTimeMillis if ((now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)) { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 1100b484ba..3608b77ccb 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -41,7 +41,7 @@ class ReplayCacheTest { options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> + return ReplayCache(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> encoder = SimpleVideoEncoder( options, MuxerConfig( 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 0000000000..426af9b4af --- /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() + + val flutterCacheDir = + File(fixture.options.cacheDirPath!!, "flutter_replay").also { it.mkdirs() } + val screenshot = File(flutterCacheDir, "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/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 1353e01a58..516b1e06f5 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; public final class NoOpReplayController implements ReplayController { @@ -31,4 +32,9 @@ public boolean isRecording() { @Override public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} + + @Override + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; + } } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index d2b7f7eb16..76ae450168 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.protocol.SentryId; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -16,4 +17,7 @@ public interface ReplayController { boolean isRecording(); void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); + + @NotNull + SentryId getReplayId(); } From 06d4b6d412043cbd50c9c714acacfe9a3dcfcbfa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 22:30:27 +0200 Subject: [PATCH 097/184] Spotless --- sentry/api/sentry.api | 40 +++++++++++++++++++ .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 40 ++++++++++--------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 02c49a4ef0..334c2427aa 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5035,6 +5035,46 @@ 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 ()J + 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 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 (J)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 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 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 diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index d22848f2c9..7ca501c3fc 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -16,7 +16,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { +public final class RRWebBreadcrumbEvent extends RRWebEvent + implements JsonUnknown, JsonSerializable { public static final String EVENT_TAG = "breadcrumb"; private @NotNull String tag; @@ -31,7 +32,6 @@ public final class RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknow private @Nullable Map payloadUnknown; private @Nullable Map dataUnknown; - public RRWebBreadcrumbEvent() { super(RRWebEventType.Custom); tag = EVENT_TAG; @@ -128,8 +128,8 @@ public static final class JsonKeys { public static final String MESSAGE = "message"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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); @@ -145,7 +145,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); writer.name(RRWebEvent.JsonKeys.TAG).value(tag); writer.name(JsonKeys.PAYLOAD); @@ -161,7 +161,7 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL } private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); if (breadcrumbType != null) { writer.name(JsonKeys.TYPE).value(breadcrumbType); @@ -188,8 +188,9 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull public static final class Deserializer implements JsonDeserializer { - @Override public @NotNull RRWebBreadcrumbEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + @Override + public @NotNull RRWebBreadcrumbEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -219,10 +220,10 @@ public static final class Deserializer implements JsonDeserializer dataUnknown = null; reader.beginObject(); @@ -247,11 +248,12 @@ private void deserializeData( reader.endObject(); } - @SuppressWarnings("unchecked") private void deserializePayload( - final @NotNull RRWebBreadcrumbEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + @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(); @@ -272,8 +274,8 @@ private void deserializeData( break; case JsonKeys.DATA: Map deserializedData = - CollectionUtils.newConcurrentHashMap( - (Map) reader.nextObjectOrNull()); + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); if (deserializedData != null) { event.data = deserializedData; } From 59056efafdfcf8ea63afc81804f31f35500444aa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 22:34:24 +0200 Subject: [PATCH 098/184] Spotless --- sentry/api/sentry.api | 42 ++++++++++++++++++ .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 43 ++++++++++--------- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 02c49a4ef0..198ef64f0d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1255,6 +1255,7 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { 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 @@ -1657,6 +1658,7 @@ public final class io/sentry/PropagationContext { } public abstract interface class io/sentry/ReplayController { + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun isRecording ()Z public abstract fun pause ()V public abstract fun resume ()V @@ -5035,6 +5037,46 @@ 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 ()J + 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 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 (J)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 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 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 diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index d22848f2c9..a4e81328a5 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -1,6 +1,5 @@ package io.sentry.rrweb; -import io.sentry.Breadcrumb; import io.sentry.ILogger; import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; @@ -16,7 +15,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { +public final class RRWebBreadcrumbEvent extends RRWebEvent + implements JsonUnknown, JsonSerializable { public static final String EVENT_TAG = "breadcrumb"; private @NotNull String tag; @@ -31,7 +31,6 @@ public final class RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknow private @Nullable Map payloadUnknown; private @Nullable Map dataUnknown; - public RRWebBreadcrumbEvent() { super(RRWebEventType.Custom); tag = EVENT_TAG; @@ -128,8 +127,8 @@ public static final class JsonKeys { public static final String MESSAGE = "message"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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); @@ -145,7 +144,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); writer.name(RRWebEvent.JsonKeys.TAG).value(tag); writer.name(JsonKeys.PAYLOAD); @@ -161,7 +160,7 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL } private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); if (breadcrumbType != null) { writer.name(JsonKeys.TYPE).value(breadcrumbType); @@ -174,7 +173,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.name(JsonKeys.MESSAGE).value(message); } if (data != null) { - writer.name(Breadcrumb.JsonKeys.DATA).value(logger, data); + writer.name(JsonKeys.DATA).value(logger, data); } if (payloadUnknown != null) { for (final String key : payloadUnknown.keySet()) { @@ -188,8 +187,9 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull public static final class Deserializer implements JsonDeserializer { - @Override public @NotNull RRWebBreadcrumbEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + @Override + public @NotNull RRWebBreadcrumbEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -219,10 +219,10 @@ public static final class Deserializer implements JsonDeserializer dataUnknown = null; reader.beginObject(); @@ -247,11 +247,12 @@ private void deserializeData( reader.endObject(); } - @SuppressWarnings("unchecked") private void deserializePayload( - final @NotNull RRWebBreadcrumbEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + @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(); @@ -272,8 +273,8 @@ private void deserializeData( break; case JsonKeys.DATA: Map deserializedData = - CollectionUtils.newConcurrentHashMap( - (Map) reader.nextObjectOrNull()); + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); if (deserializedData != null) { event.data = deserializedData; } From 0e951eb853b5414764de6038bce8b35df6312e0f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 22:40:29 +0200 Subject: [PATCH 099/184] Remove breadcrumb import --- sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index 7ca501c3fc..a4e81328a5 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -1,6 +1,5 @@ package io.sentry.rrweb; -import io.sentry.Breadcrumb; import io.sentry.ILogger; import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; @@ -174,7 +173,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.name(JsonKeys.MESSAGE).value(message); } if (data != null) { - writer.name(Breadcrumb.JsonKeys.DATA).value(logger, data); + writer.name(JsonKeys.DATA).value(logger, data); } if (payloadUnknown != null) { for (final String key : payloadUnknown.keySet()) { From e33b29c1264622b3fa3f423de64c8c1cd8dd7754 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 14:46:45 +0200 Subject: [PATCH 100/184] Send temporary breadcrumbs and add test --- .../replay/capture/BaseCaptureStrategy.kt | 68 ++++++++++++++++--- sentry/api/sentry.api | 4 +- .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 11 +-- .../RRWebBreadcrumbEventSerializationTest.kt | 43 ++++++++++++ .../json/rrweb_breadcrumb_event.json | 17 +++++ 5 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/rrweb_breadcrumb_event.json diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6004576e8c..667318bf98 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -170,14 +170,60 @@ internal abstract class BaseCaptureStrategy( hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before(endTimestamp)) { - recordingPayload += RRWebBreadcrumbEvent().apply { - timestamp = breadcrumb.timestamp.time - breadcrumbTimestamp = breadcrumb.timestamp.time - breadcrumbType = breadcrumb.type - category = breadcrumb.category - message = breadcrumb.message - data = breadcrumb.data + if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before( + endTimestamp + ) + ) { + // TODO: rework this later when aligned with iOS and frontend + var breadcrumbMessage: String? = null + val breadcrumbCategory: String? + val breadcrumbData = mutableMapOf() + when { + breadcrumb.category == "http" -> return@forEach + + breadcrumb.category == "device.orientation" -> { + breadcrumbCategory = breadcrumb.category!! + breadcrumbMessage = breadcrumb.data["position"] as? String ?: "" + } + + breadcrumb.type == "navigation" -> { + breadcrumbCategory = "navigation" + breadcrumbData["to"] = when { + breadcrumb.data["state"] == "resumed" -> breadcrumb.data["screen"] as? String + breadcrumb.category == "app.lifecycle" -> breadcrumb.data["state"] as? String + "to" in breadcrumb.data -> breadcrumb.data["to"] as? String + else -> return@forEach + } ?: return@forEach + } + + breadcrumb.category in setOf("ui.click", "ui.scroll", "ui.swipe") -> { + breadcrumbCategory = breadcrumb.category!! + breadcrumbMessage = ( + breadcrumb.data["view.id"] + ?: breadcrumb.data["view.class"] + ?: breadcrumb.data["view.tag"] + ) as? String ?: "" + } + + breadcrumb.type == "system" -> { + breadcrumbCategory = breadcrumb.type!! + breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" + } + + else -> { + breadcrumbCategory = breadcrumb.category + breadcrumbMessage = breadcrumb.message + } + } + if (!breadcrumbCategory.isNullOrEmpty()) { + recordingPayload += RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 + breadcrumbType = "default" + category = breadcrumbCategory + message = breadcrumbMessage + data = if (breadcrumbData.isEmpty()) null else breadcrumbData + } } } } @@ -188,7 +234,11 @@ internal abstract class BaseCaptureStrategy( payload = recordingPayload } - return ReplaySegment.Created(videoDuration = duration, replay = replay, recording = recording) + return ReplaySegment.Created( + videoDuration = duration, + replay = replay, + recording = recording + ) } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 334c2427aa..22e8613730 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5038,7 +5038,7 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { 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 ()J + public fun getBreadcrumbTimestamp ()D public fun getBreadcrumbType ()Ljava/lang/String; public fun getCategory ()Ljava/lang/String; public fun getData ()Ljava/util/Map; @@ -5048,7 +5048,7 @@ public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebE 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 (J)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 diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index a4e81328a5..d98e91c0e5 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -9,6 +9,7 @@ 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; @@ -20,7 +21,7 @@ public final class RRWebBreadcrumbEvent extends RRWebEvent public static final String EVENT_TAG = "breadcrumb"; private @NotNull String tag; - private long breadcrumbTimestamp; + private double breadcrumbTimestamp; private @Nullable String breadcrumbType; private @Nullable String category; private @Nullable String message; @@ -45,11 +46,11 @@ public void setTag(final @NotNull String tag) { this.tag = tag; } - public long getBreadcrumbTimestamp() { + public double getBreadcrumbTimestamp() { return breadcrumbTimestamp; } - public void setBreadcrumbTimestamp(final long breadcrumbTimestamp) { + public void setBreadcrumbTimestamp(final double breadcrumbTimestamp) { this.breadcrumbTimestamp = breadcrumbTimestamp; } @@ -165,7 +166,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull if (breadcrumbType != null) { writer.name(JsonKeys.TYPE).value(breadcrumbType); } - writer.name(JsonKeys.TIMESTAMP).value(breadcrumbTimestamp); + writer.name(JsonKeys.TIMESTAMP).value(logger, BigDecimal.valueOf(breadcrumbTimestamp)); if (category != null) { writer.name(JsonKeys.CATEGORY).value(category); } @@ -263,7 +264,7 @@ private void deserializePayload( event.breadcrumbType = reader.nextStringOrNull(); break; case JsonKeys.TIMESTAMP: - event.breadcrumbTimestamp = reader.nextLong(); + event.breadcrumbTimestamp = reader.nextDouble(); break; case JsonKeys.CATEGORY: event.category = reader.nextStringOrNull(); 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 0000000000..cf711fd27f --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.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 RRWebBreadcrumbEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebBreadcrumbEvent().apply { + timestamp = 12345678901 + breadcrumbType = "default" + breadcrumbTimestamp = 12345678.901 + category = "navigation" + message = "message" + 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/resources/json/rrweb_breadcrumb_event.json b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json new file mode 100644 index 0000000000..f41c6cb20d --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json @@ -0,0 +1,17 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "breadcrumb", + "payload": { + "type": "default", + "timestamp": 12345678.901, + "category": "navigation", + "message": "message", + "data": { + "screen": "MainActivity", + "state": "resumed" + } + } + } +} From 27e17c1e8b630b53484aadcc6ae010d847d0f96a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 16:09:04 +0200 Subject: [PATCH 101/184] Formatting --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 667318bf98..1df4a013f9 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -170,10 +170,8 @@ internal abstract class BaseCaptureStrategy( hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before( - endTimestamp - ) - ) { + if (breadcrumb.timestamp.after(segmentTimestamp) && + breadcrumb.timestamp.before(endTimestamp)) { // TODO: rework this later when aligned with iOS and frontend var breadcrumbMessage: String? = null val breadcrumbCategory: String? From 51cc43260dc5b3214153d5692f88cf534e2c19c4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 18:01:50 +0200 Subject: [PATCH 102/184] Sort rrweb events --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 1df4a013f9..615ebb2603 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -220,7 +220,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbType = "default" category = breadcrumbCategory message = breadcrumbMessage - data = if (breadcrumbData.isEmpty()) null else breadcrumbData + data = breadcrumbData } } } @@ -229,7 +229,7 @@ internal abstract class BaseCaptureStrategy( val recording = ReplayRecording().apply { this.segmentId = segmentId - payload = recordingPayload + payload = recordingPayload.sortedBy { it.timestamp } } return ReplaySegment.Created( From 4c7d1a062016fd49ffa386026b8fda56c8d9c622 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 18:29:21 +0200 Subject: [PATCH 103/184] Formatting --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 615ebb2603..6e6ff98281 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -171,7 +171,8 @@ internal abstract class BaseCaptureStrategy( hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> if (breadcrumb.timestamp.after(segmentTimestamp) && - breadcrumb.timestamp.before(endTimestamp)) { + breadcrumb.timestamp.before(endTimestamp) + ) { // TODO: rework this later when aligned with iOS and frontend var breadcrumbMessage: String? = null val breadcrumbCategory: String? From c6b16ed8e550a0fb49a6e1cd487d11184cb78339 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 20:21:54 +0200 Subject: [PATCH 104/184] Expose replayCacheDir --- sentry-android-replay/api/sentry-android-replay.api | 1 + .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 1 + .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 1 + .../io/sentry/android/replay/capture/CaptureStrategy.kt | 2 ++ .../android/replay/ReplayIntegrationWithRecorderTest.kt | 6 +++--- 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index f80f29d816..3918cde06b 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -42,6 +42,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 7d52d1779f..cc3248e1a4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -52,6 +52,7 @@ public class ReplayIntegration( private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null + public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir private lateinit var recorderConfig: ScreenshotRecorderConfig diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 4dcbc66c33..2e0eb1ba7c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -46,6 +46,7 @@ internal abstract class BaseCaptureStrategy( protected val replayStartTimestamp = AtomicLong() override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) override val currentSegment = AtomicInteger(0) + override val replayCacheDir: File? get() = cache?.replayCacheDir protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 530ddb3ef3..821ebcef66 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -5,12 +5,14 @@ import io.sentry.SentryEvent 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) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index 426af9b4af..b4994cdb21 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -184,9 +184,9 @@ class ReplayIntegrationWithRecorderTest { // start again and capture some frames replay.start() - val flutterCacheDir = - File(fixture.options.cacheDirPath!!, "flutter_replay").also { it.mkdirs() } - val screenshot = File(flutterCacheDir, "1.jpg").also { it.createNewFile() } + // 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) From 6f3dc0a3cee2ae025ae25a687c776317336ef494 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 22 Apr 2024 23:01:05 +0200 Subject: [PATCH 105/184] Capture network requests --- .../replay/capture/BaseCaptureStrategy.kt | 54 +++- .../io/sentry/okhttp/SentryOkHttpEvent.kt | 5 + .../sentry/okhttp/SentryOkHttpInterceptor.kt | 14 +- sentry/api/sentry.api | 40 +++ .../main/java/io/sentry/ReplayRecording.java | 42 ++- .../java/io/sentry/rrweb/RRWebSpanEvent.java | 289 ++++++++++++++++++ .../java/io/sentry/util/MapObjectReader.java | 13 - .../ReplayRecordingSerializationTest.kt | 6 +- .../rrweb/RRWebSpanEventSerializationTest.kt | 43 +++ .../io/sentry/util/MapObjectReaderTest.kt | 2 + .../test/resources/json/replay_recording.json | 2 +- .../json/replay_recording_payload.json | 32 -- .../test/resources/json/rrweb_span_event.json | 17 ++ 13 files changed, 494 insertions(+), 65 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt delete mode 100644 sentry/src/test/resources/json/replay_recording_payload.json create mode 100644 sentry/src/test/resources/json/rrweb_span_event.json diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 2e0eb1ba7c..c3a5d1f198 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -16,6 +17,7 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebSpanEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -39,6 +41,14 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" + private val supportedNetworkData = setOf( + "status_code", + "method", + "response_content_length", + "request_content_length", + "http.response_content_length", + "http.request_content_length" + ) } protected var cache: ReplayCache? = null @@ -74,7 +84,8 @@ internal abstract class BaseCaptureStrategy( } } - cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId, recorderConfig) + cache = + replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId, recorderConfig) // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) @@ -180,7 +191,32 @@ internal abstract class BaseCaptureStrategy( val breadcrumbCategory: String? val breadcrumbData = mutableMapOf() when { - breadcrumb.category == "http" -> return@forEach + breadcrumb.category == "http" -> { + if (!breadcrumb.isValidForRRWebSpan()) { + return@forEach + } + recordingPayload += RRWebSpanEvent().apply { + timestamp = breadcrumb.timestamp.time + op = "resource.xhr" // TODO: should be 'http' when supported on FE + description = breadcrumb.data["url"] as String + startTimestamp = + (breadcrumb.data["start_timestamp"] as Long) / 1000.0 + this.endTimestamp = + (breadcrumb.data["end_timestamp"] as Long) / 1000.0 + for ((key, value) in breadcrumb.data) { + if (key in supportedNetworkData) { + breadcrumbData[ + key + .replace("content_length", "body_size") + .substringAfter(".") + .snakeToCamelCase() + ] = value + } + } + data = breadcrumbData + } + return@forEach + } breadcrumb.category == "device.orientation" -> { breadcrumbCategory = breadcrumb.category!! @@ -208,7 +244,8 @@ internal abstract class BaseCaptureStrategy( breadcrumb.type == "system" -> { breadcrumbCategory = breadcrumb.type!! - breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" + breadcrumbMessage = + breadcrumb.data.entries.joinToString() as? String ?: "" } else -> { @@ -280,4 +317,15 @@ internal abstract class BaseCaptureStrategy( } } } + + private fun Breadcrumb.isValidForRRWebSpan(): Boolean { + return !(data["url"] as? String).isNullOrEmpty() && + "start_timestamp" in data && + "end_timestamp" in data + } + + private fun String.snakeToCamelCase(): String { + val pattern = "_[a-z]".toRegex() + return replace(pattern) { it.value.last().uppercase() } + } } 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 21a3329a14..d35ec01746 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("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("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 efa472963d..ab30f494bc 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("start_timestamp", startTimestamp) + breadcrumb.setData("end_timestamp", CurrentDateProvider.getInstance().currentTimeMillis) hub.addBreadcrumb(breadcrumb, hint) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 36fbaf3f30..7d00d43207 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5155,6 +5155,46 @@ public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { 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; diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 4e6aecdb2b..d04469af7d 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -1,8 +1,10 @@ package io.sentry; +import io.sentry.rrweb.RRWebBreadcrumbEvent; import io.sentry.rrweb.RRWebEvent; import io.sentry.rrweb.RRWebEventType; 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; @@ -148,19 +150,33 @@ public static final class Deserializer implements JsonDeserializer data = - (Map) eventMap.getOrDefault("data", Collections.emptyMap()); - final String tag = - (String) data.getOrDefault(RRWebEvent.JsonKeys.TAG, "default"); - switch (tag) { - case RRWebVideoEvent.EVENT_TAG: - final RRWebEvent videoEvent = - new RRWebVideoEvent.Deserializer().deserialize(mapReader, logger); - payload.add(videoEvent); - break; - default: - logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); - break; + Map data = (Map) eventMap.get("data"); + if (data == null) { + data = Collections.emptyMap(); + } + final String tag = (String) data.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: 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 0000000000..5bdc667f40 --- /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/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index cd44ee0777..fdb6ccff2b 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -8,7 +8,6 @@ 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.List; @@ -338,18 +337,6 @@ private T nextValueOrNull( final T value = (T) currentEntry.getValue(); if (deserializer != null && logger != null) { return deserializer.deserialize(this, logger); - } else if (value instanceof List) { - List list = new ArrayList<>((List) value); - if (!list.isEmpty()) { - final T next = (T) list.remove(0); - if (next instanceof Map) { - stack.addLast(new AbstractMap.SimpleEntry<>(null, next)); - } - return next; - } - } else if (value instanceof Map) { - stack.addLast(new AbstractMap.SimpleEntry<>(null, value)); - return value; } stack.removeLast(); return value; diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index d8f93ddfb5..b7dfa96c4e 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -5,7 +5,9 @@ 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.RRWebMetaEventSerializationTest +import io.sentry.rrweb.RRWebSpanEventSerializationTest import io.sentry.rrweb.RRWebVideoEventSerializationTest import org.junit.Test import org.mockito.kotlin.mock @@ -19,7 +21,9 @@ class ReplayRecordingSerializationTest { segmentId = 0 payload = listOf( RRWebMetaEventSerializationTest.Fixture().getSut(), - RRWebVideoEventSerializationTest.Fixture().getSut() + RRWebVideoEventSerializationTest.Fixture().getSut(), + RRWebBreadcrumbEventSerializationTest.Fixture().getSut(), + RRWebSpanEventSerializationTest.Fixture().getSut() ) } } 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 0000000000..034a1ded99 --- /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/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt index 06124c88e7..ad2c2344a1 100644 --- a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -74,10 +74,12 @@ class MapObjectReaderTest { 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")) val reader = MapObjectReader(data) reader.beginObject() assertEquals(JsonToken.NAME, reader.peek()) + assertEquals(mapOf("screen" to "MainActivity"), reader.nextObjectOrNull()) assertEquals("Enum", reader.nextName()) assertEquals(BasicEnum.A, BasicEnum.valueOf(reader.nextString())) assertEquals("Currency", reader.nextName()) diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index 287419e1eb..fac90d3803 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,2 +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":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","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"}}}}] diff --git a/sentry/src/test/resources/json/replay_recording_payload.json b/sentry/src/test/resources/json/replay_recording_payload.json deleted file mode 100644 index fe790b52d6..0000000000 --- a/sentry/src/test/resources/json/replay_recording_payload.json +++ /dev/null @@ -1,32 +0,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 - } - } - } -] 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 0000000000..6ec906a3e3 --- /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" + } + } + } +} From 4c562fb47554f93de205cd719bd252d8623d0b08 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 22 Apr 2024 23:20:52 +0200 Subject: [PATCH 106/184] Change op name to resource.http --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index c3a5d1f198..365d37c482 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -197,7 +197,7 @@ internal abstract class BaseCaptureStrategy( } recordingPayload += RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time - op = "resource.xhr" // TODO: should be 'http' when supported on FE + op = "resource.http" description = breadcrumb.data["url"] as String startTimestamp = (breadcrumb.data["start_timestamp"] as Long) / 1000.0 From cd4fd9e2d117cd5ce47f1606a7823effcf63800c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 23 Apr 2024 15:32:30 +0200 Subject: [PATCH 107/184] feat(replay): Add `sendReplay` method for Hybrid SDKs --- .../sentry/android/replay/ReplayIntegration.kt | 12 ++++++++++-- .../replay/capture/BufferCaptureStrategy.kt | 17 +++++++++-------- .../android/replay/capture/CaptureStrategy.kt | 3 +-- .../replay/capture/SessionCaptureStrategy.kt | 13 +++++-------- .../java/io/sentry/NoOpReplayController.java | 5 +++++ .../main/java/io/sentry/ReplayController.java | 3 +++ 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index cc3248e1a4..902d12b1ae 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -138,12 +138,20 @@ public class ReplayIntegration( 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", event.eventId) + options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) return } - captureStrategy?.sendReplayForEvent(event, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) + captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) captureStrategy = captureStrategy?.convert() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 0c5dfc7314..2d69852abf 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay.capture import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub -import io.sentry.SentryEvent import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions @@ -41,14 +40,16 @@ internal class BufferCaptureStrategy( super.stop() } - override fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) { + override fun sendReplayForEvent( + isCrashed: Boolean, + eventId: String?, + hint: Hint?, + onSegmentSent: () -> Unit + ) { val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) - if (sampled) { - // don't ask me why - event.setTag("replayId", currentReplayId.get().toString()) - } else { - options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) + if (!sampled) { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", eventId) return } @@ -83,7 +84,7 @@ internal class BufferCaptureStrategy( BUFFER ) if (segment is ReplaySegment.Created) { - segment.capture(hub, hint) + 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 821ebcef66..d78d02ad6b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -1,7 +1,6 @@ package io.sentry.android.replay.capture import io.sentry.Hint -import io.sentry.SentryEvent import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId @@ -22,7 +21,7 @@ internal interface CaptureStrategy { fun resume() - fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) + fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 771b760b88..43af2e3c37 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay.capture import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub -import io.sentry.SentryEvent import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions @@ -58,16 +57,14 @@ internal class SessionCaptureStrategy( super.stop() } - override fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) { - // don't ask me why - event.setTag("replayId", currentReplayId.get().toString()) - if (!event.isCrashed) { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + 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", event.eventId) + 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) + segment.capture(hub, hint ?: Hint()) } } } diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 516b1e06f5..a1a715318c 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -2,6 +2,7 @@ import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public final class NoOpReplayController implements ReplayController { @@ -33,6 +34,10 @@ public boolean isRecording() { @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; diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 76ae450168..a85cdacc93 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -3,6 +3,7 @@ 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 { @@ -18,6 +19,8 @@ public interface ReplayController { void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); + void sendReplay(@Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint); + @NotNull SentryId getReplayId(); } From e836f49a992eaaf2a76bf4dbc52e4075e7312d0b Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 23 Apr 2024 16:44:48 +0200 Subject: [PATCH 108/184] fix apiDump --- sentry-android-replay/api/sentry-android-replay.api | 1 + sentry/api/sentry.api | 2 ++ 2 files changed, 3 insertions(+) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 3918cde06b..9e4dcb074d 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -52,6 +52,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 start ()V public fun stop ()V diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7d00d43207..2158b7f929 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1259,6 +1259,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { 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 start ()V public fun stop ()V @@ -1662,6 +1663,7 @@ public abstract interface class io/sentry/ReplayController { 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 start ()V public abstract fun stop ()V From 4f240d4f0dea75b34b4e16d74b84aadb94eed445 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Apr 2024 21:08:09 +0200 Subject: [PATCH 109/184] Address PR review --- .../replay/capture/BaseCaptureStrategy.kt | 59 +++++++++++-------- .../io/sentry/okhttp/SentryOkHttpEvent.kt | 4 +- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 4 +- sentry/api/sentry.api | 2 + .../java/io/sentry/SpanDataConvention.java | 2 + 5 files changed, 41 insertions(+), 30 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 365d37c482..d390d75124 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -9,6 +9,7 @@ import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.SpanDataConvention import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.gracefullyShutdown @@ -41,6 +42,7 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" + private val snakecasePattern = "_[a-z]".toRegex() private val supportedNetworkData = setOf( "status_code", "method", @@ -192,28 +194,8 @@ internal abstract class BaseCaptureStrategy( val breadcrumbData = mutableMapOf() when { breadcrumb.category == "http" -> { - if (!breadcrumb.isValidForRRWebSpan()) { - return@forEach - } - recordingPayload += RRWebSpanEvent().apply { - timestamp = breadcrumb.timestamp.time - op = "resource.http" - description = breadcrumb.data["url"] as String - startTimestamp = - (breadcrumb.data["start_timestamp"] as Long) / 1000.0 - this.endTimestamp = - (breadcrumb.data["end_timestamp"] as Long) / 1000.0 - for ((key, value) in breadcrumb.data) { - if (key in supportedNetworkData) { - breadcrumbData[ - key - .replace("content_length", "body_size") - .substringAfter(".") - .snakeToCamelCase() - ] = value - } - } - data = breadcrumbData + if (breadcrumb.isValidForRRWebSpan()) { + recordingPayload += breadcrumb.toRRWebSpanEvent() } return@forEach } @@ -320,12 +302,37 @@ internal abstract class BaseCaptureStrategy( private fun Breadcrumb.isValidForRRWebSpan(): Boolean { return !(data["url"] as? String).isNullOrEmpty() && - "start_timestamp" in data && - "end_timestamp" in data + SpanDataConvention.HTTP_START_TIMESTAMP in data && + SpanDataConvention.HTTP_END_TIMESTAMP in data } private fun String.snakeToCamelCase(): String { - val pattern = "_[a-z]".toRegex() - return replace(pattern) { it.value.last().uppercase() } + 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-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index d35ec01746..6bceb81a19 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -60,7 +60,7 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req breadcrumb.setData("host", host) breadcrumb.setData("path", encodedPath) // needs this as unix timestamp for rrweb - breadcrumb.setData("start_timestamp", CurrentDateProvider.getInstance().currentTimeMillis) + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) // We add the same data to the root call span callRootSpan?.setData("url", url) @@ -154,7 +154,7 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } // needs this as unix timestamp for rrweb - breadcrumb.setData("end_timestamp", CurrentDateProvider.getInstance().currentTimeMillis) + 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 ab30f494bc..5bf93be060 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -164,8 +164,8 @@ public open class SentryOkHttpInterceptor( hint[OKHTTP_RESPONSE] = it } // needs this as unix timestamp for rrweb - breadcrumb.setData("start_timestamp", startTimestamp) - breadcrumb.setData("end_timestamp", CurrentDateProvider.getInstance().currentTimeMillis) + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) + breadcrumb.setData(SpanDataConvention.HTTP_END_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) hub.addBreadcrumb(breadcrumb, hint) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7d00d43207..92a1fcd824 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2961,10 +2961,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; diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index b96bf41e66..4bf4da099c 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -21,4 +21,6 @@ public interface SpanDataConvention { String FRAMES_SLOW = "frames.slow"; String FRAMES_FROZEN = "frames.frozen"; String FRAMES_DELAY = "frames.delay"; + String HTTP_START_TIMESTAMP = "http.start_timestamp"; + String HTTP_END_TIMESTAMP = "http.end_timestamp"; } From f6b464b1f89f654648684093ffd93ae862557c61 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Apr 2024 16:03:36 +0200 Subject: [PATCH 110/184] Capture motion events as incremental rrweb events --- .../android/replay/ReplayIntegration.kt | 9 +- .../sentry/android/replay/WindowRecorder.kt | 73 ++++- .../replay/capture/BaseCaptureStrategy.kt | 66 +++- .../android/replay/capture/CaptureStrategy.kt | 3 + .../replay/util/FixedWindowCallback.java | 235 ++++++++++++++ .../rrweb/RRWebIncrementalSnapshotEvent.java | 101 ++++++ .../sentry/rrweb/RRWebInteractionEvent.java | 254 +++++++++++++++ .../rrweb/RRWebInteractionMoveEvent.java | 288 ++++++++++++++++++ .../RRWebInteractionEventSerializationTest.kt | 40 +++ ...ebInteractionMoveEventSerializationTest.kt | 45 +++ .../json/rrweb_interaction_event.json | 12 + .../json/rrweb_interaction_move_event.json | 15 + 12 files changed, 1133 insertions(+), 8 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/rrweb_interaction_event.json create mode 100644 sentry/src/test/resources/json/rrweb_interaction_move_event.json diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index cc3248e1a4..b2d34b0862 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -5,6 +5,7 @@ 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 @@ -32,7 +33,7 @@ public class ReplayIntegration( private val recorderProvider: (() -> Recorder)? = null, private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null -) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { +) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, ComponentCallbacks { // needed for the Java's call site constructor(context: Context, dateProvider: ICurrentDateProvider) : this( @@ -72,7 +73,7 @@ public class ReplayIntegration( } this.hub = hub - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this) isEnabled.set(true) try { @@ -212,4 +213,8 @@ public class ReplayIntegration( } 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/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index ceadfcf573..2ee60d7466 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,8 +1,13 @@ 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 @@ -16,7 +21,8 @@ import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, - private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val touchRecorderCallback: TouchRecorderCallback? = null ) : Recorder { internal companion object { @@ -39,7 +45,11 @@ internal class WindowRecorder( if (added) { rootViews.add(WeakReference(root)) recorder?.bind(root) + + root.startGestureTracking() } else { + root.stopGestureTracking() + recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -86,6 +96,60 @@ internal class WindowRecorder( 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.obtain(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 { @@ -94,9 +158,8 @@ internal class WindowRecorder( return ret } } +} - override fun close() { - stop() - capturer.gracefullyShutdown(options) - } +public interface TouchRecorderCallback { + fun onTouchEvent(event: MotionEvent) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index d390d75124..9b3516f330 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -1,10 +1,12 @@ package io.sentry.android.replay.capture +import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.ReplayRecording +import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType @@ -17,6 +19,11 @@ 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.RRWebSpanEvent import io.sentry.rrweb.RRWebVideoEvent @@ -42,6 +49,7 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" + private const val DEBOUNCE_TIMEOUT = 200 private val snakecasePattern = "_[a-z]".toRegex() private val supportedNetworkData = setOf( "status_code", @@ -59,6 +67,8 @@ internal abstract class BaseCaptureStrategy( override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir + private val currentEvents = mutableListOf() + private val lastExecutionTime = AtomicLong(0) protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -225,7 +235,7 @@ internal abstract class BaseCaptureStrategy( } breadcrumb.type == "system" -> { - breadcrumbCategory = breadcrumb.type!! + breadcrumbCategory = null breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" } @@ -248,6 +258,12 @@ internal abstract class BaseCaptureStrategy( } } } + currentEvents.removeAll { + if (it.timestamp > segmentTimestamp.time && it.timestamp < endTimestamp.time) { + recordingPayload += it + } + it.timestamp < endTimestamp.time + } val recording = ReplayRecording().apply { this.segmentId = segmentId @@ -265,6 +281,13 @@ internal abstract class BaseCaptureStrategy( this.recorderConfig = recorderConfig } + override fun onTouchEvent(event: MotionEvent) { + val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() + if (rrwebEvent != null) { + currentEvents += rrwebEvent + } + } + override fun close() { replayExecutor.gracefullyShutdown(options) } @@ -335,4 +358,45 @@ internal abstract class BaseCaptureStrategy( data = breadcrumbData } } + + private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): RRWebIncrementalSnapshotEvent? { + val event = this + return when(val action = event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.getCurrentTimeMillis() + if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { + return null + } + lastExecutionTime.set(now) + + RRWebInteractionMoveEvent().apply { + timestamp = dateProvider.currentTimeMillis + positions = listOf( + Position().apply { + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = event.getPointerId(event.actionIndex) + timeOffset = 0 // TODO: is this needed? + } + ) // TODO: support multiple pointers + } + } + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = event.getPointerId(event.actionIndex) + interactionType = when (action) { + MotionEvent.ACTION_UP -> InteractionType.TouchEnd + MotionEvent.ACTION_DOWN -> InteractionType.TouchStart + MotionEvent.ACTION_CANCEL -> InteractionType.TouchCancel + else -> InteractionType.TouchMove_Departed // should not happen + } + } + } + else -> null + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 821ebcef66..357019dc73 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.view.MotionEvent import io.sentry.Hint import io.sentry.SentryEvent import io.sentry.android.replay.ReplayCache @@ -28,6 +29,8 @@ internal interface CaptureStrategy { fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) + fun onTouchEvent(event: MotionEvent) + fun convert(): CaptureStrategy fun close() 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 0000000000..d2121e8849 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java @@ -0,0 +1,235 @@ +/** + * 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/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java new file mode 100644 index 0000000000..89b945504b --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -0,0 +1,101 @@ +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 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 0000000000..e08feb52b2 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -0,0 +1,254 @@ +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; + + // 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; + } + + @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"; + } + + @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); + 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; + 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 0000000000..9edf7e836b --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -0,0 +1,288 @@ +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 @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; + } + + // 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"; + } + + @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); + } + 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; + 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/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt new file mode 100644 index 0000000000..cc63de72ba --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt @@ -0,0 +1,40 @@ +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 + } + } + + 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 0000000000..43d8fc7658 --- /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.RRWebInteractionEvent.InteractionType.TouchStart +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 + } + ) + } + } + + 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/resources/json/rrweb_interaction_event.json b/sentry/src/test/resources/json/rrweb_interaction_event.json new file mode 100644 index 0000000000..f6b4b1de83 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_event.json @@ -0,0 +1,12 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 2, + "type": 7, + "id": 1, + "x": 1.0, + "y": 2.0, + "pointerType": 2 + } +} 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 0000000000..3f181f543a --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_move_event.json @@ -0,0 +1,15 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 6, + "positions": [ + { + "id": 1, + "x": 1.0, + "y": 2.0, + "timeOffset": 100 + } + ] + } +} From 2d508c9e35de0e82fb08eed3b3e8d06699aabcc6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Apr 2024 16:05:38 +0200 Subject: [PATCH 111/184] Spotless --- .../api/sentry-android-replay.api | 37 +++- .../replay/capture/BaseCaptureStrategy.kt | 43 +++-- .../replay/util/FixedWindowCallback.java | 109 +++++++----- sentry/api/sentry.api | 160 ++++++++++++++++++ .../rrweb/RRWebIncrementalSnapshotEvent.java | 28 ++- .../sentry/rrweb/RRWebInteractionEvent.java | 33 ++-- .../rrweb/RRWebInteractionMoveEvent.java | 39 ++--- ...ebInteractionMoveEventSerializationTest.kt | 1 - 8 files changed, 328 insertions(+), 122 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 3918cde06b..337a9640f0 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -37,7 +37,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { 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, java/io/Closeable { +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 @@ -49,6 +49,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 @@ -88,6 +89,40 @@ 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 9b3516f330..4236adbc0e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -6,7 +6,6 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.ReplayRecording -import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType @@ -361,27 +360,27 @@ internal abstract class BaseCaptureStrategy( private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): RRWebIncrementalSnapshotEvent? { val event = this - return when(val action = event.actionMasked) { - MotionEvent.ACTION_MOVE -> { - // we only throttle move events as those can be overwhelming - val now = dateProvider.getCurrentTimeMillis() - if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { - return null - } - lastExecutionTime.set(now) - - RRWebInteractionMoveEvent().apply { - timestamp = dateProvider.currentTimeMillis - positions = listOf( - Position().apply { - x = event.x * recorderConfig.scaleFactorX - y = event.y * recorderConfig.scaleFactorY - id = event.getPointerId(event.actionIndex) - timeOffset = 0 // TODO: is this needed? - } - ) // TODO: support multiple pointers - } - } + return when (val action = event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.getCurrentTimeMillis() + if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { + return null + } + lastExecutionTime.set(now) + + RRWebInteractionMoveEvent().apply { + timestamp = dateProvider.currentTimeMillis + positions = listOf( + Position().apply { + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = event.getPointerId(event.actionIndex) + timeOffset = 0 // TODO: is this needed? + } + ) // TODO: support multiple pointers + } + } MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { RRWebInteractionEvent().apply { timestamp = dateProvider.currentTimeMillis 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 index d2121e8849..7245eefabe 100644 --- 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 @@ -1,21 +1,18 @@ /** * Adapted from https://github.com/square/curtains/tree/v1.2.5 * - * Copyright 2021 Square Inc. + *

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 + *

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 + *

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 + *

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; @@ -35,10 +32,9 @@ 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 + * 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 { @@ -48,129 +44,145 @@ public FixedWindowCallback(@Nullable Window.Callback delegate) { this.delegate = delegate; } - @Override public boolean dispatchKeyEvent(KeyEvent event) { + @Override + public boolean dispatchKeyEvent(KeyEvent event) { if (delegate == null) { return false; } return delegate.dispatchKeyEvent(event); } - @Override public boolean dispatchKeyShortcutEvent(KeyEvent event) { + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent event) { if (delegate == null) { return false; } return delegate.dispatchKeyShortcutEvent(event); } - @Override public boolean dispatchTouchEvent(MotionEvent event) { + @Override + public boolean dispatchTouchEvent(MotionEvent event) { if (delegate == null) { return false; } return delegate.dispatchTouchEvent(event); } - @Override public boolean dispatchTrackballEvent(MotionEvent event) { + @Override + public boolean dispatchTrackballEvent(MotionEvent event) { if (delegate == null) { return false; } return delegate.dispatchTrackballEvent(event); } - @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { if (delegate == null) { return false; } return delegate.dispatchGenericMotionEvent(event); } - @Override public boolean dispatchPopulateAccessibilityEvent( - AccessibilityEvent event) { + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { if (delegate == null) { return false; } return delegate.dispatchPopulateAccessibilityEvent(event); } - @Nullable @Override public View onCreatePanelView(int featureId) { + @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) { + @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) { + @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) { + @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) { + @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) { + @Override + public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { if (delegate == null) { return; } delegate.onWindowAttributesChanged(attrs); } - @Override public void onContentChanged() { + @Override + public void onContentChanged() { if (delegate == null) { return; } delegate.onContentChanged(); } - @Override public void onWindowFocusChanged(boolean hasFocus) { + @Override + public void onWindowFocusChanged(boolean hasFocus) { if (delegate == null) { return; } delegate.onWindowFocusChanged(hasFocus); } - @Override public void onAttachedToWindow() { + @Override + public void onAttachedToWindow() { if (delegate == null) { return; } delegate.onAttachedToWindow(); } - @Override public void onDetachedFromWindow() { + @Override + public void onDetachedFromWindow() { if (delegate == null) { return; } delegate.onDetachedFromWindow(); } - @Override public void onPanelClosed(int featureId, @NotNull Menu menu) { + @Override + public void onPanelClosed(int featureId, @NotNull Menu menu) { if (delegate == null) { return; } delegate.onPanelClosed(featureId, menu); } - @Override public boolean onSearchRequested() { + @Override + public boolean onSearchRequested() { if (delegate == null) { return false; } @@ -178,14 +190,17 @@ public FixedWindowCallback(@Nullable Window.Callback delegate) { } @SuppressLint("NewApi") - @Override public boolean onSearchRequested(SearchEvent searchEvent) { + @Override + public boolean onSearchRequested(SearchEvent searchEvent) { if (delegate == null) { return false; } return delegate.onSearchRequested(searchEvent); } - @Nullable @Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { if (delegate == null) { return null; } @@ -193,23 +208,25 @@ public FixedWindowCallback(@Nullable Window.Callback delegate) { } @SuppressLint("NewApi") - @Nullable @Override - public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, - int type) { + @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) { + @Override + public void onActionModeStarted(ActionMode mode) { if (delegate == null) { return; } delegate.onActionModeStarted(mode); } - @Override public void onActionModeFinished(ActionMode mode) { + @Override + public void onActionModeFinished(ActionMode mode) { if (delegate == null) { return; } @@ -217,8 +234,9 @@ public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, } @SuppressLint("NewApi") - @Override public void onProvideKeyboardShortcuts(List data, - @Nullable Menu menu, int deviceId) { + @Override + public void onProvideKeyboardShortcuts( + List data, @Nullable Menu menu, int deviceId) { if (delegate == null) { return; } @@ -226,7 +244,8 @@ public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, } @SuppressLint("NewApi") - @Override public void onPointerCaptureChanged(boolean hasCapture) { + @Override + public void onPointerCaptureChanged(boolean hasCapture) { if (delegate == null) { return; } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 92a1fcd824..1147778d03 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5126,6 +5126,166 @@ public final class io/sentry/rrweb/RRWebEventType$Deserializer : io/sentry/JsonD 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 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 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_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 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 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 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 diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java index 89b945504b..aff3c55ac3 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -3,17 +3,11 @@ 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 abstract class RRWebIncrementalSnapshotEvent extends RRWebEvent { @@ -38,14 +32,14 @@ public enum IncrementalSource implements JsonSerializable { @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + 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 { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { return IncrementalSource.values()[reader.nextInt()]; } } @@ -73,21 +67,21 @@ public static final class JsonKeys { public static final class Serializer { public void serialize( - final @NotNull RRWebIncrementalSnapshotEvent baseEvent, - final @NotNull ObjectWriter writer, - final @NotNull ILogger logger) - throws IOException { + 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 { + 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( diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java index e08feb52b2..e75d5d0781 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -14,8 +14,8 @@ import org.jetbrains.annotations.Nullable; @SuppressWarnings("SameNameButDifferent") -public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent implements - JsonSerializable, JsonUnknown { +public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { public enum InteractionType implements JsonSerializable { MouseUp, @@ -32,20 +32,19 @@ public enum InteractionType implements JsonSerializable { @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + 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 { + 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; @@ -139,8 +138,8 @@ public static final class JsonKeys { public static final String POINTER_TYPE = "pointerType"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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); @@ -156,7 +155,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.TYPE).value(logger, interactionType); @@ -174,12 +173,11 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL writer.endObject(); } - public static final class Deserializer implements - JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull RRWebInteractionEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + public @NotNull RRWebInteractionEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -209,13 +207,14 @@ public static final class Deserializer implements } private void deserializeData( - final @NotNull RRWebInteractionEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + 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(); + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java index 9edf7e836b..86eb5e33e3 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -15,8 +15,8 @@ import org.jetbrains.annotations.Nullable; @SuppressWarnings("SameNameButDifferent") -public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent implements - JsonSerializable, JsonUnknown { +public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { public static final class Position implements JsonSerializable, JsonUnknown { @@ -82,8 +82,9 @@ public static final class JsonKeys { public static final String TIME_OFFSET = "timeOffset"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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); @@ -99,11 +100,11 @@ public static final class JsonKeys { writer.endObject(); } - public static final class Deserializer implements - JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -187,8 +188,8 @@ public static final class JsonKeys { public static final String POSITIONS = "positions"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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); @@ -204,7 +205,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); if (positions != null && !positions.isEmpty()) { @@ -220,12 +221,11 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL writer.endObject(); } - public static final class Deserializer implements - JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull RRWebInteractionMoveEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + public @NotNull RRWebInteractionMoveEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -255,13 +255,14 @@ public static final class Deserializer implements } private void deserializeData( - final @NotNull RRWebInteractionMoveEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + 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(); + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt index 43d8fc7658..5df216337d 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt @@ -2,7 +2,6 @@ package io.sentry.rrweb import io.sentry.ILogger import io.sentry.protocol.SerializationUtils -import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import io.sentry.rrweb.RRWebInteractionMoveEvent.Position import org.junit.Test import org.mockito.kotlin.mock From 234b78997156ca76d3e421cbdaebb3adfe2954f3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Apr 2024 16:08:51 +0200 Subject: [PATCH 112/184] Revert --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 4236adbc0e..fc33c0ca59 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -234,7 +234,7 @@ internal abstract class BaseCaptureStrategy( } breadcrumb.type == "system" -> { - breadcrumbCategory = null + breadcrumbCategory = breadcrumb.type!! breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" } From b3ee6598b5d6e8c47c16d8ba04b6725bc5321fa7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 26 Apr 2024 11:54:59 +0200 Subject: [PATCH 113/184] Changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d54fafd87a..328975a486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) + +We released our second 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 + ### Features - Add start_type to app context ([#3379](https://github.com/getsentry/sentry-java/pull/3379)) From e89a3ef162096b10139e5797563a31c48f684e19 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Fri, 26 Apr 2024 09:59:28 +0000 Subject: [PATCH 114/184] release: 7.9.0-alpha.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 328975a486..9631fb988a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.9.0-alpha.1 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) diff --git a/gradle.properties b/gradle.properties index 98b65810b4..7c60a7b423 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.8.0-alpha.0 +versionName=7.9.0-alpha.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 2c0977b3de0c8e320797d2d06c8040750c99ce3c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 26 Apr 2024 16:54:40 +0200 Subject: [PATCH 115/184] Fix test --- sentry/src/test/resources/json/sentry_replay_event.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json index dea5a99e59..f026c9fee4 100644 --- a/sentry/src/test/resources/json/sentry_replay_event.json +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -35,7 +35,8 @@ "CAMERA": "granted" }, "in_foreground": true, - "view_names": ["MainActivity", "SidebarActivity"] + "view_names": ["MainActivity", "SidebarActivity"], + "start_type": "cold" }, "browser": { From 5c05b6f52d17aa6d77a3e7953478c3d8b7a1c1d3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 1 May 2024 20:32:58 +0200 Subject: [PATCH 116/184] WIP --- .../android/replay/ScreenshotRecorder.kt | 90 ++++++++-- .../replay/capture/BaseCaptureStrategy.kt | 3 + .../io/sentry/android/replay/util/Views.kt | 75 ++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 160 +++++++----------- .../java/io/sentry/SentryReplayOptions.java | 4 +- 5 files changed, 219 insertions(+), 113 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index c9b6846529..00e099a993 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -5,6 +5,7 @@ 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 @@ -15,6 +16,7 @@ import android.os.Build.VERSION_CODES import android.os.Handler import android.os.HandlerThread import android.os.Looper +import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -25,12 +27,17 @@ 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.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.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime @TargetApi(26) internal class ScreenshotRecorder( @@ -57,6 +64,7 @@ internal class ScreenshotRecorder( private val isCapturing = AtomicBoolean(true) private var lastScreenshot: Bitmap? = null + @OptIn(ExperimentalTime::class) fun capture() { val viewHierarchy = pendingViewHierarchy.getAndSet(null) @@ -96,6 +104,14 @@ internal class ScreenshotRecorder( // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { +// val viewHierarchy: ViewHierarchyNode +// val time = measureTime { +// val rootNode = ViewHierarchyNode.fromView(root, options) +// root.traverse(rootNode) +// viewHierarchy = rootNode +// } +// Log.e("Recorder", "Time to get view hierarchy: $time") + try { PixelCopy.request( window, @@ -122,21 +138,61 @@ internal class ScreenshotRecorder( ) val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) - viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - // TODO: check for view type rather than rely on absence of dominantColor here - val color = if (it.dominantColor == null) { - singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) - singlePixelBitmap.getPixel(0, 0) - } else { - it.dominantColor + viewHierarchy.traverse { node -> + if (node.shouldRedact && (node.width > 0 && node.height > 0)) { + node.visibleRect ?: return@traverse false + + var isObscured = false + viewHierarchy.traverse innerTraverse@{ otherNode -> + otherNode.visibleRect ?: return@innerTraverse false + + if (!otherNode.visibleRect.contains(node.visibleRect)) { + return@innerTraverse false + } + + if (otherNode.elevation > node.elevation) { + isObscured = true + return@innerTraverse false + } + return@innerTraverse true + } + + if (isObscured) { + return@traverse true + } + // TODO: iterate the rest of the tree and check if the view is + // TODO: obscured by any of those, isVisibleToUser does not + // TODO: consider elevation. Basically search for views with + // TODO: higher elevation and check if their visibleRect contains + // TODO: the one of the current view + val (visibleRects, color) = when (node) { + is ImageViewHierarchyNode -> { + singlePixelBitmapCanvas.drawBitmap( + bitmap, + node.visibleRect, + Rect(0, 0, 1, 1), + null + ) + listOf(node.visibleRect) to singlePixelBitmap.getPixel(0, 0) + } + 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) - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + visibleRects.forEach { rect -> + canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) + } } + return@traverse true } } @@ -206,11 +262,13 @@ internal class ScreenshotRecorder( thread.quitSafely() } - private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { - callback(this) - if (this.children != null) { - this.children!!.forEach { - it.traverse(callback) + private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Boolean) { + val traverseChildren = callback(this) + if (traverseChildren) { + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fc33c0ca59..068d0fa188 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -128,6 +128,8 @@ internal abstract class BaseCaptureStrategy( width: Int, replayType: ReplayType = SESSION ): ReplaySegment { + return ReplaySegment.Failed + val generatedVideo = cache?.createVideoOf( duration, currentSegmentTimestamp.time, @@ -136,6 +138,7 @@ internal abstract class BaseCaptureStrategy( width ) ?: return ReplaySegment.Failed + val (video, frameCount, videoDuration) = generatedVideo return buildReplay( video, 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 0000000000..9e4ac8ec11 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -0,0 +1,75 @@ +package io.sentry.android.replay.util + +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.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 + // 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 || view.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 +} + +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) + } + // TODO: actually not sure - maybe the old way is not that bad, because multiline rects can seem noisy + 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/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 3db9731171..9c25bc4758 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -2,133 +2,103 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi 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 -import android.view.accessibility.AccessibilityNodeInfo 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 -// TODO: merge with ViewHierarchyNode from sentry-core maybe? @TargetApi(26) -data class ViewHierarchyNode( +sealed class ViewHierarchyNode( val x: Float, val y: Float, val width: Int, val height: Int, + val elevation: Float, val shouldRedact: Boolean = false, - val dominantColor: Int? = null, val visibleRect: Rect? = null ) { - var children: List? = null - companion object { + class GenericViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + shouldRedact: Boolean = false, + visibleRect: Rect? = null + ): ViewHierarchyNode(x, y, width, height, elevation, shouldRedact, visibleRect) - private fun isVisible(view: View?): Boolean { - if (view == null || !view.isShown) { - return false - } - val actualPosition = Rect() - view.getGlobalVisibleRect(actualPosition) - val screen = Rect( - 0, - 0, - view.context.resources.displayMetrics.widthPixels, - view.context.resources.displayMetrics.heightPixels - ) - return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) - } + 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, + shouldRedact: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, shouldRedact, visibleRect) + + class ImageViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + shouldRedact: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, shouldRedact, visibleRect) + + companion object { - // TODO: check if this works on RN private fun Int.toOpaque() = this or 0xFF000000.toInt() fun fromView(view: View, options: SentryOptions): ViewHierarchyNode { - // TODO: Extract redacting into its own class/function - // TODO: extract redacting into a separate thread? - var shouldRedact = false - var dominantColor: Int? = null - var rect: Rect? = null when { view is TextView && options.experimental.sessionReplay.redactAllText -> { - // TODO: API level check - // TODO: perhaps this is heavy, might reconsider - val nodeInfo = if (VERSION.SDK_INT >= VERSION_CODES.R) { - AccessibilityNodeInfo() - } else { - AccessibilityNodeInfo.obtain() - } - view.onInitializeAccessibilityNodeInfo(nodeInfo) - shouldRedact = nodeInfo.isVisibleToUser - nodeInfo.recycle() - if (shouldRedact) { - val bounds = Rect() - val text = view.text.toString() - view.paint.getTextBounds(text, 0, text.length, bounds) - dominantColor = view.currentTextColor.toOpaque() - rect = Rect() - view.getGlobalVisibleRect(rect) - - var textEnd = Int.MIN_VALUE - var textStart = Int.MAX_VALUE - if (view.layout != null) { - for (i in 0 until view.layout.lineCount) { - val min = view.layout.getLineStart(i) - val minPosition = view.layout.getPrimaryHorizontal(min).toInt() - val max = view.layout.getLineVisibleEnd(i) - val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() - if (minPosition < textStart) { - textStart = minPosition - } - if (maxPosition > textEnd) { - textEnd = maxPosition - } - } - } else { - textEnd = rect.right - rect.left - textStart = 0 - } - // TODO: support known 3rd-party widgets like MaterialButton with an icon - // TODO: also calculate height properly based on text bounds - rect.left += textStart + view.paddingStart - rect.right = rect.left + (textEnd - textStart) - } + val (isVisible, visibleRect) = view.isVisibleToUser() + 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 = view.elevation, + shouldRedact = isVisible, + visibleRect = visibleRect + ) } view is ImageView && options.experimental.sessionReplay.redactAllImages -> { - shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) - if (shouldRedact) { - rect = Rect() - view.getGlobalVisibleRect(rect) - } + val (isVisible, visibleRect) = view.isVisibleToUser() + return ImageViewHierarchyNode( + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = view.elevation, + shouldRedact = isVisible && view.drawable?.isRedactable() == true, + visibleRect = visibleRect + ) } } - return ViewHierarchyNode( + return GenericViewHierarchyNode( view.x, view.y, view.width, view.height, - shouldRedact, - dominantColor, - rect + view.elevation ) } - - private 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 - } - } } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 4c58cf2e86..d8f6930543 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -110,7 +110,7 @@ public boolean getRedactAllText() { } public void setRedactAllText(final boolean redactAllText) { - this.redactAllText = redactAllText; + //this.redactAllText = redactAllText; } public boolean getRedactAllImages() { @@ -118,7 +118,7 @@ public boolean getRedactAllImages() { } public void setRedactAllImages(final boolean redactAllImages) { - this.redactAllImages = redactAllImages; + //this.redactAllImages = redactAllImages; } @ApiStatus.Internal From 0476132d65d1da7278fe37b6bd65b8c82039bbf4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 May 2024 23:55:55 +0200 Subject: [PATCH 117/184] Adhere to rrweb move event expectations --- .../replay/capture/BaseCaptureStrategy.kt | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fc33c0ca59..8ed448e2c3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -30,6 +30,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.util.Date +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory @@ -48,7 +49,6 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" - private const val DEBOUNCE_TIMEOUT = 200 private val snakecasePattern = "_[a-z]".toRegex() private val supportedNetworkData = setOf( "status_code", @@ -58,6 +58,9 @@ internal abstract class BaseCaptureStrategy( "http.response_content_length", "http.request_content_length" ) + // rrweb values + private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 + private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 } protected var cache: ReplayCache? = null @@ -66,8 +69,11 @@ internal abstract class BaseCaptureStrategy( override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir - private val currentEvents = mutableListOf() - private val lastExecutionTime = AtomicLong(0) + + private val currentEvents = CopyOnWriteArrayList() + private val currentPositions = mutableListOf() + private var touchMoveBaseline = 0L + private var lastCapturedMoveEvent = 0L protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -281,6 +287,7 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { + // TODO: rotate in buffer mode val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() if (rrwebEvent != null) { currentEvents += rrwebEvent @@ -363,30 +370,47 @@ internal abstract class BaseCaptureStrategy( return when (val action = event.actionMasked) { MotionEvent.ACTION_MOVE -> { // we only throttle move events as those can be overwhelming - val now = dateProvider.getCurrentTimeMillis() - if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { + val now = dateProvider.currentTimeMillis + if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { return null } - lastExecutionTime.set(now) + lastCapturedMoveEvent = now - RRWebInteractionMoveEvent().apply { - timestamp = dateProvider.currentTimeMillis - positions = listOf( - Position().apply { - x = event.x * recorderConfig.scaleFactorX - y = event.y * recorderConfig.scaleFactorY - id = event.getPointerId(event.actionIndex) - timeOffset = 0 // TODO: is this needed? + // idk why but rrweb does it like dis + if (touchMoveBaseline == 0L) { + touchMoveBaseline = now + } + + currentPositions += Position().apply { + 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 + timeOffset = now - touchMoveBaseline + } + + val totalOffset = now - touchMoveBaseline + return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + RRWebInteractionMoveEvent().apply { + timestamp = now + positions = currentPositions.map { pos -> + pos.timeOffset -= totalOffset + pos } - ) // TODO: support multiple pointers + }.also { + currentPositions.clear() + touchMoveBaseline = 0L + } + } else { + null } } + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { RRWebInteractionEvent().apply { timestamp = dateProvider.currentTimeMillis x = event.x * recorderConfig.scaleFactorX y = event.y * recorderConfig.scaleFactorY - id = event.getPointerId(event.actionIndex) + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE interactionType = when (action) { MotionEvent.ACTION_UP -> InteractionType.TouchEnd MotionEvent.ACTION_DOWN -> InteractionType.TouchStart @@ -395,6 +419,7 @@ internal abstract class BaseCaptureStrategy( } } } + else -> null } } From 7823d87aad8ad98470e37dd6081e3545e1862ea1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 May 2024 23:58:12 +0200 Subject: [PATCH 118/184] formatting --- .../java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 8ed448e2c3..fad8aaa67a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -58,6 +58,7 @@ internal abstract class BaseCaptureStrategy( "http.response_content_length", "http.request_content_length" ) + // rrweb values private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 From 9596bb93199a1032cb255a62bea32020bd04c9ac Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 3 May 2024 17:31:48 +0200 Subject: [PATCH 119/184] Align breadcrumbs with frontend and iOS --- .../api/sentry-android-core.api | 2 + .../sentry/android/core/DeviceInfoUtil.java | 11 ++- .../SystemEventsBreadcrumbsIntegration.java | 73 +++++++++++++------ .../SystemEventsBreadcrumbsIntegrationTest.kt | 60 +++++++++++++++ .../replay/capture/BaseCaptureStrategy.kt | 63 ++++++++++++---- sentry/api/sentry.api | 9 +++ .../src/main/java/io/sentry/SentryLevel.java | 2 +- .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 22 ++++++ .../RRWebBreadcrumbEventSerializationTest.kt | 2 + .../test/resources/json/replay_recording.json | 2 +- .../json/rrweb_breadcrumb_event.json | 1 + 11 files changed, 202 insertions(+), 45 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8eb017346d..9ca53b233c 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -183,9 +183,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/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 8c5d661524..f1debc5d23 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/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 1c22a7dcc8..dcd92e8bf8 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/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index f8293f9b87..3dfca15fdb 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-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fad8aaa67a..599d89c093 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -6,6 +6,7 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.ReplayRecording +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType @@ -201,12 +202,12 @@ internal abstract class BaseCaptureStrategy( hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.after(segmentTimestamp) && - breadcrumb.timestamp.before(endTimestamp) + if (breadcrumb.timestamp.time >= segmentTimestamp.time && + breadcrumb.timestamp.time < endTimestamp.time ) { - // TODO: rework this later when aligned with iOS and frontend var breadcrumbMessage: String? = null - val breadcrumbCategory: String? + var breadcrumbCategory: String? = null + var breadcrumbLevel: SentryLevel? = null val breadcrumbData = mutableMapOf() when { breadcrumb.category == "http" -> { @@ -216,39 +217,68 @@ internal abstract class BaseCaptureStrategy( return@forEach } - breadcrumb.category == "device.orientation" -> { + breadcrumb.type == "navigation" && + breadcrumb.category == "app.lifecycle" -> { + breadcrumbCategory = "app.${breadcrumb.data["state"]}" + } + + breadcrumb.type == "navigation" && + breadcrumb.category == "device.orientation" -> { breadcrumbCategory = breadcrumb.category!! - breadcrumbMessage = breadcrumb.data["position"] as? String ?: "" + val position = breadcrumb.data["position"] + if (position == "landscape" || position == "portrait") { + breadcrumbData["position"] = position + } else { + return@forEach + } } breadcrumb.type == "navigation" -> { breadcrumbCategory = "navigation" breadcrumbData["to"] = when { - breadcrumb.data["state"] == "resumed" -> breadcrumb.data["screen"] as? String - breadcrumb.category == "app.lifecycle" -> breadcrumb.data["state"] as? String + breadcrumb.data["state"] == "resumed" -> (breadcrumb.data["screen"] as? String)?.substringAfterLast('.') "to" in breadcrumb.data -> breadcrumb.data["to"] as? String else -> return@forEach } ?: return@forEach } - breadcrumb.category in setOf("ui.click", "ui.scroll", "ui.swipe") -> { - breadcrumbCategory = breadcrumb.category!! + breadcrumb.category == "ui.click" -> { + breadcrumbCategory = "ui.tap" breadcrumbMessage = ( breadcrumb.data["view.id"] - ?: breadcrumb.data["view.class"] ?: breadcrumb.data["view.tag"] - ) as? String ?: "" + ?: breadcrumb.data["view.class"] + ) as? String ?: return@forEach + breadcrumbData.putAll(breadcrumb.data) } - breadcrumb.type == "system" -> { - breadcrumbCategory = breadcrumb.type!! - breadcrumbMessage = - breadcrumb.data.entries.joinToString() as? String ?: "" + 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@forEach + } + else -> return@forEach + } + } + + 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) } } if (!breadcrumbCategory.isNullOrEmpty()) { @@ -258,6 +288,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbType = "default" category = breadcrumbCategory message = breadcrumbMessage + level = breadcrumbLevel data = breadcrumbData } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 68c12600ab..2e73a86168 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2255,6 +2255,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 @@ -5049,6 +5055,7 @@ public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebE 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; @@ -5059,6 +5066,7 @@ public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebE 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 @@ -5074,6 +5082,7 @@ public final class io/sentry/rrweb/RRWebBreadcrumbEvent$Deserializer : io/sentry 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; diff --git a/sentry/src/main/java/io/sentry/SentryLevel.java b/sentry/src/main/java/io/sentry/SentryLevel.java index f1c6e04cb8..76b07c6b37 100644 --- a/sentry/src/main/java/io/sentry/SentryLevel.java +++ b/sentry/src/main/java/io/sentry/SentryLevel.java @@ -18,7 +18,7 @@ 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 ObjectReader reader, @NotNull ILogger logger) diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index d98e91c0e5..6fb269c405 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -6,6 +6,7 @@ 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; @@ -25,6 +26,7 @@ public final class RRWebBreadcrumbEvent extends RRWebEvent 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": { ... } } } @@ -81,6 +83,15 @@ 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; @@ -126,6 +137,7 @@ public static final class JsonKeys { 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 @@ -173,6 +185,9 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull 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); } @@ -272,6 +287,13 @@ private void deserializePayload( 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( diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt index cf711fd27f..9dfffef8d2 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt @@ -1,6 +1,7 @@ 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 @@ -16,6 +17,7 @@ class RRWebBreadcrumbEventSerializationTest { breadcrumbTimestamp = 12345678.901 category = "navigation" message = "message" + level = INFO data = mapOf( "screen" to "MainActivity", "state" to "resumed" diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index fac90d3803..37f2f74330 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,2 +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","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":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"}}}}] diff --git a/sentry/src/test/resources/json/rrweb_breadcrumb_event.json b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json index f41c6cb20d..e1fbe676fa 100644 --- a/sentry/src/test/resources/json/rrweb_breadcrumb_event.json +++ b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json @@ -8,6 +8,7 @@ "timestamp": 12345678.901, "category": "navigation", "message": "message", + "level": "info", "data": { "screen": "MainActivity", "state": "resumed" From 733b490df5bb3ed7d6af295101b66402a0e64ad9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 6 May 2024 13:09:49 +0200 Subject: [PATCH 120/184] Add tests and fix deserialization --- .../main/java/io/sentry/ReplayRecording.java | 47 +++++++++++-- .../java/io/sentry/util/MapObjectReader.java | 70 ++++++++++++++++++- .../ReplayRecordingSerializationTest.kt | 6 +- .../io/sentry/util/MapObjectReaderTest.kt | 10 +++ .../test/resources/json/replay_recording.json | 2 +- 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index d04469af7d..84b6166a13 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -3,6 +3,9 @@ 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; @@ -141,20 +144,54 @@ public static final class Deserializer implements JsonDeserializer entry : eventMap.entrySet()) { final String key = entry.getKey(); final Object value = entry.getValue(); - if (key.equals("type")) { + if (key.equals(RRWebEvent.JsonKeys.TYPE)) { final RRWebEventType type = RRWebEventType.values()[(int) value]; switch (type) { + case IncrementalSnapshot: + 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: - Map data = (Map) eventMap.get("data"); - if (data == null) { - data = Collections.emptyMap(); + Map customData = (Map) eventMap.get("data"); + if (customData == null) { + customData = Collections.emptyMap(); } - final String tag = (String) data.get(RRWebEvent.JsonKeys.TAG); + final String tag = (String) customData.get(RRWebEvent.JsonKeys.TAG); if (tag != null) { switch (tag) { case RRWebVideoEvent.EVENT_TAG: diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index fdb6ccff2b..b04fbb9675 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -8,8 +8,10 @@ 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; @@ -41,7 +43,27 @@ public void nextUnknown( public List nextListOrNull( final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) throws IOException { - return nextValueOrNull(); + 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 @@ -49,13 +71,55 @@ public List nextListOrNull( public Map nextMapOrNull( final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) throws IOException { - return nextValueOrNull(); + 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 { - return nextValueOrNull(); + 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 diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index b7dfa96c4e..cff08ee2ab 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -6,6 +6,8 @@ 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 @@ -23,7 +25,9 @@ class ReplayRecordingSerializationTest { RRWebMetaEventSerializationTest.Fixture().getSut(), RRWebVideoEventSerializationTest.Fixture().getSut(), RRWebBreadcrumbEventSerializationTest.Fixture().getSut(), - RRWebSpanEventSerializationTest.Fixture().getSut() + RRWebSpanEventSerializationTest.Fixture().getSut(), + RRWebInteractionEventSerializationTest.Fixture().getSut(), + RRWebInteractionMoveEventSerializationTest.Fixture().getSut() ) } } diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt index ad2c2344a1..a335fc71f8 100644 --- a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -75,10 +75,20 @@ class MapObjectReaderTest { 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())) diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index fac90d3803..f92c4d7cb9 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,2 +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","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":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","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}},{"type":3,"timestamp":12345678901,"data":{"source":6,"positions":[{"id":1,"x":1.0,"y":2.0,"timeOffset":100}]}}] From 1217bb1ac104c76a5311c23f628e13aa328b6c7a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 6 May 2024 13:19:40 +0200 Subject: [PATCH 121/184] Rotate buffered motion events in buffer mode --- .../sentry/android/replay/capture/BaseCaptureStrategy.kt | 3 +-- .../sentry/android/replay/capture/BufferCaptureStrategy.kt | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fad8aaa67a..e245022750 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -71,7 +71,7 @@ internal abstract class BaseCaptureStrategy( override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir - private val currentEvents = CopyOnWriteArrayList() + protected val currentEvents = CopyOnWriteArrayList() private val currentPositions = mutableListOf() private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -288,7 +288,6 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - // TODO: rotate in buffer mode val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() if (rrwebEvent != null) { currentEvents += rrwebEvent diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 2d69852abf..12af281c45 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.view.MotionEvent import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -171,4 +172,10 @@ internal class BufferCaptureStrategy( 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 + currentEvents.removeAll { it.timestamp < bufferLimit } + } } From 69e5144c03e6499bcc897a3b34d2790e2848d318 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 11:01:51 +0200 Subject: [PATCH 122/184] Add Nullables --- sentry/src/main/java/io/sentry/ReplayRecording.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 84b6166a13..55595ebbc5 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -148,7 +148,7 @@ public static final class Deserializer implements JsonDeserializer incrementalData = + @Nullable Map incrementalData = (Map) eventMap.get("data"); if (incrementalData == null) { incrementalData = Collections.emptyMap(); @@ -187,7 +187,7 @@ public static final class Deserializer implements JsonDeserializer customData = (Map) eventMap.get("data"); + @Nullable Map customData = (Map) eventMap.get("data"); if (customData == null) { customData = Collections.emptyMap(); } From a3d581c6780050fb90b76b823fffee3be7a49d78 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 12:57:41 +0200 Subject: [PATCH 123/184] Address PR feedback --- .../sentry/android/replay/WindowRecorder.kt | 2 +- .../replay/capture/BaseCaptureStrategy.kt | 31 ++++++++++++++----- .../replay/capture/BufferCaptureStrategy.kt | 2 +- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 2ee60d7466..09f498329d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -137,7 +137,7 @@ internal class WindowRecorder( ) : FixedWindowCallback(delegate) { override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event != null) { - val copy: MotionEvent = MotionEvent.obtain(event) + val copy: MotionEvent = MotionEvent.obtainNoHistory(event) try { touchRecorderCallback?.onTouchEvent(copy) } catch (e: Throwable) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index e245022750..eb5bb53696 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -30,7 +30,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.util.Date -import java.util.concurrent.CopyOnWriteArrayList +import java.util.LinkedList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory @@ -71,7 +71,8 @@ internal abstract class BaseCaptureStrategy( override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir - protected val currentEvents = CopyOnWriteArrayList() + protected val currentEvents = LinkedList() + private val currentEventsLock = Any() private val currentPositions = mutableListOf() private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -264,11 +265,11 @@ internal abstract class BaseCaptureStrategy( } } } - currentEvents.removeAll { - if (it.timestamp > segmentTimestamp.time && it.timestamp < endTimestamp.time) { - recordingPayload += it + + rotateCurrentEvents(endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event } - it.timestamp < endTimestamp.time } val recording = ReplayRecording().apply { @@ -290,7 +291,9 @@ internal abstract class BaseCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() if (rrwebEvent != null) { - currentEvents += rrwebEvent + synchronized(currentEventsLock) { + currentEvents += rrwebEvent + } } } @@ -298,6 +301,20 @@ internal abstract class BaseCaptureStrategy( 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 { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 12af281c45..b365831d7a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -176,6 +176,6 @@ internal class BufferCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration - currentEvents.removeAll { it.timestamp < bufferLimit } + rotateCurrentEvents(bufferLimit) } } From d93e6095306ee8a0b6b3708a6108dba284aa824f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 12:58:24 +0200 Subject: [PATCH 124/184] Formatting --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- sentry/src/main/java/io/sentry/ReplayRecording.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index eb5bb53696..aac08c53e5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -303,7 +303,7 @@ internal abstract class BaseCaptureStrategy( protected fun rotateCurrentEvents( until: Long, - callback: ((RRWebEvent) -> Unit)? = null, + callback: ((RRWebEvent) -> Unit)? = null ) { synchronized(currentEventsLock) { var event = currentEvents.peek() diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 55595ebbc5..ca1c676dbd 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -148,7 +148,8 @@ public static final class Deserializer implements JsonDeserializer incrementalData = + @Nullable + Map incrementalData = (Map) eventMap.get("data"); if (incrementalData == null) { incrementalData = Collections.emptyMap(); @@ -187,7 +188,8 @@ public static final class Deserializer implements JsonDeserializer customData = (Map) eventMap.get("data"); + @Nullable + Map customData = (Map) eventMap.get("data"); if (customData == null) { customData = Collections.emptyMap(); } From b9b78df08f5098360c0727e023998e2ecfb5674e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 13:19:22 +0200 Subject: [PATCH 125/184] Rotate current events until segment end exclusively --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index d38c7126c9..e97eb80ebf 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -338,7 +338,7 @@ internal abstract class BaseCaptureStrategy( ) { synchronized(currentEventsLock) { var event = currentEvents.peek() - while (event != null && event.timestamp <= until) { + while (event != null && event.timestamp < until) { callback?.invoke(event) currentEvents.remove() event = currentEvents.peek() From f37c593b1bb00e2f529a9a7291aa2e16b21a515b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 May 2024 14:38:11 +0200 Subject: [PATCH 126/184] Allow rrweb breadcrumb customization from hybrid SDKs --- .../core/AndroidOptionsInitializer.java | 2 + .../api/sentry-android-replay.api | 7 + .../DefaultReplayBreadcrumbConverter.kt | 148 ++++++++++++++++++ .../android/replay/ReplayIntegration.kt | 9 ++ .../replay/capture/BaseCaptureStrategy.kt | 141 +---------------- sentry/api/sentry.api | 13 ++ .../sentry/NoOpReplayBreadcrumbConverter.java | 21 +++ .../java/io/sentry/NoOpReplayController.java | 8 + .../io/sentry/ReplayBreadcrumbConverter.java | 12 ++ .../main/java/io/sentry/ReplayController.java | 5 + 10 files changed, 231 insertions(+), 135 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt create mode 100644 sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java create mode 100644 sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java 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 2e83c49700..2d559fd781 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,7 @@ 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; @@ -308,6 +309,7 @@ static void installDefaultIntegrations( 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-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index ffaf60382c..b8a8d4a778 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,6 +6,11 @@ public final class io/sentry/android/replay/BuildConfig { 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; @@ -42,6 +47,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 @@ -55,6 +61,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 } 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 0000000000..4bf7f1d140 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -0,0 +1,148 @@ +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 + +public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { + internal companion object { + private val snakecasePattern = "_[a-z]".toRegex() + private val supportedNetworkData = setOf( + "status_code", + "method", + "response_content_length", + "request_content_length", + "http.response_content_length", + "http.request_content_length" + ) + } + + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { + var rrwebBreadcrumb: RRWebBreadcrumbEvent? = null + + 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 + } + } + + 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) + } + } + if (!breadcrumbCategory.isNullOrEmpty()) { + rrwebBreadcrumb = RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 + breadcrumbType = "default" + category = breadcrumbCategory + message = breadcrumbMessage + level = breadcrumbLevel + data = breadcrumbData + } + } + return rrwebBreadcrumb + } + + 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/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 0a6418514b..c1ee1f9ae8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -9,6 +9,8 @@ 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.SentryEvent import io.sentry.SentryIntegrationPackageStorage @@ -54,6 +56,7 @@ public class ReplayIntegration( 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 @@ -158,6 +161,12 @@ public class ReplayIntegration( 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index e97eb80ebf..d0c8bc226d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -1,23 +1,19 @@ package io.sentry.android.replay.capture import android.view.MotionEvent -import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.ReplayRecording -import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.SESSION -import io.sentry.SpanDataConvention 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 @@ -25,7 +21,6 @@ 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.RRWebSpanEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -50,15 +45,6 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" - private val snakecasePattern = "_[a-z]".toRegex() - private val supportedNetworkData = setOf( - "status_code", - "method", - "response_content_length", - "request_content_length", - "http.response_content_length", - "http.request_content_length" - ) // rrweb values private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 @@ -206,92 +192,13 @@ internal abstract class BaseCaptureStrategy( if (breadcrumb.timestamp.time >= segmentTimestamp.time && breadcrumb.timestamp.time < endTimestamp.time ) { - var breadcrumbMessage: String? = null - var breadcrumbCategory: String? = null - var breadcrumbLevel: SentryLevel? = null - val breadcrumbData = mutableMapOf() - when { - breadcrumb.category == "http" -> { - if (breadcrumb.isValidForRRWebSpan()) { - recordingPayload += breadcrumb.toRRWebSpanEvent() - } - return@forEach - } - - 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@forEach - } - } - - 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 -> return@forEach - } ?: return@forEach - } - - breadcrumb.category == "ui.click" -> { - breadcrumbCategory = "ui.tap" - breadcrumbMessage = ( - breadcrumb.data["view.id"] - ?: breadcrumb.data["view.tag"] - ?: breadcrumb.data["view.class"] - ) as? String ?: return@forEach - breadcrumbData.putAll(breadcrumb.data) - } + val rrwebEvent = options + .replayController + .breadcrumbConverter + .convert(breadcrumb) - 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@forEach - } - else -> return@forEach - } - } - - 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) - } - } - if (!breadcrumbCategory.isNullOrEmpty()) { - recordingPayload += RRWebBreadcrumbEvent().apply { - timestamp = breadcrumb.timestamp.time - breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 - breadcrumbType = "default" - category = breadcrumbCategory - message = breadcrumbMessage - level = breadcrumbLevel - data = breadcrumbData - } + if (rrwebEvent != null) { + recordingPayload += rrwebEvent } } } @@ -377,42 +284,6 @@ internal abstract class BaseCaptureStrategy( } } - 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 - } - } - private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): RRWebIncrementalSnapshotEvent? { val event = this return when (val action = event.actionMasked) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6fa74a2ba5..d836a6c8a2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1251,7 +1251,13 @@ 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 @@ -1259,6 +1265,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { 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 } @@ -1656,13 +1663,19 @@ 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 } 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 0000000000..d71a57e440 --- /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 index a1a715318c..d365f650ea 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -42,4 +42,12 @@ public void sendReplay( 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/ReplayBreadcrumbConverter.java b/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java new file mode 100644 index 0000000000..dadd5d9b6f --- /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 index a85cdacc93..caaa847423 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -23,4 +23,9 @@ public interface ReplayController { @NotNull SentryId getReplayId(); + + void setBreadcrumbConverter(@NotNull ReplayBreadcrumbConverter converter); + + @NotNull + ReplayBreadcrumbConverter getBreadcrumbConverter(); } From 730dc66c7c1d1babbd113ca4bad2344f262c87ae Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 3 Jun 2024 11:39:17 +0200 Subject: [PATCH 127/184] Fix proguard rules --- sentry-android-core/proguard-rules.pro | 2 +- .../src/main/java/io/sentry/android/core/SentryAndroid.java | 4 +--- .../sentry/android/replay/DefaultReplayBreadcrumbConverter.kt | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index a78a5a14a1..0c6d47e5ec 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -75,6 +75,6 @@ ##---------------Begin: proguard configuration for sentry-android-replay ---------- -dontwarn io.sentry.android.replay.ReplayIntegration --dontwarn io.sentry.android.replay.ReplayIntegrationKt +-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/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 676bb2173a..02db612a5d 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,8 +36,6 @@ public final class SentryAndroid { static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = "io.sentry.android.replay.ReplayIntegration"; - private static boolean isReplayAvailable = false; - private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -104,7 +102,7 @@ public static synchronized void init( final boolean isTimberAvailable = (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); - isReplayAvailable = + final boolean isReplayAvailable = classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); 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 index 4bf7f1d140..3affe6f7a6 100644 --- 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 @@ -7,10 +7,11 @@ 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 = "_[a-z]".toRegex() + private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } private val supportedNetworkData = setOf( "status_code", "method", From 69b23cc20faae0ccd1c26efce5cc48e9ef45b7a2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 3 Jun 2024 14:08:25 +0200 Subject: [PATCH 128/184] WIP --- .../DefaultReplayBreadcrumbConverterTest.kt | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt 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 0000000000..650e3fe081 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -0,0 +1,217 @@ +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 junit.framework.TestCase.assertTrue +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"]) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) + assertEquals("default", rrwebEvent.breadcrumbType) + } + + @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"]) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) + assertEquals("default", rrwebEvent.breadcrumbType) + } + + @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"]) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) + assertEquals("default", rrwebEvent.breadcrumbType) + } + +// @Test +// fun `test convert with navigation and app lifecycle`() { +// val breadcrumb = Breadcrumb().apply { +// message = "message" +// category = "navigation" +// type = "navigation" +// level = SentryLevel.ERROR +// data["state"] = "resumed" +// data["screen"] = "screen" +// } +// +// val result = DefaultReplayBreadcrumbConverter().convert(breadcrumb) +// +// assertTrue(result is RRWebBreadcrumbEvent) +// assertEquals("navigation", result.category) +// assertEquals("navigation", result.type) +// assertEquals(SentryLevel.ERROR, result.level) +// assertEquals("resumed", result.data["state"]) +// assertEquals("screen", result.data["screen"]) +// } +// +// @Test +// fun `test convert with navigation and device orientation`() { +// val breadcrumb = Breadcrumb().apply { +// message = "message" +// category = "navigation" +// type = "navigation" +// level = SentryLevel.ERROR +// data["position"] = "landscape" +// } +// +// val result = DefaultReplayBreadcrumbConverter().convert(breadcrumb) +// +// assertTrue(result is RRWebBreadcrumbEvent) +// assertEquals("navigation", result.category) +// assertEquals("navigation", result.type) +// assertEquals(SentryLevel.ERROR, result.level) +// assertEquals("landscape", result.data["position"]) +// } +} From c2dcad5a50db9eb23567f192ef80801d9ee79913 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 3 Jun 2024 17:21:26 +0200 Subject: [PATCH 129/184] Add tests --- .../DefaultReplayBreadcrumbConverterTest.kt | 169 +++++++++++++----- 1 file changed, 120 insertions(+), 49 deletions(-) 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 index 650e3fe081..0dfb3d39c8 100644 --- 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 @@ -6,7 +6,6 @@ import io.sentry.SpanDataConvention import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebSpanEvent import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue import java.util.Date import kotlin.test.Test import kotlin.test.assertNull @@ -115,9 +114,6 @@ class DefaultReplayBreadcrumbConverterTest { check(rrwebEvent is RRWebBreadcrumbEvent) assertEquals("device.orientation", rrwebEvent.category) assertEquals("landscape", rrwebEvent.data!!["position"]) - assertEquals(123L, rrwebEvent.timestamp) - assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) - assertEquals("default", rrwebEvent.breadcrumbType) } @Test @@ -150,9 +146,6 @@ class DefaultReplayBreadcrumbConverterTest { check(rrwebEvent is RRWebBreadcrumbEvent) assertEquals("navigation", rrwebEvent.category) assertEquals("MainActivity", rrwebEvent.data!!["to"]) - assertEquals(123L, rrwebEvent.timestamp) - assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) - assertEquals("default", rrwebEvent.breadcrumbType) } @Test @@ -170,48 +163,126 @@ class DefaultReplayBreadcrumbConverterTest { check(rrwebEvent is RRWebBreadcrumbEvent) assertEquals("navigation", rrwebEvent.category) assertEquals("/github", rrwebEvent.data!!["to"]) - assertEquals(123L, rrwebEvent.timestamp) - assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) - assertEquals("default", rrwebEvent.breadcrumbType) } -// @Test -// fun `test convert with navigation and app lifecycle`() { -// val breadcrumb = Breadcrumb().apply { -// message = "message" -// category = "navigation" -// type = "navigation" -// level = SentryLevel.ERROR -// data["state"] = "resumed" -// data["screen"] = "screen" -// } -// -// val result = DefaultReplayBreadcrumbConverter().convert(breadcrumb) -// -// assertTrue(result is RRWebBreadcrumbEvent) -// assertEquals("navigation", result.category) -// assertEquals("navigation", result.type) -// assertEquals(SentryLevel.ERROR, result.level) -// assertEquals("resumed", result.data["state"]) -// assertEquals("screen", result.data["screen"]) -// } -// -// @Test -// fun `test convert with navigation and device orientation`() { -// val breadcrumb = Breadcrumb().apply { -// message = "message" -// category = "navigation" -// type = "navigation" -// level = SentryLevel.ERROR -// data["position"] = "landscape" -// } -// -// val result = DefaultReplayBreadcrumbConverter().convert(breadcrumb) -// -// assertTrue(result is RRWebBreadcrumbEvent) -// assertEquals("navigation", result.category) -// assertEquals("navigation", result.type) -// assertEquals(SentryLevel.ERROR, result.level) -// assertEquals("landscape", result.data["position"]) -// } + @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"]) + } } From d8fda33381ac4e01beddf74837f9127a5bb1fa90 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Jun 2024 17:28:10 +0200 Subject: [PATCH 130/184] Detect obscured views --- .../android/replay/ScreenshotRecorder.kt | 42 +--- .../io/sentry/android/replay/util/Views.kt | 9 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 231 +++++++++++++++++- 3 files changed, 233 insertions(+), 49 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 00e099a993..10b24df3b7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -16,7 +16,6 @@ import android.os.Build.VERSION_CODES import android.os.Handler import android.os.HandlerThread import android.os.Looper -import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -36,8 +35,6 @@ import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt -import kotlin.time.ExperimentalTime -import kotlin.time.measureTime @TargetApi(26) internal class ScreenshotRecorder( @@ -64,7 +61,6 @@ internal class ScreenshotRecorder( private val isCapturing = AtomicBoolean(true) private var lastScreenshot: Bitmap? = null - @OptIn(ExperimentalTime::class) fun capture() { val viewHierarchy = pendingViewHierarchy.getAndSet(null) @@ -142,29 +138,10 @@ internal class ScreenshotRecorder( if (node.shouldRedact && (node.width > 0 && node.height > 0)) { node.visibleRect ?: return@traverse false - var isObscured = false - viewHierarchy.traverse innerTraverse@{ otherNode -> - otherNode.visibleRect ?: return@innerTraverse false - - if (!otherNode.visibleRect.contains(node.visibleRect)) { - return@innerTraverse false - } - - if (otherNode.elevation > node.elevation) { - isObscured = true - return@innerTraverse false - } - return@innerTraverse true - } - - if (isObscured) { + if (viewHierarchy.isObscured(node)) { return@traverse true } - // TODO: iterate the rest of the tree and check if the view is - // TODO: obscured by any of those, isVisibleToUser does not - // TODO: consider elevation. Basically search for views with - // TODO: higher elevation and check if their visibleRect contains - // TODO: the one of the current view + val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { singlePixelBitmapCanvas.drawBitmap( @@ -221,7 +198,7 @@ internal class ScreenshotRecorder( return } - val rootNode = ViewHierarchyNode.fromView(root, options) + val rootNode = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(rootNode) pendingViewHierarchy.set(rootNode) @@ -262,17 +239,6 @@ internal class ScreenshotRecorder( thread.quitSafely() } - private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Boolean) { - val traverseChildren = callback(this) - if (traverseChildren) { - if (this.children != null) { - this.children!!.forEach { - it.traverse(callback) - } - } - } - } - private fun View.traverse(parentNode: ViewHierarchyNode) { if (this !is ViewGroup) { return @@ -286,7 +252,7 @@ internal class ScreenshotRecorder( for (i in 0 until childCount) { val child = getChildAt(i) if (child != null) { - val childNode = ViewHierarchyNode.fromView(child, options) + val childNode = ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) childNodes.add(childNode) child.traverse(childNode) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 9e4ac8ec11..a34fa6b28f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.util +import android.annotation.TargetApi import android.graphics.Point import android.graphics.Rect import android.graphics.drawable.BitmapDrawable @@ -8,6 +9,8 @@ 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 @@ -25,9 +28,10 @@ internal fun View.isVisibleToUser(): Pair { 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 || view.transitionAlpha <= 0 || view.visibility != View.VISIBLE) { + if (view.alpha <= 0 || transitionAlpha <= 0 || view.visibility != View.VISIBLE) { return false to null } current = view.parent @@ -41,6 +45,7 @@ internal fun View.isVisibleToUser(): Pair { return false to null } +@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) @@ -55,7 +60,7 @@ internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, padding if (this == null) { return listOf(globalRect) } - // TODO: actually not sure - maybe the old way is not that bad, because multiline rects can seem noisy + val rects = mutableListOf() for (i in 0 until lineCount) { val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 9c25bc4758..1aa2911f22 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,6 +1,7 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi +import android.content.res.Resources.NotFoundException import android.graphics.Rect import android.text.Layout import android.view.View @@ -12,92 +13,304 @@ import io.sentry.android.replay.util.isVisibleToUser @TargetApi(26) sealed class ViewHierarchyNode( + val id: String, val x: Float, val y: Float, val width: Int, val height: Int, val elevation: Float, + val distance: Int, + val parent: ViewHierarchyNode? = null, val shouldRedact: Boolean = false, + var isImportantForContentCapture: Boolean = false, + val isVisible: Boolean = false, val visibleRect: Rect? = null ) { var children: List? = null class GenericViewHierarchyNode( + id: String, 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, shouldRedact, visibleRect) + ): ViewHierarchyNode(id, 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, + id: String, 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, shouldRedact, visibleRect) + ) : ViewHierarchyNode(id, x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) class ImageViewHierarchyNode( + id: String, 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, shouldRedact, visibleRect) + ) : ViewHierarchyNode(id, 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() - fun fromView(view: View, options: SentryOptions): ViewHierarchyNode { + private fun Int.isViewIdGenerated(): Boolean { + return (this and -0x1000000) == 0 && (this and 0x00FFFFFF) != 0 + } + + @Throws(NotFoundException::class) + fun getResourceId(view: View): String? { + val viewId = view.id + if (viewId == View.NO_ID || viewId.isViewIdGenerated()) { + return null + } + val resources = view.context.resources + if (resources != null) { + return try { + resources.getResourceEntryName(viewId) + } catch (e: NotFoundException) { + null + } + } + return null + } + + /** + * 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 -> { - val (isVisible, visibleRect) = view.isVisibleToUser() + parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTop, + getResourceId(view) ?: view.toString(), x = view.x, y = view.y, width = view.width, height = view.height, - elevation = view.elevation, + 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 -> { - val (isVisible, visibleRect) = view.isVisibleToUser() + parent.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( + getResourceId(view) ?: view.toString(), x = view.x, y = view.y, width = view.width, height = view.height, - elevation = view.elevation, + elevation = (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, shouldRedact = isVisible && view.drawable?.isRedactable() == true, visibleRect = visibleRect ) } } + return GenericViewHierarchyNode( + getResourceId(view) ?: view.toString(), view.x, view.y, view.width, view.height, - view.elevation + (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldRedact = false, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect ) } } From 26df8c68caf3888d56d2201fc5688317be1743c5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Jun 2024 18:07:23 +0200 Subject: [PATCH 131/184] revert some thigns --- .../api/sentry-android-replay.api | 48 ++++++++++++------- .../replay/capture/BaseCaptureStrategy.kt | 3 -- .../replay/viewhierarchy/ViewHierarchyNode.kt | 41 ++++------------ .../java/io/sentry/SentryReplayOptions.java | 4 +- 4 files changed, 41 insertions(+), 55 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index ffaf60382c..49210fffca 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -141,34 +141,48 @@ public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentr public fun start (Landroid/media/MediaFormat;)V } -public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { +public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; - public fun (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;)V - public synthetic fun (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()F - public final fun component2 ()F - public final fun component3 ()I - public final fun component4 ()I - public final fun component5 ()Z - public final fun component6 ()Ljava/lang/Integer; - public final fun component7 ()Landroid/graphics/Rect; - public final fun copy (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public static synthetic fun copy$default (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;FFIIZLjava/lang/Integer;Landroid/graphics/Rect;ILjava/lang/Object;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public fun equals (Ljava/lang/Object;)Z + 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 getDominantColor ()Ljava/lang/Integer; + 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 fun hashCode ()I + 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 fun toString ()Ljava/lang/String; + 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/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + 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/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 068d0fa188..fc33c0ca59 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -128,8 +128,6 @@ internal abstract class BaseCaptureStrategy( width: Int, replayType: ReplayType = SESSION ): ReplaySegment { - return ReplaySegment.Failed - val generatedVideo = cache?.createVideoOf( duration, currentSegmentTimestamp.time, @@ -138,7 +136,6 @@ internal abstract class BaseCaptureStrategy( width ) ?: return ReplaySegment.Failed - val (video, frameCount, videoDuration) = generatedVideo return buildReplay( video, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 1aa2911f22..60014e8f64 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,7 +1,6 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi -import android.content.res.Resources.NotFoundException import android.graphics.Rect import android.text.Layout import android.view.View @@ -13,15 +12,17 @@ import io.sentry.android.replay.util.isVisibleToUser @TargetApi(26) sealed class ViewHierarchyNode( - val id: String, 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 @@ -29,7 +30,6 @@ sealed class ViewHierarchyNode( var children: List? = null class GenericViewHierarchyNode( - id: String, x: Float, y: Float, width: Int, @@ -41,14 +41,13 @@ sealed class ViewHierarchyNode( isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ): ViewHierarchyNode(id, x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : 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, - id: String, x: Float, y: Float, width: Int, @@ -60,10 +59,9 @@ sealed class ViewHierarchyNode( isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(id, x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) class ImageViewHierarchyNode( - id: String, x: Float, y: Float, width: Int, @@ -75,7 +73,7 @@ sealed class ViewHierarchyNode( isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(id, x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : 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 @@ -123,7 +121,8 @@ sealed class ViewHierarchyNode( // or doesn't contain the node's visible rect, we can skip it if (!otherNode.isVisible || !otherNode.isImportantForContentCapture || - !otherNode.visibleRect.contains(node.visibleRect)) { + !otherNode.visibleRect.contains(node.visibleRect) + ) { return@traverse false } @@ -219,27 +218,6 @@ sealed class ViewHierarchyNode( private fun Int.toOpaque() = this or 0xFF000000.toInt() - private fun Int.isViewIdGenerated(): Boolean { - return (this and -0x1000000) == 0 && (this and 0x00FFFFFF) != 0 - } - - @Throws(NotFoundException::class) - fun getResourceId(view: View): String? { - val viewId = view.id - if (viewId == View.NO_ID || viewId.isViewIdGenerated()) { - return null - } - val resources = view.context.resources - if (resources != null) { - return try { - resources.getResourceEntryName(viewId) - } catch (e: NotFoundException) { - null - } - } - return null - } - /** * 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: @@ -264,7 +242,6 @@ sealed class ViewHierarchyNode( dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTop, - getResourceId(view) ?: view.toString(), x = view.x, y = view.y, width = view.width, @@ -282,7 +259,6 @@ sealed class ViewHierarchyNode( view is ImageView && options.experimental.sessionReplay.redactAllImages -> { parent.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( - getResourceId(view) ?: view.toString(), x = view.x, y = view.y, width = view.width, @@ -299,7 +275,6 @@ sealed class ViewHierarchyNode( } return GenericViewHierarchyNode( - getResourceId(view) ?: view.toString(), view.x, view.y, view.width, diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d8f6930543..4c58cf2e86 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -110,7 +110,7 @@ public boolean getRedactAllText() { } public void setRedactAllText(final boolean redactAllText) { - //this.redactAllText = redactAllText; + this.redactAllText = redactAllText; } public boolean getRedactAllImages() { @@ -118,7 +118,7 @@ public boolean getRedactAllImages() { } public void setRedactAllImages(final boolean redactAllImages) { - //this.redactAllImages = redactAllImages; + this.redactAllImages = redactAllImages; } @ApiStatus.Internal From 9c874d7423aa968144eeec772b4718cebb1f3e21 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Jun 2024 18:09:19 +0200 Subject: [PATCH 132/184] Remove commented code --- .../java/io/sentry/android/replay/ScreenshotRecorder.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 10b24df3b7..ceb196d583 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -100,14 +100,6 @@ internal class ScreenshotRecorder( // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { -// val viewHierarchy: ViewHierarchyNode -// val time = measureTime { -// val rootNode = ViewHierarchyNode.fromView(root, options) -// root.traverse(rootNode) -// viewHierarchy = rootNode -// } -// Log.e("Recorder", "Time to get view hierarchy: $time") - try { PixelCopy.request( window, From 7b01de375554c118533c01770ba732819eb0567d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 10 Jun 2024 11:48:28 +0200 Subject: [PATCH 133/184] Suppress lint --- .../src/main/java/io/sentry/android/replay/util/Views.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index a34fa6b28f..8415b42e57 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.util +import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Point import android.graphics.Rect @@ -45,6 +46,7 @@ internal fun View.isVisibleToUser(): Pair { 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 From aa2a5a9c458c6d1b799972d785c114748396090e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 11 Jun 2024 11:09:41 +0200 Subject: [PATCH 134/184] Support multi-touch gestures --- .../replay/capture/BaseCaptureStrategy.kt | 117 +++++++++++++----- sentry/api/sentry.api | 6 + .../sentry/rrweb/RRWebInteractionEvent.java | 15 +++ .../rrweb/RRWebInteractionMoveEvent.java | 14 +++ 4 files changed, 124 insertions(+), 28 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index d0c8bc226d..07b8da6007 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -60,7 +60,7 @@ internal abstract class BaseCaptureStrategy( protected val currentEvents = LinkedList() private val currentEventsLock = Any() - private val currentPositions = mutableListOf() + private val currentPositions = LinkedHashMap>(10) private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -227,10 +227,10 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() - if (rrwebEvent != null) { + val rrwebEvents = event.toRRWebIncrementalSnapshotEvent() + if (rrwebEvents != null) { synchronized(currentEventsLock) { - currentEvents += rrwebEvent + currentEvents += rrwebEvents } } } @@ -284,9 +284,9 @@ internal abstract class BaseCaptureStrategy( } } - private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): RRWebIncrementalSnapshotEvent? { + private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List? { val event = this - return when (val action = event.actionMasked) { + return when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { // we only throttle move events as those can be overwhelming val now = dateProvider.currentTimeMillis @@ -295,48 +295,109 @@ internal abstract class BaseCaptureStrategy( } lastCapturedMoveEvent = now + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1 || !currentPositions.containsKey(pId)) { + // no data for this pointer + return null + } + // idk why but rrweb does it like dis if (touchMoveBaseline == 0L) { touchMoveBaseline = now } - currentPositions += Position().apply { - x = event.x * recorderConfig.scaleFactorX - y = event.y * recorderConfig.scaleFactorY + 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) { - RRWebInteractionMoveEvent().apply { - timestamp = now - positions = currentPositions.map { pos -> - pos.timeOffset -= totalOffset - pos + 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() } - }.also { - currentPositions.clear() - touchMoveBaseline = 0L } + touchMoveBaseline = 0L + moveEvents } else { null } } - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - 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 - interactionType = when (action) { - MotionEvent.ACTION_UP -> InteractionType.TouchEnd - MotionEvent.ACTION_DOWN -> InteractionType.TouchStart - MotionEvent.ACTION_CANCEL -> InteractionType.TouchCancel - else -> InteractionType.TouchMove_Departed // should not happen + 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[pId]?.clear() + 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/api/sentry.api b/sentry/api/sentry.api index d836a6c8a2..c789ac4578 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5205,6 +5205,7 @@ public final class io/sentry/rrweb/RRWebInteractionEvent : io/sentry/rrweb/RRWeb 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 @@ -5213,6 +5214,7 @@ public final class io/sentry/rrweb/RRWebInteractionEvent : io/sentry/rrweb/RRWeb 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 @@ -5251,6 +5253,7 @@ public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType$Deseria 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; @@ -5261,10 +5264,12 @@ public final class io/sentry/rrweb/RRWebInteractionEvent$JsonKeys { 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 } @@ -5277,6 +5282,7 @@ public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Deserializer : io/s 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 } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java index e75d5d0781..c7bd613c1b 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -57,6 +57,8 @@ public static final class Deserializer implements JsonDeserializer unknown; @@ -107,6 +109,14 @@ 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; @@ -136,6 +146,7 @@ public static final class JsonKeys { 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 @@ -163,6 +174,7 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL 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); @@ -235,6 +247,9 @@ private void deserializeData( 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) { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java index 86eb5e33e3..d3acf9a882 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -142,6 +142,7 @@ public static final class Deserializer implements JsonDeserializer { // 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": { ... } } @@ -180,12 +181,21 @@ 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 @@ -211,6 +221,7 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL 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); @@ -271,6 +282,9 @@ private void deserializeData( 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) { From 4aa50c26328af69936924e7c03fc999e77ef5297 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 18 Jun 2024 15:26:07 +0200 Subject: [PATCH 135/184] Address PR feedback --- .../android/replay/DefaultReplayBreadcrumbConverter.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index 3affe6f7a6..198b9f86a4 100644 --- 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 @@ -23,8 +23,6 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { - var rrwebBreadcrumb: RRWebBreadcrumbEvent? = null - var breadcrumbMessage: String? = null var breadcrumbCategory: String? = null var breadcrumbLevel: SentryLevel? = null @@ -97,8 +95,8 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbData.putAll(breadcrumb.data) } } - if (!breadcrumbCategory.isNullOrEmpty()) { - rrwebBreadcrumb = RRWebBreadcrumbEvent().apply { + return if (!breadcrumbCategory.isNullOrEmpty()) { + RRWebBreadcrumbEvent().apply { timestamp = breadcrumb.timestamp.time breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 breadcrumbType = "default" @@ -107,8 +105,9 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { level = breadcrumbLevel data = breadcrumbData } + } else { + null } - return rrwebBreadcrumb } private fun Breadcrumb.isValidForRRWebSpan(): Boolean { From 522b5868aa2603fcbc847b992167a8528e42ae64 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 18 Jun 2024 17:23:16 +0200 Subject: [PATCH 136/184] Changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53037a653d..ba67208421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## 7.9.0-alpha.1 +## Unreleased - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) -We released our second 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 +We released our third 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 ### Fixes From 263e1475b913627430f79859d65142e8fa8fc159 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 18 Jun 2024 15:40:48 +0000 Subject: [PATCH 137/184] release: 7.11.0-alpha.2 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba67208421..6c8ee83a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.11.0-alpha.2 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) diff --git a/gradle.properties b/gradle.properties index 7c60a7b423..75bc751072 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.9.0-alpha.1 +versionName=7.11.0-alpha.2 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 9444da932afaf45c7b314ef09786b2fe36817e7a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 18 Jun 2024 23:49:21 +0200 Subject: [PATCH 138/184] Make multi-touch work --- .../DefaultReplayBreadcrumbConverter.kt | 9 ++++++ .../replay/capture/BaseCaptureStrategy.kt | 31 ++++++++++--------- .../replay/capture/SessionCaptureStrategy.kt | 2 +- 3 files changed, 26 insertions(+), 16 deletions(-) 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 index 3affe6f7a6..bb0c70b6c5 100644 --- 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 @@ -22,6 +22,8 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { ) } + private var lastConnectivityState: String? = null + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { var rrwebBreadcrumb: RRWebBreadcrumbEvent? = null @@ -81,6 +83,13 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { else -> return null } + + if (lastConnectivityState == breadcrumbData["state"]) { + // debounce same state + return null + } + + lastConnectivityState = breadcrumbData["state"] as? String } breadcrumb.data["action"] == "BATTERY_CHANGED" -> { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 07b8da6007..ff3b24a772 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -295,24 +295,25 @@ internal abstract class BaseCaptureStrategy( } lastCapturedMoveEvent = now - val pId = event.getPointerId(event.actionIndex) - val pIndex = event.findPointerIndex(pId) + currentPositions.keys.forEach { pId -> + val pIndex = event.findPointerIndex(pId) - if (pIndex == -1 || !currentPositions.containsKey(pId)) { - // no data for this pointer - return null - } + if (pIndex == -1) { + // no data for this pointer + return@forEach + } - // idk why but rrweb does it like dis - if (touchMoveBaseline == 0L) { - touchMoveBaseline = now - } + // 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 + 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 43af2e3c37..effa8a8ca3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -129,7 +129,7 @@ internal class SessionCaptureStrategy( val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time + val duration = now - (currentSegmentTimestamp?.time ?: 0) val replayId = currentReplayId.get() val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth From 54056cba40e53ef586cb6096ad5a350d6d45a02b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Jun 2024 08:49:25 +0200 Subject: [PATCH 139/184] Fix tests --- .../io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt | 1 + .../sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt | 1 + sentry/src/test/resources/json/replay_recording.json | 2 +- sentry/src/test/resources/json/rrweb_interaction_event.json | 3 ++- .../src/test/resources/json/rrweb_interaction_move_event.json | 3 ++- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt index cc63de72ba..21ec522d51 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt @@ -17,6 +17,7 @@ class RRWebInteractionEventSerializationTest { x = 1.0f y = 2.0f interactionType = TouchStart + pointerId = 1 } } diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt index 5df216337d..b114a4e092 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt @@ -21,6 +21,7 @@ class RRWebInteractionMoveEventSerializationTest { timeOffset = 100 } ) + pointerId = 1 } } diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index 1e3f58226e..021c78b020 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,2 +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}},{"type":3,"timestamp":12345678901,"data":{"source":6,"positions":[{"id":1,"x":1.0,"y":2.0,"timeOffset":100}]}}] +[{"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_interaction_event.json b/sentry/src/test/resources/json/rrweb_interaction_event.json index f6b4b1de83..1af66d4afd 100644 --- a/sentry/src/test/resources/json/rrweb_interaction_event.json +++ b/sentry/src/test/resources/json/rrweb_interaction_event.json @@ -7,6 +7,7 @@ "id": 1, "x": 1.0, "y": 2.0, - "pointerType": 2 + "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 index 3f181f543a..0a815067ce 100644 --- a/sentry/src/test/resources/json/rrweb_interaction_move_event.json +++ b/sentry/src/test/resources/json/rrweb_interaction_move_event.json @@ -10,6 +10,7 @@ "y": 2.0, "timeOffset": 100 } - ] + ], + "pointerId": 1 } } From df7270e05297347a858556cd21fedcd1024ec222 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 20 Jun 2024 10:19:06 +0200 Subject: [PATCH 140/184] WIP --- .../core/ActivityLifecycleIntegration.java | 2 +- .../SentryFragmentLifecycleCallbacks.kt | 3 ++ .../navigation/SentryNavigationListener.kt | 44 +++++++++++-------- .../replay/capture/BaseCaptureStrategy.kt | 8 ++++ .../main/java/io/sentry/SentryOptions.java | 14 ++++++ 5 files changed, 52 insertions(+), 19 deletions(-) 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 1121a6bfe7..205360b8f1 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-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 5e03c99e0d..18468b99c1 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-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt index 8fdf8b0df8..2d958e79b3 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,21 @@ class SentryNavigationListener @JvmOverloads constructor( }.associateWith { args[it] } } ?: emptyMap() + 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-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index ff3b24a772..068c12bbce 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -14,6 +14,7 @@ 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 @@ -187,6 +188,7 @@ internal abstract class BaseCaptureStrategy( top = 0 } + val urls = ArrayList(options.maxBreadcrumbs) hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> if (breadcrumb.timestamp.time >= segmentTimestamp.time && @@ -199,6 +201,11 @@ internal abstract class BaseCaptureStrategy( if (rrwebEvent != null) { recordingPayload += rrwebEvent + + // fill in the urls array from navigation breadcrumbs + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.breadcrumbType == "navigation") { + urls.add(rrwebEvent.data!!["to"] as String) + } } } } @@ -215,6 +222,7 @@ internal abstract class BaseCaptureStrategy( payload = recordingPayload.sortedBy { it.timestamp } } + replay.urls = urls return ReplaySegment.Created( videoDuration = duration, replay = replay, diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index aa40a7b107..afa4528c87 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -483,6 +483,12 @@ public class SentryOptions { 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. + */ + private boolean enableScreenTracking = true; + /** * Adds an event processor * @@ -2403,6 +2409,14 @@ public void setReplayController(final @Nullable ReplayController replayControlle replayController != null ? replayController : NoOpReplayController.getInstance(); } + public boolean isEnableScreenTracking() { + return enableScreenTracking; + } + + public void setEnableScreenTracking(final boolean enableScreenTracking) { + this.enableScreenTracking = enableScreenTracking; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { From 53d5fbc6151f5c602431c27a310bfe51fc621600 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 21 Jun 2024 13:17:38 +0200 Subject: [PATCH 141/184] Capture screen names as urls for replay --- .../android/replay/ReplayIntegration.kt | 7 ++++ .../replay/capture/BaseCaptureStrategy.kt | 7 +++- .../replay/capture/BufferCaptureStrategy.kt | 42 +++++++++++++++++++ .../android/replay/capture/CaptureStrategy.kt | 2 + .../replay/capture/SessionCaptureStrategy.kt | 5 ++- sentry/api/sentry.api | 2 + 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c1ee1f9ae8..3fcf5f40f3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -12,6 +12,7 @@ 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 @@ -21,6 +22,7 @@ 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 @@ -76,6 +78,11 @@ public class ReplayIntegration( } this.hub = hub + this.options.addScopeObserver(object : ScopeObserverAdapter() { + override fun setContexts(contexts: Contexts) { + captureStrategy?.onScreenChanged(contexts.app?.viewNames?.lastOrNull()) + } + }) recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this) isEnabled.set(true) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 068c12bbce..3f130bfd88 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -55,6 +55,7 @@ internal abstract class BaseCaptureStrategy( 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 @@ -188,7 +189,7 @@ internal abstract class BaseCaptureStrategy( top = 0 } - val urls = ArrayList(options.maxBreadcrumbs) + val urls = LinkedList() hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> if (breadcrumb.timestamp.time >= segmentTimestamp.time && @@ -211,6 +212,10 @@ internal abstract class BaseCaptureStrategy( } } + if (screenAtStart.get() != null && urls.first != screenAtStart.get()) { + urls.addFirst(screenAtStart.get()) + } + rotateCurrentEvents(endTimestamp.time) { event -> if (event.timestamp >= segmentTimestamp.time) { recordingPayload += event diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index b365831d7a..8f3682f965 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -28,11 +28,35 @@ internal class BufferCaptureStrategy( ) : 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") { @@ -66,6 +90,9 @@ internal class BufferCaptureStrategy( 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) { @@ -178,4 +205,19 @@ internal class BufferCaptureStrategy( 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 index 1dd852efa5..61c4107183 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -30,6 +30,8 @@ internal interface CaptureStrategy { 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 index effa8a8ca3..05c7dba11d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -31,7 +31,10 @@ internal class SessionCaptureStrategy( 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() } + hub?.configureScope { + it.replayId = currentReplayId.get() + screenAtStart.set(it.screen) + } } override fun pause() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0bc9c3c223..c12e75fae3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2444,6 +2444,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 @@ -2490,6 +2491,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 From a783326ba24cfd495bb9c5d76f221842d4ea7bef Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 21 Jun 2024 22:19:21 +0200 Subject: [PATCH 142/184] Fix --- .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 3 ++- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 3fcf5f40f3..c6410339ba 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -80,7 +80,8 @@ public class ReplayIntegration( this.hub = hub this.options.addScopeObserver(object : ScopeObserverAdapter() { override fun setContexts(contexts: Contexts) { - captureStrategy?.onScreenChanged(contexts.app?.viewNames?.lastOrNull()) + // scope screen has fully-qualified name + captureStrategy?.onScreenChanged(contexts.app?.viewNames?.lastOrNull()?.substringAfterLast('.')) } }) recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 3f130bfd88..b3de069143 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -204,7 +204,7 @@ internal abstract class BaseCaptureStrategy( recordingPayload += rrwebEvent // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.breadcrumbType == "navigation") { + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { urls.add(rrwebEvent.data!!["to"] as String) } } From 01b7d67bb91c8c7282776a7723c62ea67a1bd6f9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 22 Jun 2024 23:43:05 +0200 Subject: [PATCH 143/184] Ignore warning --- .../io/sentry/android/navigation/SentryNavigationListener.kt | 1 + 1 file changed, 1 insertion(+) 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 2d958e79b3..dac8e54e80 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 @@ -177,6 +177,7 @@ 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) From c83241659d49a0a36fb83d58aaa2de7cb9ddb1bd Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 22 Jun 2024 23:45:04 +0200 Subject: [PATCH 144/184] Address PR feedback --- .../java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index ff3b24a772..de0d83f79c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -373,7 +373,6 @@ internal abstract class BaseCaptureStrategy( } // finger lift up - remove the pointer from tracking - currentPositions[pId]?.clear() currentPositions.remove(pId) listOf( RRWebInteractionEvent().apply { From eef3c11bd21816d90703ef674c54e6002fb6d877 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Jun 2024 12:50:25 +0200 Subject: [PATCH 145/184] Tests --- .../SentryNavigationListenerTest.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 76c57159c3..342673dafb 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" + } } From 97d530cbdf6ec5ff8a10da7d7f812e8b9c185082 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Jun 2024 16:48:26 +0200 Subject: [PATCH 146/184] Add quality settings --- .../android/replay/ScreenshotRecorder.kt | 4 +- sentry/api/sentry.api | 11 ++++++ .../java/io/sentry/SentryReplayOptions.java | 38 +++++++++++++++++-- .../java/io/sentry/SentryReplayOptionsTest.kt | 32 ++++++++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ceb196d583..c22e1f8e8a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -292,10 +292,10 @@ public data class ScreenshotRecorderConfig( // use the baseline density of 1x (mdpi) val (height, width) = - (screenBounds.height() / context.resources.displayMetrics.density) + ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.sizeScale) .roundToInt() .adjustToBlockSize() to - (screenBounds.width() / context.resources.displayMetrics.density) + ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.sizeScale) .roundToInt() .adjustToBlockSize() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c12e75fae3..0d8cccb0bc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2699,19 +2699,30 @@ public final class io/sentry/SentryReplayOptions { 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 getSizeScale ()F 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 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; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 4c58cf2e86..ca2d212dd6 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,10 +2,22 @@ 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, + + /** Video Scale: 100% Bit Rate: 75.000 */ + MEDIUM, + + /** Video Scale: 100% Bit Rate: 100.000 */ + HIGH + } + /** * 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 @@ -39,10 +51,10 @@ public final class SentryReplayOptions { private boolean redactAllImages = true; /** - * 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 100kbps. + * 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 int bitRate = 100_000; + private SentryReplayQuality quality = SentryReplayQuality.MEDIUM; /** * Number of frames per second of the replay. The bigger the number, the more accurate the replay @@ -121,9 +133,27 @@ public void setRedactAllImages(final boolean redactAllImages) { this.redactAllImages = redactAllImages; } + public @NotNull SentryReplayQuality getQuality() { + return quality; + } + + public void setQuality(final @NotNull SentryReplayQuality quality) { + this.quality = quality; + } + + /** + * 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. + */ @ApiStatus.Internal public int getBitRate() { - return bitRate; + return quality.ordinal() * 25_000 + 50_000; + } + + /** The scale related to the window size (in dp) at which the replay will be created. */ + @ApiStatus.Internal + public float getSizeScale() { + return quality == SentryReplayQuality.LOW ? 0.8f : 1.0f; } @ApiStatus.Internal 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 0000000000..79f60513ac --- /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(40_000, replayOptions.bitRate) + assertEquals(1.0f, replayOptions.sizeScale) + } + + @Test + fun `low quality`() { + val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + + assertEquals(20_000, replayOptions.bitRate) + assertEquals(0.8f, replayOptions.sizeScale) + } + + @Test + fun `high quality`() { + val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } + + assertEquals(60_000, replayOptions.bitRate) + assertEquals(1.0f, replayOptions.sizeScale) + } +} From 5557450e84ba492cd82a5e61e9782c10cb4f4642 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Jun 2024 22:27:58 +0200 Subject: [PATCH 147/184] Fix redacting out of sync --- .../android/replay/ScreenshotRecorder.kt | 200 +++++++++++------- 1 file changed, 118 insertions(+), 82 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index c22e1f8e8a..91854236ff 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -14,8 +14,8 @@ import android.graphics.RectF import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.os.Handler -import android.os.HandlerThread import android.os.Looper +import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -27,14 +27,19 @@ 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.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt +import kotlin.system.measureNanoTime @TargetApi(26) internal class ScreenshotRecorder( @@ -43,9 +48,11 @@ internal class ScreenshotRecorder( private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { + private val recorder by lazy { + Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + } private var rootView: WeakReference? = null - private val thread = HandlerThread("SentryReplayRecorder").also { it.start() } - private val handler = Handler(thread.looper) + private val handler = Handler(Looper.getMainLooper()) private val pendingViewHierarchy = AtomicReference() private val maskingPaint = Paint() private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( @@ -62,8 +69,6 @@ internal class ScreenshotRecorder( private var lastScreenshot: Bitmap? = null fun capture() { - val viewHierarchy = pendingViewHierarchy.getAndSet(null) - if (!isCapturing.get()) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") return @@ -93,89 +98,114 @@ internal class ScreenshotRecorder( } val bitmap = Bitmap.createBitmap( - root.width, - root.height, + 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()).postAtFrontOfQueue { + Handler(Looper.getMainLooper()).post { try { - 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 - } - - val scaledBitmap: Bitmap - - if (viewHierarchy == null) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - bitmap.recycle() - return@request - } else { - scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - config.recordingWidth, - config.recordingHeight, - true - ) - val canvas = Canvas(scaledBitmap) - 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 nanos = System.nanoTime() + 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 + } - val (visibleRects, color) = when (node) { - is ImageViewHierarchyNode -> { - singlePixelBitmapCanvas.drawBitmap( - bitmap, - node.visibleRect, - Rect(0, 0, 1, 1), - null - ) - listOf(node.visibleRect) to singlePixelBitmap.getPixel(0, 0) - } - is TextViewHierarchyNode -> { - node.layout.getVisibleRects( - node.visibleRect, - node.paddingLeft, - node.paddingTop - ) to (node.dominantColor ?: Color.BLACK) + // should never happen, since this callback is called on the main thread + 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) + + val nanos2 = System.nanoTime() + Log.e("TIME", String.format("%.6f milliseconds", ((nanos2 - nanos) / 1_000_000.0))) + + 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 } - else -> { - listOf(node.visibleRect) to Color.BLACK + + val (visibleRects, color) = when (node) { + is ImageViewHierarchyNode -> { + // TODO: maybe this ceremony can be just simplified to + // TODO: multiplying the visibleRect by the prescaledMatrix + val visibleRect = Rect(node.visibleRect) + val visibleRectF = RectF(visibleRect) + prescaledMatrix.mapRect(visibleRectF) + visibleRectF.round(visibleRect) + singlePixelBitmapCanvas.drawBitmap( + bitmap, + visibleRect, + Rect(0, 0, 1, 1), + null + ) + listOf(node.visibleRect) to singlePixelBitmap.getPixel( + 0, + 0 + ) + } + + 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) + maskingPaint.setColor(color) + visibleRects.forEach { rect -> + canvas.drawRoundRect( + RectF(rect), + 10f, + 10f, + maskingPaint + ) + } } + return@traverse true } - return@traverse true + + val screenshot = bitmap.copy(ARGB_8888, false) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastScreenshot?.recycle() + lastScreenshot = screenshot + contentChanged.set(false) + + bitmap.recycle() } - } - - val screenshot = scaledBitmap.copy(ARGB_8888, false) - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - lastScreenshot?.recycle() - lastScreenshot = screenshot - contentChanged.set(false) - - scaledBitmap.recycle() - bitmap.recycle() - }, - handler - ) + }, + handler + ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) bitmap.recycle() @@ -190,10 +220,6 @@ internal class ScreenshotRecorder( return } - val rootNode = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(rootNode) - pendingViewHierarchy.set(rootNode) - contentChanged.set(true) } @@ -228,7 +254,7 @@ internal class ScreenshotRecorder( lastScreenshot?.recycle() pendingViewHierarchy.set(null) isCapturing.set(false) - thread.quitSafely() + recorder.gracefullyShutdown(options) } private fun View.traverse(parentNode: ViewHierarchyNode) { @@ -244,13 +270,23 @@ internal class ScreenshotRecorder( for (i in 0 until childCount) { val child = getChildAt(i) if (child != null) { - val childNode = ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + 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( From 213ff9ede157e273139e28fc1889a8e01bcabc9b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jul 2024 10:49:47 +0200 Subject: [PATCH 148/184] Remove time measuring --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 91854236ff..c15a5a7c24 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -106,7 +106,6 @@ internal class ScreenshotRecorder( // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).post { try { - val nanos = System.nanoTime() contentChanged.set(false) PixelCopy.request( window, @@ -135,9 +134,6 @@ internal class ScreenshotRecorder( val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy) - val nanos2 = System.nanoTime() - Log.e("TIME", String.format("%.6f milliseconds", ((nanos2 - nanos) / 1_000_000.0))) - recorder.submit { val canvas = Canvas(bitmap) canvas.setMatrix(prescaledMatrix) From fc7138bd37875b5362a4d2fa05c4ecde53341909 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jul 2024 10:55:31 +0200 Subject: [PATCH 149/184] Mark isEnableScreenTracking as experimental --- sentry/src/main/java/io/sentry/SentryOptions.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index afa4528c87..70c8500dfa 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -487,6 +487,7 @@ public class SentryOptions { * 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; /** @@ -2409,10 +2410,12 @@ public void setReplayController(final @Nullable ReplayController replayControlle replayController != null ? replayController : NoOpReplayController.getInstance(); } + @ApiStatus.Experimental public boolean isEnableScreenTracking() { return enableScreenTracking; } + @ApiStatus.Experimental public void setEnableScreenTracking(final boolean enableScreenTracking) { this.enableScreenTracking = enableScreenTracking; } From d655d521e2ab1e3c302340afec2b665439f1d1f5 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 2 Jul 2024 08:58:59 +0000 Subject: [PATCH 150/184] Format code --- sentry/src/main/java/io/sentry/SentryOptions.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 70c8500dfa..3ff84c48de 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -487,8 +487,7 @@ public class SentryOptions { * 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; + @ApiStatus.Experimental private boolean enableScreenTracking = true; /** * Adds an event processor From a154cd0c44e7567cb0a9b7e0bc40c56ac9beecea Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jul 2024 11:29:24 +0200 Subject: [PATCH 151/184] Address PR feedback --- .../android/replay/ScreenshotRecorder.kt | 6 ++-- sentry/api/sentry.api | 4 +-- .../java/io/sentry/SentryReplayOptions.java | 36 +++++++++---------- .../java/io/sentry/SentryReplayOptionsTest.kt | 12 +++---- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index c22e1f8e8a..85d1f4a1ab 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -292,10 +292,10 @@ public data class ScreenshotRecorderConfig( // use the baseline density of 1x (mdpi) val (height, width) = - ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.sizeScale) + ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() to - ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.sizeScale) + ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() @@ -305,7 +305,7 @@ public data class ScreenshotRecorderConfig( scaleFactorX = width.toFloat() / screenBounds.width(), scaleFactorY = height.toFloat() / screenBounds.height(), frameRate = sessionReplay.frameRate, - bitRate = sessionReplay.bitRate + bitRate = sessionReplay.quality.bitRate ) } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0d8cccb0bc..6df0c9f3c1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2695,7 +2695,6 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent public final class io/sentry/SentryReplayOptions { public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun getBitRate ()I public fun getErrorReplayDuration ()J public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I @@ -2705,7 +2704,6 @@ public final class io/sentry/SentryReplayOptions { public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J - public fun getSizeScale ()F public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun setErrorSampleRate (Ljava/lang/Double;)V @@ -2719,6 +2717,8 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang 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; } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index ca2d212dd6..54cabeaef6 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -9,13 +9,27 @@ public final class SentryReplayOptions { public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ - LOW, + LOW(0.8f, 50_000), /** Video Scale: 100% Bit Rate: 75.000 */ - MEDIUM, + MEDIUM(1.0f, 75_000), /** Video Scale: 100% Bit Rate: 100.000 */ - HIGH + 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; + } } /** @@ -133,6 +147,7 @@ public void setRedactAllImages(final boolean redactAllImages) { this.redactAllImages = redactAllImages; } + @ApiStatus.Internal public @NotNull SentryReplayQuality getQuality() { return quality; } @@ -141,21 +156,6 @@ public void setQuality(final @NotNull SentryReplayQuality quality) { this.quality = quality; } - /** - * 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. - */ - @ApiStatus.Internal - public int getBitRate() { - return quality.ordinal() * 25_000 + 50_000; - } - - /** The scale related to the window size (in dp) at which the replay will be created. */ - @ApiStatus.Internal - public float getSizeScale() { - return quality == SentryReplayQuality.LOW ? 0.8f : 1.0f; - } - @ApiStatus.Internal public int getFrameRate() { return frameRate; diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 79f60513ac..01843dfc90 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -10,23 +10,23 @@ class SentryReplayOptionsTest { val replayOptions = SentryReplayOptions() assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) - assertEquals(40_000, replayOptions.bitRate) - assertEquals(1.0f, replayOptions.sizeScale) + 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(20_000, replayOptions.bitRate) - assertEquals(0.8f, replayOptions.sizeScale) + 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(60_000, replayOptions.bitRate) - assertEquals(1.0f, replayOptions.sizeScale) + assertEquals(100_000, replayOptions.quality.bitRate) + assertEquals(1.0f, replayOptions.quality.sizeScale) } } From 402587d4d6232cb98977c67d8a19842a115c82d8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jul 2024 17:31:45 +0200 Subject: [PATCH 152/184] Clean up --- .../android/replay/ScreenshotRecorder.kt | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index c15a5a7c24..cf1dfef670 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -74,7 +74,7 @@ internal class ScreenshotRecorder( return } - if (!contentChanged.get() && lastScreenshot != null) { + if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) { options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") lastScreenshot?.let { @@ -112,21 +112,13 @@ internal class ScreenshotRecorder( bitmap, { copyResult: Int -> if (copyResult != PixelCopy.SUCCESS) { - options.logger.log( - INFO, - "Failed to capture replay recording: %d", - copyResult - ) + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) bitmap.recycle() return@request } - // should never happen, since this callback is called on the main thread if (contentChanged.get()) { - options.logger.log( - INFO, - "Failed to determine view hierarchy, not capturing" - ) + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() return@request } @@ -147,22 +139,8 @@ internal class ScreenshotRecorder( val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { - // TODO: maybe this ceremony can be just simplified to - // TODO: multiplying the visibleRect by the prescaledMatrix - val visibleRect = Rect(node.visibleRect) - val visibleRectF = RectF(visibleRect) - prescaledMatrix.mapRect(visibleRectF) - visibleRectF.round(visibleRect) - singlePixelBitmapCanvas.drawBitmap( - bitmap, - visibleRect, - Rect(0, 0, 1, 1), - null - ) - listOf(node.visibleRect) to singlePixelBitmap.getPixel( - 0, - 0 - ) + listOf(node.visibleRect) to + bitmap.dominantColorForRect(node.visibleRect) } is TextViewHierarchyNode -> { @@ -180,12 +158,7 @@ internal class ScreenshotRecorder( maskingPaint.setColor(color) visibleRects.forEach { rect -> - canvas.drawRoundRect( - RectF(rect), - 10f, - 10f, - maskingPaint - ) + canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) } } return@traverse true @@ -253,6 +226,29 @@ internal class ScreenshotRecorder( 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 From 71bdb5aa29fc43b1fe03fc9b37bde1cc1431d13c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jul 2024 17:32:24 +0200 Subject: [PATCH 153/184] Spotless --- .../android/replay/ScreenshotRecorder.kt | 121 +++++++++--------- 1 file changed, 59 insertions(+), 62 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index cf1dfef670..daf24a5757 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -15,7 +15,6 @@ import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.os.Handler import android.os.Looper -import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -35,11 +34,9 @@ import java.io.File import java.lang.ref.WeakReference import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt -import kotlin.system.measureNanoTime @TargetApi(26) internal class ScreenshotRecorder( @@ -106,75 +103,75 @@ internal class ScreenshotRecorder( // 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 + 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 + } - if (viewHierarchy.isObscured(node)) { - return@traverse true + val (visibleRects, color) = when (node) { + is ImageViewHierarchyNode -> { + listOf(node.visibleRect) to + bitmap.dominantColorForRect(node.visibleRect) } - 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 - } + is TextViewHierarchyNode -> { + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop + ) to (node.dominantColor ?: Color.BLACK) } - maskingPaint.setColor(color) - visibleRects.forEach { rect -> - canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) + else -> { + listOf(node.visibleRect) to Color.BLACK } } - return@traverse true + + 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) + val screenshot = bitmap.copy(ARGB_8888, false) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastScreenshot?.recycle() + lastScreenshot = screenshot + contentChanged.set(false) - bitmap.recycle() - } - }, - handler - ) + bitmap.recycle() + } + }, + handler + ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) bitmap.recycle() From bc18c8b5c3883b63399346e268b53989620b807d Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Jul 2024 14:07:47 +0200 Subject: [PATCH 154/184] feat(replay): Add `redactClasses` option --- CHANGELOG.md | 6 ++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 2 +- .../java/io/sentry/SentryReplayOptions.java | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8ee83a3d..905ff11743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add `redactClasses` to Session Replay options ([#3546](https://github.com/getsentry/sentry-java/pull/3546)) + ## 7.11.0-alpha.2 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 60014e8f64..888d489a1f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -282,7 +282,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = false, + shouldRedact = options.experimental.sessionReplay.redactClasses.contains(view.javaClass.canonicalName), isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 54cabeaef6..da686b18c7 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,6 +1,8 @@ package io.sentry; import io.sentry.util.SampleRateUtils; +import java.util.HashSet; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -64,6 +66,14 @@ public enum SentryReplayQuality { */ private boolean redactAllImages = true; + /** + * Redact all views with the specified class names. The class name is the fully qualified class + * name of the view, e.g. android.widget.TextView. + * + *

Default is empty. + */ + private Set redactClasses = new HashSet<>(); + /** * 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. @@ -147,6 +157,14 @@ public void setRedactAllImages(final boolean redactAllImages) { this.redactAllImages = redactAllImages; } + public Set getRedactClasses() { + return this.redactClasses; + } + + public void setRedactClasses(final Set redactClasses) { + this.redactClasses = redactClasses; + } + @ApiStatus.Internal public @NotNull SentryReplayQuality getQuality() { return quality; From 8f9a6d34ca8e7055fe6bb0a0e97e9754110af0e3 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 3 Jul 2024 17:01:51 +0200 Subject: [PATCH 155/184] update api dump --- sentry/api/sentry.api | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6df0c9f3c1..48b1bfe622 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2701,6 +2701,7 @@ public final class io/sentry/SentryReplayOptions { public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; public fun getRedactAllImages ()Z public fun getRedactAllText ()Z + public fun getRedactClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J @@ -2710,6 +2711,7 @@ public final class io/sentry/SentryReplayOptions { public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V public fun setRedactAllImages (Z)V public fun setRedactAllText (Z)V + public fun setRedactClasses (Ljava/util/Set;)V public fun setSessionSampleRate (Ljava/lang/Double;)V } From 67efa107e21e4fdd08888c65f098aadef2938be3 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 3 Jul 2024 15:55:03 +0000 Subject: [PATCH 156/184] Format code --- .../java/io/sentry/android/core/SentryAndroid.java | 12 ++++++------ sentry/src/main/java/io/sentry/TraceContext.java | 12 +++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) 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 831ed94321..d444d08cb0 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 @@ -161,12 +161,12 @@ public static synchronized void init( // 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); - } - }); + 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(); diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index 65e5c14589..f3d603b7c0 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -40,7 +40,17 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String sampleRate, @Nullable String sampled, @Nullable SentryId replayId) { - this(traceId, publicKey, release, environment, userId, null, transaction, sampleRate, sampled, replayId); + this( + traceId, + publicKey, + release, + environment, + userId, + null, + transaction, + sampleRate, + sampled, + replayId); } /** From 89f5186d7a7e12cbf1f67293c81ebe70184f39c2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Jul 2024 18:06:19 +0200 Subject: [PATCH 157/184] Changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f67618933f..ffb5854570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- 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 From befe3fe819ca0f7b0cffd380318b96fe80717c80 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 3 Jul 2024 17:50:12 +0000 Subject: [PATCH 158/184] release: 7.12.0-alpha.3 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb5854570..6ac3f135ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.12.0-alpha.3 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) diff --git a/gradle.properties b/gradle.properties index 35ce98ed2d..66a1b2364e 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 From 4866d5400f1d20ae0cc678b40d816d3f25fad3ec Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 3 Jul 2024 19:58:31 +0200 Subject: [PATCH 159/184] Update CHANGELOG.md Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905ff11743..6c8ee83a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,5 @@ # Changelog -## Unreleased - -### Features - -- Add `redactClasses` to Session Replay options ([#3546](https://github.com/getsentry/sentry-java/pull/3546)) - ## 7.11.0-alpha.2 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) From 837e91198f396f4fa1953b49dd4e1f1921b6f963 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 8 Jul 2024 11:16:52 +0200 Subject: [PATCH 160/184] [SR] Add `redactClasses` option (#3546) Co-authored-by: Roman Zavarnitsyn --- .../replay/viewhierarchy/ViewHierarchyNode.kt | 6 +++++- sentry/api/sentry.api | 2 ++ .../java/io/sentry/SentryReplayOptions.java | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 60014e8f64..1a94b295f7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -232,6 +232,10 @@ sealed class ViewHierarchyNode( } } + private fun shouldRedact(view: View, options: SentryOptions): Boolean { + return options.experimental.sessionReplay.redactClasses.contains(view.javaClass.canonicalName) + } + fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() when { @@ -282,7 +286,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = false, + shouldRedact = isVisible && shouldRedact(view, options), isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f662b29efb..af9ffe9fc4 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2699,12 +2699,14 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent public final class io/sentry/SentryReplayOptions { public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun addClassToRedact (Ljava/lang/String;)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 getRedactClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 54cabeaef6..db230f2a30 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,6 +1,8 @@ package io.sentry; import io.sentry.util.SampleRateUtils; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -64,6 +66,14 @@ public enum SentryReplayQuality { */ private boolean redactAllImages = true; + /** + * Redact all views with the specified class names. The class name is the fully qualified class + * name of the view, e.g. android.widget.TextView. + * + *

Default is empty. + */ + private Set redactClasses = new CopyOnWriteArraySet<>(); + /** * 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. @@ -147,6 +157,14 @@ public void setRedactAllImages(final boolean redactAllImages) { this.redactAllImages = redactAllImages; } + public Set getRedactClasses() { + return this.redactClasses; + } + + public void addClassToRedact(final String className) { + this.redactClasses.add(className); + } + @ApiStatus.Internal public @NotNull SentryReplayQuality getQuality() { return quality; From 50c1d50e530e2695ffe1854808d0265dfc43db25 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 9 Jul 2024 15:32:37 +0200 Subject: [PATCH 161/184] misc(changelog): Prepare for next alpha --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac3f135ed..9276668bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- 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.12.0-alpha.3 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) From 361f73a8856ab788082cc5109812eab6ea62a43b Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 9 Jul 2024 16:12:46 +0200 Subject: [PATCH 162/184] fix(changelog): Bump alpha version number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9276668bff..5aa72c747f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - 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 +We released our fifth 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.12.0-alpha.3 From 061ac4b489fcad7b85d5a20a53260f32fba681ff Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 9 Jul 2024 14:14:31 +0000 Subject: [PATCH 163/184] release: 7.12.0-alpha.4 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa72c747f..f89707f2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.12.0-alpha.4 - Session Replay for Android ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) diff --git a/gradle.properties b/gradle.properties index 66a1b2364e..827ee0034e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.12.0-alpha.3 +versionName=7.12.0-alpha.4 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From c9b0804129c68fc31d372041566a4b84589d1d77 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Jul 2024 10:04:28 +0200 Subject: [PATCH 164/184] Capture replay in session mode for ANRs --- .../android/core/AnrV2EventProcessor.java | 14 + .../api/sentry-android-replay.api | 6 + .../io/sentry/android/replay/ReplayCache.kt | 187 ++++++++++- .../android/replay/ReplayIntegration.kt | 14 +- .../replay/capture/BaseCaptureStrategy.kt | 295 +++++++++++++++--- .../replay/capture/BufferCaptureStrategy.kt | 32 +- .../android/replay/capture/CaptureStrategy.kt | 8 +- .../replay/capture/SessionCaptureStrategy.kt | 58 ++-- sentry/api/sentry.api | 12 +- sentry/src/main/java/io/sentry/Baggage.java | 9 +- .../main/java/io/sentry/IScopeObserver.java | 5 +- .../java/io/sentry/PropagationContext.java | 6 + .../main/java/io/sentry/ReplayRecording.java | 4 +- sentry/src/main/java/io/sentry/Scope.java | 14 +- .../java/io/sentry/ScopeObserverAdapter.java | 6 +- .../src/main/java/io/sentry/SentryClient.java | 1 + .../sentry/cache/PersistingScopeObserver.java | 14 +- .../java/io/sentry/protocol/Contexts.java | 1 + 18 files changed, 562 insertions(+), 124 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 45f997542b..a6906b647c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -10,10 +10,12 @@ import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME; import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; +import static io.sentry.protocol.Contexts.REPLAY_TYPE; import android.annotation.SuppressLint; import android.app.ActivityManager; @@ -151,6 +153,18 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setFingerprints(event, hint); setLevel(event); setTrace(event); + setReplayId(event); + } + + private void setReplayId(final @NotNull SentryEvent event) { + final String persistedReplayId = + PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + + if (persistedReplayId == null) { + return; + } + + event.getContexts().put(REPLAY_TYPE, persistedReplayId); } private void setTrace(final @NotNull SentryEvent event) { diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index c98749c271..2788070fd1 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -34,14 +34,20 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; 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 persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V public final fun rotate (J)V } +public final class io/sentry/android/replay/ReplayCache$Companion { + public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; +} + 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 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index f49abfaa84..d7535e9ce6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -3,15 +3,25 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.BitmapFactory +import io.sentry.DateUtils +import io.sentry.ReplayRecording import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebEvent +import io.sentry.util.FileUtils import java.io.Closeable import java.io.File +import java.io.StringReader +import java.util.Date +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean /** * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the @@ -50,19 +60,12 @@ public class ReplayCache internal constructor( ).also { it.start() } }) + private val isClosed = AtomicBoolean(false) 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() } - } + makeReplayCacheDir(options, replayId) } // TODO: maybe account for multi-threaded access @@ -71,7 +74,7 @@ public class ReplayCache internal constructor( /** * 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]. + * under [makeReplayCacheDir]. * * This method is not thread-safe. * @@ -111,14 +114,14 @@ public class ReplayCache internal constructor( /** * 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". + * [videoFile] location, which defaults to "[makeReplayCacheDir]/[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 + * result video under [makeReplayCacheDir], 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 @@ -237,9 +240,169 @@ public class ReplayCache internal constructor( encoder?.release() encoder = null } + isClosed.set(true) + } + + // TODO: it's awful, choose a better serialization format + @Synchronized + fun persistSegmentValues(key: String, value: String?) { + if (isClosed.get()) { + return + } + val file = File(replayCacheDir, ONGOING_SEGMENT) + if (!file.exists()) { + file.createNewFile() + } + val map = LinkedHashMap() + file.useLines { lines -> + lines.associateTo(map) { + val (k, v) = it.split("=", limit = 2) + k to v + } + if (value == null) { + map.remove(key) + } else { + map[key] = value + } + } + file.writeText(map.entries.joinToString("\n") { (k, v) -> "$k=$v" }) + } + + companion object { + private const val ONGOING_SEGMENT = ".ongoing_segment" + + internal const val SEGMENT_KEY_HEIGHT = "config.height" + internal const val SEGMENT_KEY_WIDTH = "config.width" + internal const val SEGMENT_KEY_FRAME_RATE = "config.frame-rate" + internal const val SEGMENT_KEY_BIT_RATE = "config.bit-rate" + internal const val SEGMENT_KEY_TIMESTAMP = "segment.timestamp" + internal const val SEGMENT_KEY_REPLAY_ID = "replay.id" + internal const val SEGMENT_KEY_REPLAY_TYPE = "replay.type" + internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" + internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" + internal const val SEGMENT_KEY_ID = "segment.id" + + fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? { + return 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() } + } + } + + internal fun fromDisk(options: SentryOptions, replayId: SentryId): LastSegmentData? { + val replayCacheDir = makeReplayCacheDir(options, replayId) + val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) + if (!lastSegmentFile.exists()) { + options.logger.log(DEBUG, "No ongoing segment found for replay: %s", replayId) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val lastSegment = LinkedHashMap() + lastSegmentFile.useLines { lines -> + lines.associateTo(lastSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + + val height = lastSegment[SEGMENT_KEY_HEIGHT]?.toIntOrNull() + val width = lastSegment[SEGMENT_KEY_WIDTH]?.toIntOrNull() + val frameRate = lastSegment[SEGMENT_KEY_FRAME_RATE]?.toIntOrNull() + val bitRate = lastSegment[SEGMENT_KEY_BIT_RATE]?.toIntOrNull() + val segmentId = lastSegment[SEGMENT_KEY_ID]?.toIntOrNull() + val segmentTimestamp = try { + DateUtils.getDateTime(lastSegment[SEGMENT_KEY_TIMESTAMP].orEmpty()) + } catch (e: Throwable) { + null + } + val replayType = try { + ReplayType.valueOf(lastSegment[SEGMENT_KEY_REPLAY_TYPE].orEmpty()) + } catch (e: Throwable) { + null + } + if (height == null || width == null || frameRate == null || bitRate == null || + segmentId == null || segmentTimestamp == null || replayType == null + ) { + options.logger.log( + DEBUG, + "Incorrect segment values found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val recorderConfig = ScreenshotRecorderConfig( + recordingHeight = height, + recordingWidth = width, + frameRate = frameRate, + bitRate = bitRate, + // these are not used for already captured frames, so we just hardcode them + scaleFactorX = 1.0f, + scaleFactorY = 1.0f + ) + + val cache = ReplayCache(options, replayId, recorderConfig) + cache.replayCacheDir?.listFiles { dir, name -> + if (name.endsWith(".jpg")) { + val file = File(dir, name) + val timestamp = file.nameWithoutExtension.toLongOrNull() + if (timestamp != null) { + cache.addFrame(file, timestamp) + } + } + false + } + + cache.frames.sortBy { it.timestamp } + + val duration = if (replayType == SESSION) { + options.experimental.sessionReplay.sessionSegmentDuration + } else { + options.experimental.sessionReplay.errorReplayDuration + } + + val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { + val reader = StringReader(it) + val recording = options.serializer.deserialize(reader, ReplayRecording::class.java) + if (recording?.payload != null) { + LinkedList(recording.payload!!) + } else { + null + } + } ?: emptyList() + + return LastSegmentData( + recorderConfig = recorderConfig, + cache = cache, + timestamp = segmentTimestamp, + id = segmentId, + duration = duration, + replayType = replayType, + screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], + events = events.sortedBy { it.timestamp } + ) + } } } +internal data class LastSegmentData( + val recorderConfig: ScreenshotRecorderConfig, + val cache: ReplayCache, + val timestamp: Date, + val id: Int, + val duration: Long, + val replayType: ReplayType, + val screenAtStart: String?, + val events: List +) + internal data class ReplayFrame( val screenshot: File, val timestamp: 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 index c6410339ba..bd05b5ab65 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -122,12 +122,12 @@ public class ReplayIntegration( recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = if (isFullSession) { - SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) + SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) + BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider) } - captureStrategy?.start() + captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) } @@ -158,16 +158,18 @@ public class ReplayIntegration( return } - if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) { + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId)) { 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?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { + captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 + }) captureStrategy = captureStrategy?.convert() } - override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId ?: SentryId.EMPTY_ID override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { replayBreadcrumbConverter = converter diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 96bb200d71..d024a4180d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -1,6 +1,7 @@ package io.sentry.android.replay.capture import android.view.MotionEvent +import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -8,11 +9,25 @@ import io.sentry.ReplayRecording import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely +import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent @@ -25,21 +40,23 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils +import java.io.BufferedWriter import java.io.File +import java.io.StringWriter 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 +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty 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 { @@ -52,15 +69,37 @@ internal abstract class BaseCaptureStrategy( private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 } + private val persistingExecutor: ScheduledExecutorService by lazy { + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + } + protected var cache: ReplayCache? = null - protected val segmentTimestamp = AtomicReference() + protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + if (newValue == null) { + // recorderConfig is only nullable on init, but never after + return@persistableAtomic + } + cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_FRAME_RATE, newValue.frameRate.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_BIT_RATE, newValue.bitRate.toString()) + } + protected var segmentTimestamp by persistableAtomicNullable(propertyName = SEGMENT_KEY_TIMESTAMP) { _, _, newValue -> + cache?.persistSegmentValues(SEGMENT_KEY_TIMESTAMP, if (newValue == null) null else DateUtils.getTimestamp(newValue)) + } protected val replayStartTimestamp = AtomicLong() - protected val screenAtStart = AtomicReference() - override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) - override val currentSegment = AtomicInteger(0) + protected var screenAtStart by persistableAtomicNullable(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START) + override var currentReplayId: SentryId by persistableAtomic(initialValue = SentryId.EMPTY_ID, propertyName = SEGMENT_KEY_REPLAY_ID) + override var currentSegment: Int by persistableAtomic(initialValue = -1, propertyName = SEGMENT_KEY_ID) override val replayCacheDir: File? get() = cache?.replayCacheDir - protected val currentEvents = LinkedList() + private var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + protected val currentEvents: LinkedList = PersistableLinkedList( + propertyName = SEGMENT_KEY_REPLAY_RECORDING, + options, + persistingExecutor, + cacheProvider = { cache } + ) private val currentEventsLock = Any() private val currentPositions = LinkedHashMap>(10) private var touchMoveBaseline = 0L @@ -70,19 +109,16 @@ internal abstract class BaseCaptureStrategy( executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - currentSegment.set(segmentId) - currentReplayId.set(replayId) - + override fun start(recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { if (cleanupOldReplays) { replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { + val unfinishedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: "" // 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() - ) + if (name.startsWith("replay_") && + !name.contains(replayId.toString()) && + !name.contains(unfinishedReplayId) ) { FileUtils.deleteRecursively(File(dir, name)) } @@ -95,25 +131,31 @@ internal abstract class BaseCaptureStrategy( cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId, recorderConfig) + replayType = if (this is SessionCaptureStrategy) SESSION else BUFFER + this.recorderConfig = recorderConfig + currentSegment = segmentId + currentReplayId = replayId + // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + segmentTimestamp = 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) + + finalizePreviousReplay() } override fun resume() { // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + segmentTimestamp = DateUtils.getCurrentDateTime() } override fun pause() = Unit override fun stop() { cache?.close() - currentSegment.set(0) + currentSegment = -1 replayStartTimestamp.set(0) - segmentTimestamp.set(null) - currentReplayId.set(SentryId.EMPTY_ID) + segmentTimestamp = null + currentReplayId = SentryId.EMPTY_ID } protected fun createSegment( @@ -123,7 +165,12 @@ internal abstract class BaseCaptureStrategy( segmentId: Int, height: Int, width: Int, - replayType: ReplayType = SESSION + replayType: ReplayType = SESSION, + cache: ReplayCache? = this.cache, + frameRate: Int = recorderConfig.frameRate, + screenAtStart: String? = this.screenAtStart, + breadcrumbs: List? = null, + events: LinkedList = this.currentEvents ): ReplaySegment { val generatedVideo = cache?.createVideoOf( duration, @@ -134,6 +181,17 @@ internal abstract class BaseCaptureStrategy( ) ?: return ReplaySegment.Failed val (video, frameCount, videoDuration) = generatedVideo + + val replayBreadcrumbs: List = if (breadcrumbs == null) { + var crumbs = emptyList() + hub?.configureScope { scope -> + crumbs = ArrayList(scope.breadcrumbs) + } + crumbs + } else { + breadcrumbs + } + return buildReplay( video, replayId, @@ -142,8 +200,12 @@ internal abstract class BaseCaptureStrategy( height, width, frameCount, + frameRate, videoDuration, - replayType + replayType, + screenAtStart, + replayBreadcrumbs, + events ) } @@ -155,8 +217,12 @@ internal abstract class BaseCaptureStrategy( height: Int, width: Int, frameCount: Int, + frameRate: Int, duration: Long, - replayType: ReplayType + replayType: ReplayType, + screenAtStart: String?, + breadcrumbs: List, + events: LinkedList ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) val replay = SentryReplayEvent().apply { @@ -181,7 +247,7 @@ internal abstract class BaseCaptureStrategy( this.durationMs = duration this.frameCount = frameCount size = video.length() - frameRate = recorderConfig.frameRate + this.frameRate = frameRate this.height = height this.width = width // TODO: support non-fullscreen windows later @@ -190,33 +256,31 @@ internal abstract class BaseCaptureStrategy( } 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) - } + 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()) + if (screenAtStart != null && urls.first != screenAtStart) { + urls.addFirst(screenAtStart) } - rotateCurrentEvents(endTimestamp.time) { event -> + rotateEvents(events, endTimestamp.time) { event -> if (event.timestamp >= segmentTimestamp.time) { recordingPayload += event } @@ -252,20 +316,53 @@ internal abstract class BaseCaptureStrategy( replayExecutor.gracefullyShutdown(options) } - protected fun rotateCurrentEvents( + protected fun rotateEvents( + events: LinkedList, until: Long, callback: ((RRWebEvent) -> Unit)? = null ) { synchronized(currentEventsLock) { - var event = currentEvents.peek() + var event = events.peek() while (event != null && event.timestamp < until) { callback?.invoke(event) - currentEvents.remove() - event = currentEvents.peek() + events.remove() + event = events.peek() } } } + private fun finalizePreviousReplay() { + // TODO: run it on options.executorService and read persisted options/scope values form the + // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor + + replayExecutor.submitSafely(options, "$TAG.finalize_previous_replay") { + val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: return@submitSafely + val previousReplayId = SentryId(previousReplayIdString) + val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + + val lastSegment = ReplayCache.fromDisk(options, previousReplayId) ?: return@submitSafely + val segment = createSegment( + duration = lastSegment.duration, + currentSegmentTimestamp = lastSegment.timestamp, + replayId = previousReplayId, + segmentId = lastSegment.id, + height = lastSegment.recorderConfig.recordingHeight, + width = lastSegment.recorderConfig.recordingWidth, + frameRate = lastSegment.recorderConfig.frameRate, + cache = lastSegment.cache, + replayType = lastSegment.replayType, + screenAtStart = lastSegment.screenAtStart, + breadcrumbs = breadcrumbs, + events = LinkedList(lastSegment.events) + ) + + if (segment is ReplaySegment.Created) { + segment.capture(hub) + } + FileUtils.deleteRecursively(lastSegment.cache.replayCacheDir) + } + } + private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -275,6 +372,15 @@ internal abstract class BaseCaptureStrategy( } } + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret + } + } + protected sealed class ReplaySegment { object Failed : ReplaySegment() data class Created( @@ -416,4 +522,95 @@ internal abstract class BaseCaptureStrategy( else -> null } } + + private inline fun persistableAtomicNullable( + initialValue: T? = null, + propertyName: String? = null, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + propertyName ?: error("Can't persist value without a property name") + + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submit { + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + } else { + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + } + ): ReadWriteProperty = + object : ReadWriteProperty { + private val value = AtomicReference(initialValue) + + init { + onChange(propertyName, initialValue, initialValue) + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + val oldValue = this.value.getAndSet(value) + if (oldValue != value) { + onChange(propertyName, oldValue, value) + } + } + } + + private inline fun persistableAtomic( + initialValue: T? = null, + propertyName: String? = null, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + propertyName ?: error("Can't persist value without a property name") + + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submit { + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + } else { + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + } + ): ReadWriteProperty = + persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty + + private class PersistableLinkedList( + private val propertyName: String, + private val options: SentryOptions, + private val persistingExecutor: ScheduledExecutorService, + private val cacheProvider: () -> ReplayCache? + ) : LinkedList() { + // only overriding methods that we use, to observe the collection + override fun addAll(elements: Collection): Boolean { + val result = super.addAll(elements) + persistRecording() + return result + } + + override fun add(element: RRWebEvent): Boolean { + val result = super.add(element) + persistRecording() + return result + } + + override fun remove(): RRWebEvent { + val result = super.remove() + persistRecording() + return result + } + + private fun persistRecording() { + val cache = cacheProvider() ?: return + val recording = ReplayRecording().apply { payload = this@PersistableLinkedList } + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submit { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } else { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 8f3682f965..74eeeca4b7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -22,10 +22,9 @@ 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) { +) : BaseCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) { private val bufferedSegments = mutableListOf() private val bufferedScreensLock = Any() @@ -35,8 +34,13 @@ internal class BufferCaptureStrategy( private const val TAG = "BufferCaptureStrategy" } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - super.start(segmentId, replayId, cleanupOldReplays) + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId, + cleanupOldReplays: Boolean + ) { + super.start(recorderConfig, segmentId, replayId, cleanupOldReplays) hub?.configureScope { val screen = it.screen @@ -86,8 +90,8 @@ internal class BufferCaptureStrategy( } else { DateUtils.getDateTime(now - errorReplayDuration) } - val segmentId = currentSegment.get() - val replayId = currentReplayId.get() + val segmentId = currentSegment + val replayId = currentReplayId val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth @@ -138,7 +142,7 @@ internal class BufferCaptureStrategy( // 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() + currentSegment-- deleteFile(it.replay.videoFile) removed = true return@removeAll true @@ -176,9 +180,9 @@ internal class BufferCaptureStrategy( } else { DateUtils.getDateTime(now - errorReplayDuration) } - val segmentId = currentSegment.get() + val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() + val replayId = currentReplayId val height = this.recorderConfig.recordingHeight val width = this.recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { @@ -187,7 +191,7 @@ internal class BufferCaptureStrategy( if (segment is ReplaySegment.Created) { bufferedSegments += segment - currentSegment.getAndIncrement() + currentSegment++ } } super.onConfigurationChanged(recorderConfig) @@ -195,15 +199,15 @@ internal class BufferCaptureStrategy( 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) + val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, cleanupOldReplays = false) return captureStrategy } override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration - rotateCurrentEvents(bufferLimit) + rotateEvents(currentEvents, bufferLimit) } private fun findAndSetStartScreen(segmentStart: Long) { @@ -214,7 +218,7 @@ internal class BufferCaptureStrategy( // 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) + screenAtStart = 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 index 61c4107183..71aa2f6b53 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -6,15 +6,13 @@ 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 + var currentSegment: Int + var currentReplayId: SentryId val replayCacheDir: File? - fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + fun start(recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) fun stop() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 05c7dba11d..b43d48620c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -18,22 +18,26 @@ 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) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider) { internal companion object { private const val TAG = "SessionCaptureStrategy" } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - super.start(segmentId, replayId, cleanupOldReplays) + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId, + cleanupOldReplays: Boolean + ) { + super.start(recorderConfig, 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) + it.replayId = currentReplayId + screenAtStart = it.screen } } @@ -42,7 +46,7 @@ internal class SessionCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ } } super.pause() @@ -82,43 +86,47 @@ internal class SessionCaptureStrategy( 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 currentSegmentTimestamp = segmentTimestamp + currentSegmentTimestamp ?: run { + options.logger.log(DEBUG, "Segment timestamp is not set, not recording frame") + return@submitSafely + } + val now = dateProvider.currentTimeMillis + if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { val segment = createSegment( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, - replayId, - segmentId, + currentReplayId, + currentSegment, height, width ) if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) } - } else if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { - stop() + } + + if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } } } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { - val currentSegmentTimestamp = segmentTimestamp.get() + val currentSegmentTimestamp = segmentTimestamp ?: return createCurrentSegment("onConfigurationChanged") { segment -> if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) } } @@ -130,10 +138,10 @@ internal class SessionCaptureStrategy( 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 currentSegmentTimestamp = segmentTimestamp ?: return + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 48b1bfe622..ab98ff149a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -741,10 +741,11 @@ public abstract interface class io/sentry/IScopeObserver { public abstract fun setExtras (Ljava/util/Map;)V public abstract fun setFingerprint (Ljava/util/Collection;)V public abstract fun setLevel (Lio/sentry/SentryLevel;)V + public abstract fun setReplayId (Lio/sentry/protocol/SentryId;)V public abstract fun setRequest (Lio/sentry/protocol/Request;)V public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setTags (Ljava/util/Map;)V - public abstract fun setTrace (Lio/sentry/SpanContext;)V + public abstract fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public abstract fun setTransaction (Ljava/lang/String;)V public abstract fun setUser (Lio/sentry/protocol/User;)V } @@ -1663,6 +1664,7 @@ public final class io/sentry/PropagationContext { public fun setSampled (Ljava/lang/Boolean;)V public fun setSpanId (Lio/sentry/SpanId;)V public fun setTraceId (Lio/sentry/protocol/SentryId;)V + public fun toSpanContext ()Lio/sentry/SpanContext; public fun traceContext ()Lio/sentry/TraceContext; } @@ -1800,10 +1802,11 @@ public abstract class io/sentry/ScopeObserverAdapter : io/sentry/IScopeObserver public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V public fun setTags (Ljava/util/Map;)V - public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V } @@ -3344,6 +3347,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static final field EXTRAS_FILENAME Ljava/lang/String; public static final field FINGERPRINT_FILENAME Ljava/lang/String; public static final field LEVEL_FILENAME Ljava/lang/String; + public static final field REPLAY_FILENAME Ljava/lang/String; public static final field REQUEST_FILENAME Ljava/lang/String; public static final field SCOPE_CACHE Ljava/lang/String; public static final field TAGS_FILENAME Ljava/lang/String; @@ -3358,9 +3362,10 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setTags (Ljava/util/Map;)V - public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V } @@ -3967,6 +3972,7 @@ public final class io/sentry/protocol/Browser$JsonKeys { } public final class io/sentry/protocol/Contexts : java/util/concurrent/ConcurrentHashMap, io/sentry/JsonSerializable { + public static final field REPLAY_TYPE Ljava/lang/String; public fun ()V public fun (Lio/sentry/protocol/Contexts;)V public fun getApp ()Lio/sentry/protocol/App; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 53bd10248e..27c07add08 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -1,5 +1,7 @@ package io.sentry; +import static io.sentry.protocol.Contexts.REPLAY_TYPE; + import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.protocol.User; @@ -141,7 +143,12 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); - // TODO: add replay_id later + final @Nullable Object replayId = event.getContexts().get(REPLAY_TYPE); + if (replayId != null && !replayId.toString().equals(SentryId.EMPTY_ID.toString())) { + baggage.setReplayId(replayId.toString()); + // relay will set it from the DSC, we don't need to send it + event.getContexts().remove(REPLAY_TYPE); + } baggage.freeze(); return baggage; } diff --git a/sentry/src/main/java/io/sentry/IScopeObserver.java b/sentry/src/main/java/io/sentry/IScopeObserver.java index 4a103668d2..a43ccf6b69 100644 --- a/sentry/src/main/java/io/sentry/IScopeObserver.java +++ b/sentry/src/main/java/io/sentry/IScopeObserver.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.Map; @@ -41,5 +42,7 @@ public interface IScopeObserver { void setTransaction(@Nullable String transaction); - void setTrace(@Nullable SpanContext spanContext); + void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope); + + void setReplayId(@NotNull SentryId replayId); } diff --git a/sentry/src/main/java/io/sentry/PropagationContext.java b/sentry/src/main/java/io/sentry/PropagationContext.java index 9a29e8c161..b0debc2a9d 100644 --- a/sentry/src/main/java/io/sentry/PropagationContext.java +++ b/sentry/src/main/java/io/sentry/PropagationContext.java @@ -139,4 +139,10 @@ public void setSampled(final @Nullable Boolean sampled) { return null; } + + public @NotNull SpanContext toSpanContext() { + final SpanContext spanContext = new SpanContext(traceId, spanId, "default", null, null); + spanContext.setOrigin("auto"); + return spanContext; + } } diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index ca1c676dbd..a83eddd380 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -81,7 +81,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger // {"segment_id":0}\n{json-serialized-rrweb-protocol} writer.setLenient(true); - writer.jsonValue("\n"); + if (segmentId != null) { + writer.jsonValue("\n"); + } if (payload != null) { writer.value(logger, payload); } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 161502a9d3..c818b3b19f 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -243,10 +243,10 @@ public void setTransaction(final @Nullable ITransaction transaction) { for (final IScopeObserver observer : options.getScopeObservers()) { if (transaction != null) { observer.setTransaction(transaction.getName()); - observer.setTrace(transaction.getSpanContext()); + observer.setTrace(transaction.getSpanContext(), this); } else { observer.setTransaction(null); - observer.setTrace(null); + observer.setTrace(null, this); } } } @@ -326,7 +326,9 @@ public void setScreen(final @Nullable String screen) { public void setReplayId(final @NotNull SentryId replayId) { this.replayId = replayId; - // TODO: set to contexts and notify observers to persist this as well + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setReplayId(replayId); + } } /** @@ -486,7 +488,7 @@ public void clearTransaction() { for (final IScopeObserver observer : options.getScopeObservers()) { observer.setTransaction(null); - observer.setTrace(null); + observer.setTrace(null, this); } } @@ -934,6 +936,10 @@ public SentryOptions getOptions() { @Override public void setPropagationContext(final @NotNull PropagationContext propagationContext) { this.propagationContext = propagationContext; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTrace(propagationContext.toSpanContext(), this); + } } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java index 38d0cdf7a1..f0ec6448e0 100644 --- a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.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.Map; @@ -52,5 +53,8 @@ public void setContexts(@NotNull Contexts contexts) {} public void setTransaction(@Nullable String transaction) {} @Override - public void setTrace(@Nullable SpanContext spanContext) {} + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) {} + + @Override + public void setReplayId(@NotNull SentryId replayId) {} } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 525b9484f1..a6b77d282e 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -305,6 +305,7 @@ private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hin } try { + // TODO: check if event is Backfillable and backfill traceContext from the event values @Nullable TraceContext traceContext = null; if (scope != null) { final @Nullable ITransaction transaction = scope.getTransaction(); diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 0c4a110733..73cac15aa4 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -3,6 +3,7 @@ import static io.sentry.SentryLevel.ERROR; import io.sentry.Breadcrumb; +import io.sentry.IScope; import io.sentry.JsonDeserializer; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; @@ -10,6 +11,7 @@ import io.sentry.SpanContext; 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.Map; @@ -29,6 +31,7 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String FINGERPRINT_FILENAME = "fingerprint.json"; public static final String TRANSACTION_FILENAME = "transaction.json"; public static final String TRACE_FILENAME = "trace.json"; + public static final String REPLAY_FILENAME = "replay.json"; private final @NotNull SentryOptions options; @@ -105,11 +108,13 @@ public void setTransaction(@Nullable String transaction) { } @Override - public void setTrace(@Nullable SpanContext spanContext) { + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) { serializeToDisk( () -> { if (spanContext == null) { - delete(TRACE_FILENAME); + // we always need a trace_id to properly link with traces/replays, so we fallback to + // propagation context values and create a fake SpanContext + store(scope.getPropagationContext().toSpanContext(), TRACE_FILENAME); } else { store(spanContext, TRACE_FILENAME); } @@ -121,6 +126,11 @@ public void setContexts(@NotNull Contexts contexts) { serializeToDisk(() -> store(contexts, CONTEXTS_FILENAME)); } + @Override + public void setReplayId(@NotNull SentryId replayId) { + serializeToDisk(() -> store(replayId, REPLAY_FILENAME)); + } + @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { try { diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 28d2e8d2a4..07f954747e 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -19,6 +19,7 @@ public final class Contexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 252445813254943011L; + public static final String REPLAY_TYPE = "replay"; /** Response lock, Ops should be atomic */ private final @NotNull Object responseLock = new Object(); From 641a434bdf2e7839ac935052bf6acbbc31ab5d7f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Jul 2024 12:50:43 +0200 Subject: [PATCH 165/184] Tests --- .../android/core/AnrV2EventProcessor.java | 4 +- .../android/core/AnrV2EventProcessorTest.kt | 5 + .../io/sentry/android/replay/ReplayCache.kt | 10 +- .../replay/capture/BaseCaptureStrategy.kt | 3 + .../sentry/android/replay/ReplayCacheTest.kt | 188 ++++++++++++++++++ sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/Baggage.java | 6 +- .../java/io/sentry/protocol/Contexts.java | 2 +- sentry/src/test/java/io/sentry/ScopeTest.kt | 24 ++- .../test/java/io/sentry/SentryClientTest.kt | 2 + .../cache/PersistingScopeObserverTest.kt | 83 +++++--- 11 files changed, 281 insertions(+), 48 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index a6906b647c..1b386c38e0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -15,7 +15,7 @@ import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; -import static io.sentry.protocol.Contexts.REPLAY_TYPE; +import static io.sentry.protocol.Contexts.REPLAY_ID; import android.annotation.SuppressLint; import android.app.ActivityManager; @@ -164,7 +164,7 @@ private void setReplayId(final @NotNull SentryEvent event) { return; } - event.getContexts().put(REPLAY_TYPE, persistedReplayId); + event.getContexts().put(REPLAY_ID, persistedReplayId); } private void setTrace(final @NotNull SentryEvent event) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index b581856fe0..2c381c6285 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -27,6 +27,7 @@ import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME @@ -44,6 +45,7 @@ import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId import io.sentry.protocol.SentryStackFrame import io.sentry.protocol.SentryStackTrace import io.sentry.protocol.SentryThread @@ -118,6 +120,7 @@ class AnrV2EventProcessorTest { REQUEST_FILENAME, Request().apply { url = "google.com"; method = "GET" } ) + persistScope(REPLAY_FILENAME, SentryId("64cf554cc8d74c6eafa3e08b7c984f6d")) } if (populateOptionsCache) { @@ -292,6 +295,8 @@ class AnrV2EventProcessorTest { // contexts assertEquals(1024, processed.contexts.response!!.bodySize) assertEquals("Google Chrome", processed.contexts.browser!!.name) + // replay_id + assertEquals("64cf554cc8d74c6eafa3e08b7c984f6d", processed.contexts[Contexts.REPLAY_ID].toString()) } @Test diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index d7535e9ce6..0c884c2272 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -74,7 +74,7 @@ public class ReplayCache internal constructor( /** * 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 [makeReplayCacheDir]. + * under [replayCacheDir]. * * This method is not thread-safe. * @@ -114,14 +114,14 @@ public class ReplayCache internal constructor( /** * 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 "[makeReplayCacheDir]/[segmentId].mp4". + * [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 [makeReplayCacheDir], e.g. "replay_/0.mp4", where segmentId=0 + * 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 @@ -269,7 +269,7 @@ public class ReplayCache internal constructor( } companion object { - private const val ONGOING_SEGMENT = ".ongoing_segment" + internal const val ONGOING_SEGMENT = ".ongoing_segment" internal const val SEGMENT_KEY_HEIGHT = "config.height" internal const val SEGMENT_KEY_WIDTH = "config.width" @@ -327,7 +327,7 @@ public class ReplayCache internal constructor( null } if (height == null || width == null || frameRate == null || bitRate == null || - segmentId == null || segmentTimestamp == null || replayType == null + (segmentId == null || segmentId == -1) || segmentTimestamp == null || replayType == null ) { options.logger.log( DEBUG, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index d024a4180d..2a5c079105 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -338,6 +338,9 @@ internal abstract class BaseCaptureStrategy( replayExecutor.submitSafely(options, "$TAG.finalize_previous_replay") { val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: return@submitSafely val previousReplayId = SentryId(previousReplayIdString) + if (previousReplayId == SentryId.EMPTY_ID) { + return@submitSafely + } val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List val lastSegment = ReplayCache.fromDisk(options, previousReplayId) ?: return@submitSafely diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 3608b77ccb..74a9ff04e7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -5,10 +5,24 @@ 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.DateUtils import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith @@ -18,6 +32,7 @@ import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -245,4 +260,177 @@ class ReplayCacheTest { assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(flutterCacheDir, "flutter_0.mp4"), segment0.video) } + + @Test + fun `does not persist segment if already closed`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.close() + + replayCache.persistSegmentValues("key", "value") + assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) + } + + @Test + fun `stores segment key value pairs`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key1=value1", segmentValues[0]) + assertEquals("key2=value2", segmentValues[1]) + } + + @Test + fun `removes segment key value pair, if the value is null`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + replayCache.persistSegmentValues("key1", null) + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals(1, segmentValues.size) + assertEquals("key2=value2", segmentValues[0]) + } + + @Test + fun `if no ongoing_segment file exists, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId") + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `if one of the required segment values is not present, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + """.trimIndent() + ) + // omitting replay type, which is required, for the test + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `returns last segment data when all values are present`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(912, lastSegment.recorderConfig.recordingHeight) + assertEquals(416, lastSegment.recorderConfig.recordingWidth) + assertEquals(1, lastSegment.recorderConfig.frameRate) + assertEquals(75000, lastSegment.recorderConfig.bitRate) + assertEquals(0, lastSegment.id) + assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) + assertEquals(ReplayType.SESSION, lastSegment.replayType) + assertEquals(fixture.options.experimental.sessionReplay.sessionSegmentDuration, lastSegment.duration) + assertTrue { + val firstEvent = lastSegment.events.first() as RRWebInteractionEvent + firstEvent.timestamp == 1720693523997 && + firstEvent.interactionType == TouchStart && + firstEvent.x.toDouble() == 314.2979431152344 && + firstEvent.y.toDouble() == 625.44140625 + } + assertTrue { + val lastEvent = lastSegment.events.last() as RRWebInteractionEvent + lastEvent.timestamp == 1720693524774 && + lastEvent.interactionType == TouchEnd && + lastEvent.x.toDouble() == 322.00390625 && + lastEvent.y.toDouble() == 424.4384765625 + } + } + + @Test + fun `fills in cache with frames from disk`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(1, lastSegment.cache.frames.size) + assertEquals(1, lastSegment.cache.frames.first().timestamp) + assertEquals("1.jpg", lastSegment.cache.frames.first().screenshot.name) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ab98ff149a..e31f884650 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3972,7 +3972,7 @@ public final class io/sentry/protocol/Browser$JsonKeys { } public final class io/sentry/protocol/Contexts : java/util/concurrent/ConcurrentHashMap, io/sentry/JsonSerializable { - public static final field REPLAY_TYPE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public fun ()V public fun (Lio/sentry/protocol/Contexts;)V public fun getApp ()Lio/sentry/protocol/App; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 27c07add08..b7d2008743 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -1,6 +1,6 @@ package io.sentry; -import static io.sentry.protocol.Contexts.REPLAY_TYPE; +import static io.sentry.protocol.Contexts.REPLAY_ID; import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; @@ -143,11 +143,11 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); - final @Nullable Object replayId = event.getContexts().get(REPLAY_TYPE); + final @Nullable Object replayId = event.getContexts().get(REPLAY_ID); if (replayId != null && !replayId.toString().equals(SentryId.EMPTY_ID.toString())) { baggage.setReplayId(replayId.toString()); // relay will set it from the DSC, we don't need to send it - event.getContexts().remove(REPLAY_TYPE); + event.getContexts().remove(REPLAY_ID); } baggage.freeze(); return baggage; diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 07f954747e..ba4cbe51cb 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -19,7 +19,7 @@ public final class Contexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 252445813254943011L; - public static final String REPLAY_TYPE = "replay"; + public static final String REPLAY_ID = "replay_id"; /** Response lock, Ops should be atomic */ private final @NotNull Object responseLock = new Object(); diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 906c897c62..07b9176de7 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -2,6 +2,7 @@ package io.sentry import io.sentry.SentryLevel.WARNING import io.sentry.protocol.Request +import io.sentry.protocol.SentryId import io.sentry.protocol.User import io.sentry.test.callMethod import org.junit.Assert.assertArrayEquals @@ -738,7 +739,7 @@ class ScopeTest { whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) } verify(observer).setTransaction(eq("main")) - verify(observer).setTrace(argThat { operation == "ui.load" }) + verify(observer).setTrace(argThat { operation == "ui.load" }, eq(scope)) } @Test @@ -751,7 +752,7 @@ class ScopeTest { scope.transaction = null verify(observer).setTransaction(null) - verify(observer).setTrace(null) + verify(observer).setTrace(null, scope) } @Test @@ -767,11 +768,11 @@ class ScopeTest { whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) } verify(observer).setTransaction(eq("main")) - verify(observer).setTrace(argThat { operation == "ui.load" }) + verify(observer).setTrace(argThat { operation == "ui.load" }, eq(scope)) scope.clearTransaction() verify(observer).setTransaction(null) - verify(observer).setTrace(null) + verify(observer).setTrace(null, scope) } @Test @@ -819,6 +820,21 @@ class ScopeTest { ) } + @Test + fun `Scope set propagation context sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { + addScopeObserver(observer) + } + val scope = Scope(options) + + scope.propagationContext = PropagationContext(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), SpanId(), null, null, null) + verify(observer).setTrace( + argThat { traceId.toString() == "64cf554cc8d74c6eafa3e08b7c984f6d" }, + eq(scope) + ) + } + @Test fun `Scope getTransaction returns the transaction if there is no active span`() { val scope = Scope(SentryOptions()) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index b6598c22b2..cbd35bc24f 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -848,6 +848,7 @@ class SentryClientTest { val event = SentryEvent().apply { environment = "release" release = "io.sentry.samples@22.1.1" + contexts[Contexts.REPLAY_ID] = "64cf554cc8d74c6eafa3e08b7c984f6d" contexts.trace = SpanContext(traceId, SpanId(), "ui.load", null, null) transaction = "MainActivity" } @@ -862,6 +863,7 @@ class SentryClientTest { assertEquals("io.sentry.samples@22.1.1", it.header.traceContext!!.release) assertEquals(traceId, it.header.traceContext!!.traceId) assertEquals("MainActivity", it.header.traceContext!!.transaction) + assertEquals(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), it.header.traceContext!!.replayId) }, anyOrNull() ) diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index d31b7088cf..e1927438e5 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -3,6 +3,7 @@ package io.sentry.cache import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.JsonDeserializer +import io.sentry.Scope import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SpanContext @@ -12,6 +13,7 @@ import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME @@ -35,15 +37,21 @@ import org.junit.runners.Parameterized import kotlin.test.Test import kotlin.test.assertEquals -class StoreScopeValue(private val store: PersistingScopeObserver.(T) -> Unit) { - operator fun invoke(value: T, observer: PersistingScopeObserver) { - observer.store(value) +class StoreScopeValue(private val store: PersistingScopeObserver.(T, Scope) -> Unit) { + operator fun invoke(value: T, observer: PersistingScopeObserver, scope: Scope) { + observer.store(value, scope) } } -class DeleteScopeValue(private val delete: PersistingScopeObserver.() -> Unit) { - operator fun invoke(observer: PersistingScopeObserver) { - observer.delete() +class DeleteScopeValue(private val delete: PersistingScopeObserver.(Scope) -> Unit) { + operator fun invoke(observer: PersistingScopeObserver, scope: Scope) { + observer.delete(scope) + } +} + +class DeletedEntityProvider(private val provider: (Scope) -> T?) { + operator fun invoke(scope: Scope): T? { + return provider(scope) } } @@ -53,7 +61,7 @@ class PersistingScopeObserverTest( private val store: StoreScopeValue, private val filename: String, private val delete: DeleteScopeValue, - private val deletedEntity: T?, + private val deletedEntity: DeletedEntityProvider, private val elementDeserializer: JsonDeserializer? ) { @@ -63,6 +71,7 @@ class PersistingScopeObserverTest( class Fixture { val options = SentryOptions() + val scope = Scope(options) fun getSut(cacheDir: TemporaryFolder): PersistingScopeObserver { options.run { @@ -78,14 +87,14 @@ class PersistingScopeObserverTest( @Test fun `store and delete scope value`() { val sut = fixture.getSut(tmpDir) - store(entity, sut) + store(entity, sut, fixture.scope) val persisted = read() assertEquals(entity, persisted) - delete(sut) + delete(sut, fixture.scope) val persistedAfterDeletion = read() - assertEquals(deletedEntity, persistedAfterDeletion) + assertEquals(deletedEntity(fixture.scope), persistedAfterDeletion) } private fun read(): T? = PersistingScopeObserver.read( @@ -103,10 +112,10 @@ class PersistingScopeObserverTest( id = "c4d61c1b-c144-431e-868f-37a46be5e5f2" ipAddress = "192.168.0.1" }, - StoreScopeValue { setUser(it) }, + StoreScopeValue { user, _ -> setUser(user) }, USER_FILENAME, DeleteScopeValue { setUser(null) }, - null, + DeletedEntityProvider { null }, null ) @@ -115,10 +124,10 @@ class PersistingScopeObserverTest( Breadcrumb.navigation("one", "two"), Breadcrumb.userInteraction("click", "viewId", "viewClass") ), - StoreScopeValue> { setBreadcrumbs(it) }, + StoreScopeValue> { breadcrumbs, _ -> setBreadcrumbs(breadcrumbs) }, BREADCRUMBS_FILENAME, DeleteScopeValue { setBreadcrumbs(emptyList()) }, - emptyList(), + DeletedEntityProvider { emptyList() }, Breadcrumb.Deserializer() ) @@ -127,10 +136,10 @@ class PersistingScopeObserverTest( "one" to "two", "tag" to "none" ), - StoreScopeValue> { setTags(it) }, + StoreScopeValue> { tags, _ -> setTags(tags) }, TAGS_FILENAME, DeleteScopeValue { setTags(emptyMap()) }, - emptyMap(), + DeletedEntityProvider { emptyMap() }, null ) @@ -140,10 +149,10 @@ class PersistingScopeObserverTest( "two" to 2, "three" to 3.2 ), - StoreScopeValue> { setExtras(it) }, + StoreScopeValue> { extras, _ -> setExtras(extras) }, EXTRAS_FILENAME, DeleteScopeValue { setExtras(emptyMap()) }, - emptyMap(), + DeletedEntityProvider { emptyMap() }, null ) @@ -156,46 +165,46 @@ class PersistingScopeObserverTest( fragment = "fragment" bodySize = 1000 }, - StoreScopeValue { setRequest(it) }, + StoreScopeValue { request, _ -> setRequest(request) }, REQUEST_FILENAME, DeleteScopeValue { setRequest(null) }, - null, + DeletedEntityProvider { null }, null ) private fun fingerprint(): Array = arrayOf( listOf("finger", "print"), - StoreScopeValue> { setFingerprint(it) }, + StoreScopeValue> { fingerprint, _ -> setFingerprint(fingerprint) }, FINGERPRINT_FILENAME, DeleteScopeValue { setFingerprint(emptyList()) }, - emptyList(), + DeletedEntityProvider { emptyList() }, null ) private fun level(): Array = arrayOf( SentryLevel.WARNING, - StoreScopeValue { setLevel(it) }, + StoreScopeValue { level, _ -> setLevel(level) }, LEVEL_FILENAME, DeleteScopeValue { setLevel(null) }, - null, + DeletedEntityProvider { null }, null ) private fun transaction(): Array = arrayOf( "MainActivity", - StoreScopeValue { setTransaction(it) }, + StoreScopeValue { transaction, _ -> setTransaction(transaction) }, TRANSACTION_FILENAME, DeleteScopeValue { setTransaction(null) }, - null, + DeletedEntityProvider { null }, null ) private fun trace(): Array = arrayOf( SpanContext(SentryId(), SpanId(), "ui.load", null, null), - StoreScopeValue { setTrace(it) }, + StoreScopeValue { trace, scope -> setTrace(trace, scope) }, TRACE_FILENAME, - DeleteScopeValue { setTrace(null) }, - null, + DeleteScopeValue { scope -> setTrace(null, scope) }, + DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() }, null ) @@ -257,10 +266,19 @@ class PersistingScopeObserverTest( } ) }, - StoreScopeValue { setContexts(it) }, + StoreScopeValue { contexts, _ -> setContexts(contexts) }, CONTEXTS_FILENAME, DeleteScopeValue { setContexts(Contexts()) }, - Contexts(), + DeletedEntityProvider { Contexts() }, + null + ) + + private fun replayId(): Array = arrayOf( + "64cf554cc8d74c6eafa3e08b7c984f6d", + StoreScopeValue { replayId, _ -> setReplayId(SentryId(replayId)) }, + REPLAY_FILENAME, + DeleteScopeValue { setReplayId(SentryId.EMPTY_ID) }, + DeletedEntityProvider { SentryId.EMPTY_ID.toString() }, null ) @@ -277,7 +295,8 @@ class PersistingScopeObserverTest( level(), transaction(), trace(), - contexts() + contexts(), + replayId() ) } } From 8cf2d1cf3e2a89bb4e8f116675fb33ed1e1752fb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Jul 2024 13:56:43 +0200 Subject: [PATCH 166/184] Api dump --- sentry/api/sentry.api | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f396e364fd..708083ea3c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2719,7 +2719,6 @@ public final class io/sentry/SentryReplayOptions { public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V public fun setRedactAllImages (Z)V public fun setRedactAllText (Z)V - public fun setRedactClasses (Ljava/util/Set;)V public fun setSessionSampleRate (Ljava/lang/Double;)V } From 544bd00c5e9a42e01dbfd0f2379d4928c5a57d4d Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 16 Jul 2024 21:22:47 +0000 Subject: [PATCH 167/184] Format code --- .../java/io/sentry/android/replay/capture/CaptureStrategy.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index f6eaeab653..949bc86777 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -7,8 +7,6 @@ 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 { var currentSegment: Int From c0ebbede019df0f2ba6044efa4e2e557ba0e3ea2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 17 Jul 2024 13:29:17 +0200 Subject: [PATCH 168/184] Resolve merge conflicts --- .../android/replay/ReplayIntegration.kt | 2 - .../android/replay/ScreenshotRecorder.kt | 3 -- .../replay/capture/BaseCaptureStrategy.kt | 47 +--------------- .../sentry/android/replay/util/Persistable.kt | 53 +++++++++++++++++++ 4 files changed, 55 insertions(+), 50 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 42643d5916..df461a9369 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -236,8 +236,6 @@ public class ReplayIntegration( } catch (ignored: Throwable) { } stop() - captureStrategy?.close() - captureStrategy = null recorder?.close() recorder = null } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 3e794fcde1..40fb6ef931 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -13,8 +13,6 @@ 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 @@ -51,7 +49,6 @@ internal class ScreenshotRecorder( 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( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 0d26cd960e..e97a317c02 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -23,6 +23,7 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.PersistableLinkedList import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely import io.sentry.cache.PersistingScopeObserver @@ -40,9 +41,7 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils -import java.io.BufferedWriter import java.io.File -import java.io.StringWriter import java.util.Date import java.util.LinkedList import java.util.concurrent.Executors @@ -275,7 +274,7 @@ internal abstract class BaseCaptureStrategy( } } - if (screenAtStart != null && urls.first != screenAtStart) { + if (screenAtStart != null && urls.firstOrNull() != screenAtStart) { urls.addFirst(screenAtStart) } @@ -573,46 +572,4 @@ internal abstract class BaseCaptureStrategy( } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty - - private class PersistableLinkedList( - private val propertyName: String, - private val options: SentryOptions, - private val persistingExecutor: ScheduledExecutorService, - private val cacheProvider: () -> ReplayCache? - ) : LinkedList() { - // only overriding methods that we use, to observe the collection - override fun addAll(elements: Collection): Boolean { - val result = super.addAll(elements) - persistRecording() - return result - } - - override fun add(element: RRWebEvent): Boolean { - val result = super.add(element) - persistRecording() - return result - } - - override fun remove(): RRWebEvent { - val result = super.remove() - persistRecording() - return result - } - - private fun persistRecording() { - val cache = cacheProvider() ?: return - val recording = ReplayRecording().apply { payload = this@PersistableLinkedList } - if (options.mainThreadChecker.isMainThread) { - persistingExecutor.submit { - val stringWriter = StringWriter() - options.serializer.serialize(recording, BufferedWriter(stringWriter)) - cache.persistSegmentValues(propertyName, stringWriter.toString()) - } - } else { - val stringWriter = StringWriter() - options.serializer.serialize(recording, BufferedWriter(stringWriter)) - cache.persistSegmentValues(propertyName, stringWriter.toString()) - } - } - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt new file mode 100644 index 0000000000..2011e7d5ad --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -0,0 +1,53 @@ +// ktlint-disable filename +package io.sentry.android.replay.util + +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCache +import io.sentry.rrweb.RRWebEvent +import java.io.BufferedWriter +import java.io.StringWriter +import java.util.LinkedList +import java.util.concurrent.ScheduledExecutorService + +internal class PersistableLinkedList( + private val propertyName: String, + private val options: SentryOptions, + private val persistingExecutor: ScheduledExecutorService, + private val cacheProvider: () -> ReplayCache? +) : LinkedList() { + // only overriding methods that we use, to observe the collection + override fun addAll(elements: Collection): Boolean { + val result = super.addAll(elements) + persistRecording() + return result + } + + override fun add(element: RRWebEvent): Boolean { + val result = super.add(element) + persistRecording() + return result + } + + override fun remove(): RRWebEvent { + val result = super.remove() + persistRecording() + return result + } + + private fun persistRecording() { + val cache = cacheProvider() ?: return + val recording = ReplayRecording().apply { payload = this@PersistableLinkedList } + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submit { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } else { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } +} From 7fd180ae6d4a7ad05eab413e052f52d4ee8af5a2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 17 Jul 2024 13:52:28 +0200 Subject: [PATCH 169/184] Fix tests --- .../sentry/android/replay/ReplayIntegrationTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index cb236b6318..01f194a6ba 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -120,7 +120,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start() + verify(captureStrategy, never()).start(any(), any(), any(), any()) } @Test @@ -143,7 +143,7 @@ class ReplayIntegrationTest { replay.start() replay.start() - verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) } @Test @@ -154,7 +154,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, never()).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, never()).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) } @Test @@ -165,7 +165,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) } @Test @@ -236,7 +236,7 @@ class ReplayIntegrationTest { @Test fun `sendReplayForEvent does nothing when currentReplayId is not set`() { val captureStrategy = mock { - whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId.EMPTY_ID)) + whenever(mock.currentReplayId).thenReturn(SentryId.EMPTY_ID) } val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -254,7 +254,7 @@ class ReplayIntegrationTest { @Test fun `sendReplayForEvent calls and converts strategy`() { val captureStrategy = mock { - whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId())) + whenever(mock.currentReplayId).thenReturn(SentryId()) } val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) From eced6ae03a41f3a15c8b1d9483db7a527ad3414c Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 17 Jul 2024 11:54:29 +0000 Subject: [PATCH 170/184] Format code --- .../test/java/io/sentry/android/replay/ReplayIntegrationTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 01f194a6ba..9280d8997a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -27,7 +27,6 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config -import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals From 09fb0e4d3fde284f58ddce36f1b7414b6e51eaee Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Jul 2024 22:54:59 +0200 Subject: [PATCH 171/184] Add SessionCaptureStrategyTest --- .../io/sentry/android/replay/ReplayCache.kt | 4 +- .../replay/capture/BaseCaptureStrategy.kt | 6 +- .../android/replay/ReplayIntegrationTest.kt | 1 - .../capture/SessionCaptureStrategyTest.kt | 474 ++++++++++++++++++ 4 files changed, 478 insertions(+), 7 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 0c884c2272..5af239421e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -294,7 +294,7 @@ public class ReplayCache internal constructor( } } - internal fun fromDisk(options: SentryOptions, replayId: SentryId): LastSegmentData? { + internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? { val replayCacheDir = makeReplayCacheDir(options, replayId) val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) if (!lastSegmentFile.exists()) { @@ -348,7 +348,7 @@ public class ReplayCache internal constructor( scaleFactorY = 1.0f ) - val cache = ReplayCache(options, replayId, recorderConfig) + val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) cache.replayCacheDir?.listFiles { dir, name -> if (name.endsWith(".jpg")) { val file = File(dir, name) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index e97a317c02..6da7e9991f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -117,7 +117,7 @@ internal abstract class BaseCaptureStrategy( File(cacheDir).listFiles { dir, name -> if (name.startsWith("replay_") && !name.contains(replayId.toString()) && - !name.contains(unfinishedReplayId) + !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) ) { FileUtils.deleteRecursively(File(dir, name)) } @@ -134,7 +134,6 @@ internal abstract class BaseCaptureStrategy( currentSegment = segmentId currentReplayId = replayId - // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -142,7 +141,6 @@ internal abstract class BaseCaptureStrategy( } override fun resume() { - // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp = DateUtils.getCurrentDateTime() } @@ -341,7 +339,7 @@ internal abstract class BaseCaptureStrategy( } val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List - val lastSegment = ReplayCache.fromDisk(options, previousReplayId) ?: return@submitSafely + val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: return@submitSafely val segment = createSegment( duration = lastSegment.duration, currentSegmentTimestamp = lastSegment.timestamp, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 9280d8997a..93239f81eb 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -36,7 +36,6 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [26]) class ReplayIntegrationTest { - // write tests for ReplayIntegration with mocked context and other android things @get:Rule val tmpDir = TemporaryFolder() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt new file mode 100644 index 0000000000..0e3fb9f82f --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -0,0 +1,474 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.cache.PersistingScopeObserver +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SessionCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = mutableMapOf() + val replayCache = mock { + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): SessionCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + return SessionCaptureStrategy( + options, + hub, + dateProvider, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + } + + private val fixture = Fixture() + + @Test + fun `start sets replayId on scope for full session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId, false) + + assertEquals(replayId, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId, false) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.SESSION.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertEquals( + fixture.recorderConfig.recordingWidth.toString(), + fixture.persistedSegment[SEGMENT_KEY_WIDTH] + ) + assertEquals( + fixture.recorderConfig.recordingHeight.toString(), + fixture.persistedSegment[SEGMENT_KEY_HEIGHT] + ) + assertEquals( + fixture.recorderConfig.frameRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_FRAME_RATE] + ) + assertEquals( + fixture.recorderConfig.bitRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_BIT_RATE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `start cleans up old replays`() { + val replayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val evenOlderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + + val strategy = fixture.getSut() + + strategy.start(fixture.recorderConfig, 0, replayId, true) + + // deletes older replay folders, but keeps current and previous replay + everything else + assertTrue(currentReplay.exists()) + assertTrue(scopeCache.exists()) + assertFalse(evenOlderReplay.exists()) + } + + @Test + fun `start finalizes previous replay`() { + val replayId = SentryId() + val oldReplayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val oldReplay = + File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).also { + it.createNewFile() + it.writeText("\"$oldReplayId\"") + } + val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + fixture.options.serializer.serialize( + listOf( + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + } + ), + breadcrumbsFile.writer() + ) + File(oldReplay, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=1 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val strategy = fixture.getSut(replayCacheDir = oldReplay) + strategy.start(fixture.recorderConfig, 0, replayId, true) + + assertTrue(currentReplay.exists()) + assertFalse(oldReplay.exists()) + verify(fixture.hub).captureReplay( + check { + assertEquals(oldReplayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(1, videoEvents?.first()?.segmentId) + + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + + val interactionEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals( + InteractionType.TouchStart, + interactionEvents?.first()?.interactionType + ) + assertEquals(314.29794f, interactionEvents?.first()?.x) + assertEquals(625.4414f, interactionEvents?.first()?.y) + + assertEquals(InteractionType.TouchEnd, interactionEvents?.last()?.interactionType) + assertEquals(322.0039f, interactionEvents?.last()?.x) + assertEquals(424.43848f, interactionEvents?.last()?.y) + } + ) + } + + @Test + fun `pause creates and captures current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId(), false) + + strategy.pause() + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop creates and captures current segment and clears replayId from scope`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId, false) + + strategy.stop() + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `sendReplayForEvent captures last segment for crashed event`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.sendReplayForEvent(true, "event-id", Hint()) {} + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + } + + @Test + fun `sendReplayForEvent does nothing for non-crashed event`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.sendReplayForEvent(false, "event-id", Hint()) {} + + verify(fixture.hub, never()).captureReplay(any(), any()) + } + + @Test + fun `onScreenshotRecorded creates new segment when segment duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + + var segmentTimestamp: Date? = null + verify(fixture.hub).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `onScreenshotRecorded stops replay when replay duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionDuration * 2) + var count = 0 + val strategy = fixture.getSut( + dateProvider = { + // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and frameTimestamp) + if (count++ == 2) { + now + } else { + System.currentTimeMillis() + } + } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.options.replayController).stop() + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + var segmentTimestamp: Date? = null + verify(fixture.hub).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + // should still capture with the old values + assertEquals(1920, metaEvents?.first()?.height) + assertEquals(1080, metaEvents?.first()?.width) + } + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + assertEquals("1080", fixture.persistedSegment[SEGMENT_KEY_HEIGHT]) + assertEquals("1920", fixture.persistedSegment[SEGMENT_KEY_WIDTH]) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `fills replay urls from navigation breadcrumbs`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertEquals("to", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + + @Test + fun `sets screen from scope as replay url`() { + fixture.scope.screen = "MainActivity" + + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertEquals("MainActivity", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertTrue(breadcrumbEvents?.isEmpty() == true) + } + ) + } +} From 78b08b10b7995e1d6df8a24400aef8e066129788 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 24 Jul 2024 10:18:28 +0200 Subject: [PATCH 172/184] Infer duration from last frame for the unsent segment --- .../io/sentry/android/replay/ReplayCache.kt | 25 +++++++++++++++---- .../sentry/android/replay/ReplayCacheTest.kt | 8 +++++- .../capture/SessionCaptureStrategyTest.kt | 2 ++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 5af239421e..aaa0223aa7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -10,7 +10,6 @@ import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType -import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId @@ -22,6 +21,7 @@ import java.io.StringReader import java.util.Date import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.ceil /** * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the @@ -360,14 +360,29 @@ public class ReplayCache internal constructor( false } + if (cache.frames.isEmpty()) { + options.logger.log( + DEBUG, + "No frames found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + cache.frames.sortBy { it.timestamp } - val duration = if (replayType == SESSION) { - options.experimental.sessionReplay.sessionSegmentDuration - } else { - options.experimental.sessionReplay.errorReplayDuration + fun roundToNearestFrame(duration: Long, frameDuration: Int): Long { + val frames = duration.toDouble() / frameDuration.toDouble() + return ceil(frames).toLong() * frameDuration } + // we need to round to the nearest frame to include breadcrumbs/events happened after the frame was captured + val duration = roundToNearestFrame( + duration = (cache.frames.last().timestamp - segmentTimestamp.time), + frameDuration = 1000 / frameRate + ) - 1 // we need to subtract 1ms to avoid capturing the next frame which doesn't exist + val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { val reader = StringReader(it) val recording = options.serializer.deserialize(reader, ReplayRecording::class.java) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index ad9955e3af..2b2fc18adf 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -393,6 +393,12 @@ class ReplayCacheTest { ) } + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! assertEquals(912, lastSegment.recorderConfig.recordingHeight) @@ -402,7 +408,7 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) assertEquals(ReplayType.SESSION, lastSegment.replayType) - assertEquals(fixture.options.experimental.sessionReplay.sessionSegmentDuration, lastSegment.duration) + assertEquals(2999, lastSegment.duration) assertTrue { val firstEvent = lastSegment.events.first() as RRWebInteractionEvent firstEvent.timestamp == 1720693523997 && diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 0e3fb9f82f..6dd1b99250 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -23,6 +23,7 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDI import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.cache.PersistingScopeObserver import io.sentry.protocol.SentryId @@ -78,6 +79,7 @@ class SessionCaptureStrategyTest { } var persistedSegment = mutableMapOf() val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) on { persistSegmentValues(any(), anyOrNull()) }.then { persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) } From d7dd9c4b19f2def6311d1a0e8d88cb81b9a17fb6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 27 Jul 2024 00:26:55 +0200 Subject: [PATCH 173/184] Support replays for crashes in buffer and session modes --- .../io/sentry/android/replay/ReplayCache.kt | 29 +- .../android/replay/ReplayIntegration.kt | 96 ++++-- .../replay/capture/BaseCaptureStrategy.kt | 273 +++--------------- .../replay/capture/BufferCaptureStrategy.kt | 172 ++++++----- .../android/replay/capture/CaptureStrategy.kt | 207 ++++++++++++- .../replay/capture/SessionCaptureStrategy.kt | 30 +- .../sentry/android/replay/util/Executors.kt | 20 ++ .../java/io/sentry/NoOpReplayController.java | 6 +- .../main/java/io/sentry/ReplayController.java | 4 +- .../src/main/java/io/sentry/SentryClient.java | 39 ++- .../java/io/sentry/SentryEnvelopeItem.java | 10 +- .../test/java/io/sentry/SentryClientTest.kt | 5 +- 12 files changed, 489 insertions(+), 402 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index aaa0223aa7..e21aaff902 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -10,6 +10,7 @@ import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId @@ -21,7 +22,6 @@ import java.io.StringReader import java.util.Date import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.ceil /** * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the @@ -82,7 +82,7 @@ public class ReplayCache internal constructor( * @param frameTimestamp the timestamp when the frame screenshot was taken */ internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { - if (replayCacheDir == null) { + if (replayCacheDir == null || bitmap.isRecycled) { return } @@ -139,6 +139,9 @@ public class ReplayCache internal constructor( width: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { + if (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } if (frames.isEmpty()) { options.logger.log( DEBUG, @@ -371,17 +374,17 @@ public class ReplayCache internal constructor( } cache.frames.sortBy { it.timestamp } - - fun roundToNearestFrame(duration: Long, frameDuration: Int): Long { - val frames = duration.toDouble() / frameDuration.toDouble() - return ceil(frames).toLong() * frameDuration + // TODO: this should be removed when we start sending buffered segments on next launch + val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val normalizedTimestamp = if (replayType == SESSION) { + segmentTimestamp + } else { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache.frames.first().timestamp) } - // we need to round to the nearest frame to include breadcrumbs/events happened after the frame was captured - val duration = roundToNearestFrame( - duration = (cache.frames.last().timestamp - segmentTimestamp.time), - frameDuration = 1000 / frameRate - ) - 1 // we need to subtract 1ms to avoid capturing the next frame which doesn't exist + // add one frame to include breadcrumbs/events happened after the frame was captured + val duration = cache.frames.last().timestamp - normalizedTimestamp.time + (1000 / frameRate) val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { val reader = StringReader(it) @@ -396,8 +399,8 @@ public class ReplayCache internal constructor( return LastSegmentData( recorderConfig = recorderConfig, cache = cache, - timestamp = segmentTimestamp, - id = segmentId, + timestamp = normalizedTimestamp, + id = normalizedSegmentId, duration = duration, replayType = replayType, screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index df461a9369..1a864fef45 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -6,30 +6,38 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import android.view.MotionEvent -import io.sentry.Hint +import io.sentry.Breadcrumb 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.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME +import io.sentry.hints.Backfillable import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import io.sentry.util.HintUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable import java.io.File import java.security.SecureRandom +import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean public class ReplayIntegration( @@ -112,6 +120,8 @@ public class ReplayIntegration( addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) + + finalizePreviousReplay() } override fun isRecording() = isRecording.get() @@ -156,30 +166,17 @@ public class ReplayIntegration( 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?) { + override fun captureReplay(isTerminating: Boolean?) { if (!isEnabled.get() || !isRecording.get()) { return } if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId)) { - options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) + options.logger.log(DEBUG, "Replay id is not set, not capturing for event") return } - captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { + captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 }) captureStrategy = captureStrategy?.convert() @@ -259,4 +256,67 @@ public class ReplayIntegration( override fun onTouchEvent(event: MotionEvent) { captureStrategy?.onTouchEvent(event) } + + private fun cleanupReplays(unfinishedReplayId: String = "") { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles { dir, name -> + if (name.startsWith("replay_") && + !name.contains(replayId.toString()) && + !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) + ) { + FileUtils.deleteRecursively(File(dir, name)) + } + false + } + } + } + + private fun finalizePreviousReplay() { + // TODO: read persisted options/scope values form the + // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor + + options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { + val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + cleanupReplays() + return@submitSafely + } + val previousReplayId = SentryId(previousReplayIdString) + if (previousReplayId == SentryId.EMPTY_ID) { + cleanupReplays() + return@submitSafely + } + val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: run { + cleanupReplays() + return@submitSafely + } + val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + val segment = CaptureStrategy.createSegment( + hub = hub, + options = options, + duration = lastSegment.duration, + currentSegmentTimestamp = lastSegment.timestamp, + replayId = previousReplayId, + segmentId = lastSegment.id, + height = lastSegment.recorderConfig.recordingHeight, + width = lastSegment.recorderConfig.recordingWidth, + frameRate = lastSegment.recorderConfig.frameRate, + cache = lastSegment.cache, + replayType = lastSegment.replayType, + screenAtStart = lastSegment.screenAtStart, + breadcrumbs = breadcrumbs, + events = LinkedList(lastSegment.events) + ) + + if (segment is ReplaySegment.Created) { + val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) + segment.capture(hub, hint) + } + cleanupReplays(unfinishedReplayId = previousReplayIdString) // will be cleaned up after the envelope is assembled + } + } + + private class PreviousReplayHint : Backfillable { + override fun shouldEnrich(): Boolean = false + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6da7e9991f..ed6c47bfda 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -3,11 +3,8 @@ package io.sentry.android.replay.capture import android.view.MotionEvent import io.sentry.Breadcrumb 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.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION @@ -23,30 +20,27 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment +import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.PersistableLinkedList import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely -import io.sentry.cache.PersistingScopeObserver -import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME -import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.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.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.properties.ReadWriteProperty @@ -72,6 +66,7 @@ internal abstract class BaseCaptureStrategy( Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) } + protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> if (newValue == null) { @@ -99,7 +94,6 @@ internal abstract class BaseCaptureStrategy( persistingExecutor, cacheProvider = { cache } ) - private val currentEventsLock = Any() private val currentPositions = LinkedHashMap>(10) private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -108,27 +102,14 @@ internal abstract class BaseCaptureStrategy( executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - override fun start(recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - if (cleanupOldReplays) { - replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { - val unfinishedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: "" - // clean up old replays - options.cacheDirPath?.let { cacheDir -> - File(cacheDir).listFiles { dir, name -> - if (name.startsWith("replay_") && - !name.contains(replayId.toString()) && - !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) - ) { - FileUtils.deleteRecursively(File(dir, name)) - } - false - } - } - } - } - + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + // TODO: this should be persisted even after conversion replayType = if (this is SessionCaptureStrategy) SESSION else BUFFER this.recorderConfig = recorderConfig currentSegment = segmentId @@ -136,8 +117,6 @@ internal abstract class BaseCaptureStrategy( segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) - - finalizePreviousReplay() } override fun resume() { @@ -154,7 +133,7 @@ internal abstract class BaseCaptureStrategy( currentReplayId = SentryId.EMPTY_ID } - protected fun createSegment( + protected fun createSegmentInternal( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, @@ -167,133 +146,23 @@ internal abstract class BaseCaptureStrategy( screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: LinkedList = this.currentEvents - ): ReplaySegment { - val generatedVideo = cache?.createVideoOf( + ): ReplaySegment = + createSegment( + hub, + options, duration, - currentSegmentTimestamp.time, - segmentId, - height, - width - ) ?: return ReplaySegment.Failed - - val (video, frameCount, videoDuration) = generatedVideo - - val replayBreadcrumbs: List = if (breadcrumbs == null) { - var crumbs = emptyList() - hub?.configureScope { scope -> - crumbs = ArrayList(scope.breadcrumbs) - } - crumbs - } else { - breadcrumbs - } - - return buildReplay( - video, - replayId, currentSegmentTimestamp, + replayId, segmentId, height, width, - frameCount, - frameRate, - videoDuration, replayType, + cache, + frameRate, screenAtStart, - replayBreadcrumbs, + breadcrumbs, events ) - } - - private fun buildReplay( - video: File, - currentReplayId: SentryId, - segmentTimestamp: Date, - segmentId: Int, - height: Int, - width: Int, - frameCount: Int, - frameRate: Int, - duration: Long, - replayType: ReplayType, - screenAtStart: String?, - breadcrumbs: List, - events: LinkedList - ): 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() - this.frameRate = frameRate - this.height = height - this.width = width - // TODO: support non-fullscreen windows later - left = 0 - top = 0 - } - - val urls = LinkedList() - 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 != null && urls.firstOrNull() != screenAtStart) { - urls.addFirst(screenAtStart) - } - - rotateEvents(events, 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 @@ -312,56 +181,6 @@ internal abstract class BaseCaptureStrategy( replayExecutor.gracefullyShutdown(options) } - protected fun rotateEvents( - events: LinkedList, - until: Long, - callback: ((RRWebEvent) -> Unit)? = null - ) { - synchronized(currentEventsLock) { - var event = events.peek() - while (event != null && event.timestamp < until) { - callback?.invoke(event) - events.remove() - event = events.peek() - } - } - } - - private fun finalizePreviousReplay() { - // TODO: run it on options.executorService and read persisted options/scope values form the - // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor - - replayExecutor.submitSafely(options, "$TAG.finalize_previous_replay") { - val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: return@submitSafely - val previousReplayId = SentryId(previousReplayIdString) - if (previousReplayId == SentryId.EMPTY_ID) { - return@submitSafely - } - val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List - - val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: return@submitSafely - val segment = createSegment( - duration = lastSegment.duration, - currentSegmentTimestamp = lastSegment.timestamp, - replayId = previousReplayId, - segmentId = lastSegment.id, - height = lastSegment.recorderConfig.recordingHeight, - width = lastSegment.recorderConfig.recordingWidth, - frameRate = lastSegment.recorderConfig.frameRate, - cache = lastSegment.cache, - replayType = lastSegment.replayType, - screenAtStart = lastSegment.screenAtStart, - breadcrumbs = breadcrumbs, - events = LinkedList(lastSegment.events) - ) - - if (segment is ReplaySegment.Created) { - segment.capture(hub) - } - FileUtils.deleteRecursively(lastSegment.cache.replayCacheDir) - } - } - private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -380,28 +199,6 @@ internal abstract class BaseCaptureStrategy( } } - 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) { @@ -528,20 +325,24 @@ internal abstract class BaseCaptureStrategy( crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> propertyName ?: error("Can't persist value without a property name") - if (options.mainThreadChecker.isMainThread) { - persistingExecutor.submit { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } - } else { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } + cache?.persistSegmentValues(propertyName, newValue.toString()) } ): ReadWriteProperty = object : ReadWriteProperty { private val value = AtomicReference(initialValue) + private fun runInBackground(task: () -> Unit) { + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submitSafely(options, "$TAG.runInBackground") { + task() + } + } else { + task() + } + } + init { - onChange(propertyName, initialValue, initialValue) + runInBackground { onChange(propertyName, initialValue, initialValue) } } override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() @@ -549,7 +350,7 @@ internal abstract class BaseCaptureStrategy( override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { val oldValue = this.value.getAndSet(value) if (oldValue != value) { - onChange(propertyName, oldValue, value) + runInBackground { onChange(propertyName, oldValue, value) } } } } @@ -560,13 +361,7 @@ internal abstract class BaseCaptureStrategy( crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> propertyName ?: error("Can't persist value without a property name") - if (options.mainThreadChecker.isMainThread) { - persistingExecutor.submit { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } - } else { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } + cache?.persistSegmentValues(propertyName, newValue.toString()) } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 264242ac13..b836b31553 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -3,14 +3,16 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub +import io.sentry.SentryLevel.DEBUG 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.capture.CaptureStrategy.Companion.rotateEvents +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId @@ -27,7 +29,10 @@ internal class BufferCaptureStrategy( replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null ) : BaseCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) { + // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() + + // TODO: rework this bs, it doesn't work with sending replay on restart private val bufferedScreensLock = Any() private val bufferedScreens = mutableListOf>() @@ -38,13 +43,12 @@ internal class BufferCaptureStrategy( override fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, - replayId: SentryId, - cleanupOldReplays: Boolean + replayId: SentryId ) { - super.start(recorderConfig, segmentId, replayId, cleanupOldReplays) + super.start(recorderConfig, segmentId, replayId) hub?.configureScope { - val screen = it.screen + val screen = it.screen?.substringAfterLast('.') if (screen != null) { synchronized(bufferedScreensLock) { bufferedScreens.add(screen to dateProvider.currentTimeMillis) @@ -62,6 +66,17 @@ internal class BufferCaptureStrategy( } } + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment++ + } + } + super.pause() + } + override fun stop() { val replayCacheDir = cache?.replayCacheDir replayExecutor.submitSafely(options, "$TAG.stop") { @@ -70,16 +85,14 @@ internal class BufferCaptureStrategy( super.stop() } - override fun sendReplayForEvent( - isCrashed: Boolean, - eventId: String?, - hint: Hint?, + override fun captureReplay( + isTerminating: Boolean, 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) + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event") return } @@ -89,45 +102,23 @@ internal class BufferCaptureStrategy( it.replayId = currentReplayId } - 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) + if (isTerminating) { + this.isTerminating.set(true) + // avoid capturing replay, because the video will be malformed + options.logger.log(DEBUG, "Not capturing replay for crashed event, will be captured on next launch") + return } - val segmentId = currentSegment - val replayId = currentReplayId - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - findAndSetStartScreen(currentSegmentTimestamp.time) + createCurrentSegment("capture_replay") { segment -> + bufferedSegments.capture() - 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()) + segment.capture(hub) // 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 + // TODO: also pass new segmentTimestamp to the new strategy onSegmentSent() } } @@ -143,25 +134,7 @@ internal class BufferCaptureStrategy( 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-- - 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) - } - } + bufferedSegments.rotate(bufferLimit) } } @@ -179,22 +152,7 @@ internal class BufferCaptureStrategy( } 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 - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId - 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) + createCurrentSegment("configuration_changed") { segment -> if (segment is ReplaySegment.Created) { bufferedSegments += segment @@ -205,9 +163,13 @@ internal class BufferCaptureStrategy( } override fun convert(): CaptureStrategy { + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not converting to session mode, because the process is about to terminate") + return this + } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) - captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, cleanupOldReplays = false) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId) return captureStrategy } @@ -228,7 +190,61 @@ internal class BufferCaptureStrategy( screenAtStart = startScreen } // can clear as we switch to session mode and don't care anymore about buffering - bufferedSegments.clear() + bufferedScreens.clear() + } + } + + private fun MutableList.capture() { + var bufferedSegment = removeFirstOrNull() + while (bufferedSegment != null) { + bufferedSegment.capture(hub) + bufferedSegment = removeFirstOrNull() + Thread.sleep(100L) + } + } + + private fun MutableList.rotate(bufferLimit: Long) { + var removed = false + 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-- + deleteFile(it.replay.videoFile) + removed = true + return@removeAll true + } + return@removeAll false + } + if (removed) { + // shift segmentIds after rotating buffered segments + forEachIndexed { index, segment -> + segment.setSegmentId(index) + } + } + } + + private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + 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 + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId + val height = this.recorderConfig.recordingHeight + val width = this.recorderConfig.recordingWidth + + findAndSetStartScreen(currentSegmentTimestamp.time) + + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + onSegmentCreated(segment) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 949bc86777..712e632abd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -2,18 +2,35 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.Breadcrumb +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.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent import java.io.File +import java.util.Date +import java.util.LinkedList internal interface CaptureStrategy { var currentSegment: Int var currentReplayId: SentryId val replayCacheDir: File? - fun start(recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int = 0, + replayId: SentryId = SentryId() + ) fun stop() @@ -21,7 +38,7 @@ internal interface CaptureStrategy { fun resume() - fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) + fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) fun onScreenshotRecorded(bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit) @@ -34,4 +51,190 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy fun close() + + companion object { + internal val currentEventsLock = Any() + + fun createSegment( + hub: IHub?, + options: SentryOptions, + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType, + cache: ReplayCache?, + frameRate: Int, + screenAtStart: String?, + breadcrumbs: List?, + events: LinkedList + ): ReplaySegment { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return ReplaySegment.Failed + + val (video, frameCount, videoDuration) = generatedVideo + + val replayBreadcrumbs: List = if (breadcrumbs == null) { + var crumbs = emptyList() + hub?.configureScope { scope -> + crumbs = ArrayList(scope.breadcrumbs) + } + crumbs + } else { + breadcrumbs + } + + return buildReplay( + options, + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + frameRate, + videoDuration, + replayType, + screenAtStart, + replayBreadcrumbs, + events + ) + } + + private fun buildReplay( + options: SentryOptions, + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + frameRate: Int, + videoDuration: Long, + replayType: ReplayType, + screenAtStart: String?, + breadcrumbs: List, + events: LinkedList + ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) + 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 = videoDuration + this.frameCount = frameCount + size = video.length() + this.frameRate = frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + left = 0 + top = 0 + } + + val urls = LinkedList() + 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 != null && urls.firstOrNull() != screenAtStart) { + urls.addFirst(screenAtStart) + } + + rotateEvents(events, 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 = videoDuration, + replay = replay, + recording = recording + ) + } + + internal fun rotateEvents( + events: LinkedList, + until: Long, + callback: ((RRWebEvent) -> Unit)? = null + ) { + synchronized(currentEventsLock) { + var event = events.peek() + while (event != null && event.timestamp < until) { + callback?.invoke(event) + events.remove() + event = events.peek() + } + } + } + } + + 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 + } + } + } + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 2d9e308551..d0fd2ce1e1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -2,7 +2,6 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IHub import io.sentry.SentryLevel.DEBUG @@ -10,6 +9,7 @@ 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.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider @@ -31,10 +31,9 @@ internal class SessionCaptureStrategy( override fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, - replayId: SentryId, - cleanupOldReplays: Boolean + replayId: SentryId ) { - super.start(recorderConfig, segmentId, replayId, cleanupOldReplays) + super.start(recorderConfig, segmentId, replayId) // 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 { @@ -66,17 +65,9 @@ internal class SessionCaptureStrategy( 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 captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event") + this.isTerminating.set(isTerminating) } override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { @@ -99,10 +90,15 @@ internal class SessionCaptureStrategy( return@submitSafely } + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not capturing segment, because the app is terminating, will be captured on next launch") + return@submitSafely + } + val now = dateProvider.currentTimeMillis if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { val segment = - createSegment( + createSegmentInternal( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, currentReplayId, @@ -153,7 +149,7 @@ internal class SessionCaptureStrategy( val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal(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 index 093416f9bb..453ff49df2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.util +import io.sentry.ISentryExecutorService import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import java.util.concurrent.ExecutorService @@ -25,6 +26,25 @@ internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { } } +internal fun ISentryExecutorService.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 ExecutorService.submitSafely( options: SentryOptions, taskName: String, diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index d365f650ea..e868038db2 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -32,11 +32,7 @@ public boolean isRecording() { } @Override - public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} - - @Override - public void sendReplay( - @Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint) {} + public void captureReplay(@Nullable Boolean isTerminating) {} @Override public @NotNull SentryId getReplayId() { diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index caaa847423..01c0f9da12 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -17,9 +17,7 @@ public interface ReplayController { boolean isRecording(); - void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); - - void sendReplay(@Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint); + void captureReplay(@Nullable Boolean isTerminating); @NotNull SentryId getReplayId(); diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 988cc14d7d..6868894340 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -199,13 +199,16 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } - if (event != null) { - options.getReplayController().sendReplayForEvent(event, hint); + final boolean isBackfillable = HintUtils.hasType(hint, Backfillable.class); + // if event is backfillable we don't wanna trigger capture replay, because it's an event from + // the past + if (event != null && !isBackfillable && (event.isErrored() || event.isCrashed())) { + options.getReplayController().captureReplay(event.isCrashed()); } try { @Nullable TraceContext traceContext = null; - if (HintUtils.hasType(hint, Backfillable.class)) { + if (isBackfillable) { // for backfillable hint we synthesize Baggage from event values if (event != null) { final Baggage baggage = Baggage.fromEvent(event, options); @@ -239,12 +242,9 @@ 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. 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 + // any running transaction / profiling data. if (scope != null) { finalizeTransaction(scope, hint); - finalizeReplay(scope, hint); } return sentryId; @@ -265,18 +265,6 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin } } - 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) { @@ -318,7 +306,9 @@ private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hin } } - final SentryEnvelope envelope = buildEnvelope(event, hint.getReplayRecording(), traceContext); + final boolean cleanupReplayFolder = HintUtils.hasType(hint, Backfillable.class); + final SentryEnvelope envelope = + buildEnvelope(event, hint.getReplayRecording(), traceContext, cleanupReplayFolder); hint.clear(); transport.send(envelope, hint); @@ -628,12 +618,17 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { private @NotNull SentryEnvelope buildEnvelope( final @NotNull SentryReplayEvent event, final @Nullable ReplayRecording replayRecording, - final @Nullable TraceContext traceContext) { + final @Nullable TraceContext traceContext, + final boolean cleanupReplayFolder) { final List envelopeItems = new ArrayList<>(); final SentryEnvelopeItem replayItem = SentryEnvelopeItem.fromReplay( - options.getSerializer(), options.getLogger(), event, replayRecording); + options.getSerializer(), + options.getLogger(), + event, + replayRecording, + cleanupReplayFolder); envelopeItems.add(replayItem); final SentryId sentryId = event.getEventId(); diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 856976b589..7862c8d664 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -8,6 +8,7 @@ import io.sentry.exception.SentryEnvelopeException; import io.sentry.metrics.EncodedMetrics; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; import io.sentry.vendor.Base64; @@ -372,7 +373,8 @@ public static SentryEnvelopeItem fromReplay( final @NotNull ISerializer serializer, final @NotNull ILogger logger, final @NotNull SentryReplayEvent replayEvent, - final @Nullable ReplayRecording replayRecording) { + final @Nullable ReplayRecording replayRecording, + final boolean cleanupReplayFolder) { final File replayVideo = replayEvent.getVideoFile(); @@ -415,7 +417,11 @@ public static SentryEnvelopeItem fromReplay( return null; } finally { if (replayVideo != null) { - replayVideo.delete(); + if (cleanupReplayFolder) { + FileUtils.deleteRecursively(replayVideo.getParentFile()); + } else { + replayVideo.delete(); + } } } }); diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index c32bb7b9e9..4db7910d0a 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -2752,11 +2752,10 @@ class SentryClientTest { } @Test - fun `calls sendReplayForEvent on replay controller for error events`() { + fun `calls captureReplay 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) + override fun captureReplay(isTerminating: Boolean?) { called = true } }) From b3033805294c4b04897fc4498adf5d6217abfd37 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 27 Jul 2024 00:27:30 +0200 Subject: [PATCH 174/184] Fix breadcrumb http timestamps type --- .../replay/DefaultReplayBreadcrumbConverter.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 index 504c4adf21..7b4a933c0e 100644 --- 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 @@ -131,14 +131,23 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { val breadcrumb = this + val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] + val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] 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 + // can be double if it was serialized to disk + startTimestamp = if (httpStartTimestamp is Double) { + httpStartTimestamp / 1000.0 + } else { + (httpStartTimestamp as Long) / 1000.0 + } + endTimestamp = if (httpEndTimestamp is Double) { + httpEndTimestamp / 1000.0 + } else { + (httpEndTimestamp as Long) / 1000.0 + } val breadcrumbData = mutableMapOf() for ((key, value) in breadcrumb.data) { From dffa4a9197298bd4d92d7830b29bf2116fd39655 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 27 Jul 2024 00:29:15 +0200 Subject: [PATCH 175/184] Always do partial updates to the video --- .../java/io/sentry/android/replay/video/SimpleVideoEncoder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 54a3bc1f89..fd770131d8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -120,7 +120,7 @@ internal class SimpleVideoEncoder( ) 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.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, -1) // use -1 to force always non-key frames, meaning only partial updates to save the video size format } From 8d86dab59af06e447dab0ae73031cd5e210c52e8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 29 Jul 2024 13:17:58 +0200 Subject: [PATCH 176/184] Make buffer mode work for ANRs --- .../android/core/AnrV2EventProcessor.java | 69 ++++++++++++++++++- .../main/java/io/sentry/IOptionsObserver.java | 2 + sentry/src/main/java/io/sentry/Sentry.java | 2 + .../cache/PersistingOptionsObserver.java | 10 +++ .../sentry/cache/PersistingScopeObserver.java | 9 ++- 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 1b386c38e0..52f7d266de 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -4,6 +4,7 @@ import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; @@ -48,11 +49,14 @@ import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.Request; import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryStackTrace; import io.sentry.protocol.SentryThread; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; +import java.io.File; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -80,13 +84,24 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable SecureRandom random; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { + this(context, options, buildInfoProvider, null); + } + + AnrV2EventProcessor( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable SecureRandom random) { this.context = context; this.options = options; this.buildInfoProvider = buildInfoProvider; + this.random = random; final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -156,14 +171,64 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setReplayId(event); } + private boolean sample(final @NotNull Double sampleRate, final @NotNull SecureRandom random) { + return !(sampleRate < random.nextDouble()); + } + private void setReplayId(final @NotNull SentryEvent event) { - final String persistedReplayId = - PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + final @Nullable String replayErrorSampleRate = + PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + + if (replayErrorSampleRate == null) { + return; + } + + try { + // we have to sample here with the old sample rate, because it may change between app launches + final SecureRandom random = this.random != null ? this.random : new SecureRandom(); + final Double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); + if (!sample(replayErrorSampleRateDouble, random)) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Not capturing replay for ANR %s due to not being sampled.", + event.getEventId()); + return; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); + return; + } + + String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + final File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); + if (persistedReplayId == null + || persistedReplayId.equals(SentryId.EMPTY_ID.toString()) + || !replayFolder.exists()) { + // if the replay folder does not exist (e.g. running in buffer mode), we need to find the + // latest replay folder that was modified before the ANR event. + long lastModified = Long.MIN_VALUE; + final File[] dirs = new File(options.getCacheDirPath()).listFiles(); + if (dirs != null) { + for (File dir : dirs) { + if (dir.isDirectory() && dir.getName().startsWith("replay_")) { + if (dir.lastModified() > lastModified + && dir.lastModified() <= event.getTimestamp().getTime()) { + lastModified = dir.lastModified(); + persistedReplayId = dir.getName().substring("replay_".length()); + } + } + } + } + } if (persistedReplayId == null) { return; } + // store the relevant replayId so ReplayIntegration can pick it up and finalize that replay + PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME); event.getContexts().put(REPLAY_ID, persistedReplayId); } diff --git a/sentry/src/main/java/io/sentry/IOptionsObserver.java b/sentry/src/main/java/io/sentry/IOptionsObserver.java index 54cacc666a..5a2ddcc9b5 100644 --- a/sentry/src/main/java/io/sentry/IOptionsObserver.java +++ b/sentry/src/main/java/io/sentry/IOptionsObserver.java @@ -22,4 +22,6 @@ public interface IOptionsObserver { void setDist(@Nullable String dist); void setTags(@NotNull Map tags); + + void setReplayErrorSampleRate(@Nullable Double replayErrorSampleRate); } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f157256ce5..13cf9ab389 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -356,6 +356,8 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setDist(options.getDist()); observer.setEnvironment(options.getEnvironment()); observer.setTags(options.getTags()); + observer.setReplayErrorSampleRate( + options.getExperimental().getSessionReplay().getErrorSampleRate()); } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java index bb1bb71572..49ec2da904 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java @@ -16,6 +16,7 @@ public final class PersistingOptionsObserver implements IOptionsObserver { public static final String ENVIRONMENT_FILENAME = "environment.json"; public static final String DIST_FILENAME = "dist.json"; public static final String TAGS_FILENAME = "tags.json"; + public static final String REPLAY_ERROR_SAMPLE_RATE_FILENAME = "replay-error-sample-rate.json"; private final @NotNull SentryOptions options; @@ -73,6 +74,15 @@ public void setTags(@NotNull Map tags) { store(tags, TAGS_FILENAME); } + @Override + public void setReplayErrorSampleRate(@Nullable Double replayErrorSampleRate) { + if (replayErrorSampleRate == null) { + delete(REPLAY_ERROR_SAMPLE_RATE_FILENAME); + } else { + store(replayErrorSampleRate.toString(), REPLAY_ERROR_SAMPLE_RATE_FILENAME); + } + } + private void store(final @NotNull T entity, final @NotNull String fileName) { CacheUtils.store(options, entity, OPTIONS_CACHE, fileName); } diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 73cac15aa4..7c186cf99d 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -150,13 +150,20 @@ private void serializeToDisk(final @NotNull Runnable task) { } private void store(final @NotNull T entity, final @NotNull String fileName) { - CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + store(options, entity, fileName); } private void delete(final @NotNull String fileName) { CacheUtils.delete(options, SCOPE_CACHE, fileName); } + public static void store( + final @NotNull SentryOptions options, + final @NotNull T entity, + final @NotNull String fileName) { + CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + } + public static @Nullable T read( final @NotNull SentryOptions options, final @NotNull String fileName, From 2267b1d2b91f2bc4309f03fb74a72f4cc50ceed0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 29 Jul 2024 17:48:24 +0200 Subject: [PATCH 177/184] Add tests --- .../android/core/AnrV2EventProcessor.java | 29 +-- .../android/core/AnrV2EventProcessorTest.kt | 78 ++++++- .../api/sentry-android-replay.api | 3 +- .../DefaultReplayBreadcrumbConverter.kt | 2 +- .../DefaultReplayBreadcrumbConverterTest.kt | 22 ++ .../sentry/android/replay/ReplayCacheTest.kt | 77 ++++++- .../android/replay/ReplayIntegrationTest.kt | 215 +++++++++++++++--- .../sentry/android/replay/ReplaySmokeTest.kt | 13 +- .../capture/SessionCaptureStrategyTest.kt | 151 ++---------- .../src/main/AndroidManifest.xml | 2 +- sentry/api/sentry.api | 12 +- .../test/java/io/sentry/SentryClientTest.kt | 107 ++++++--- .../java/io/sentry/SentryEnvelopeItemTest.kt | 29 ++- sentry/src/test/java/io/sentry/SentryTest.kt | 8 + .../cache/PersistingOptionsObserverTest.kt | 36 ++- 15 files changed, 527 insertions(+), 257 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 52f7d266de..48b0950767 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -49,7 +49,6 @@ import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.Request; import io.sentry.protocol.SdkVersion; -import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryStackTrace; import io.sentry.protocol.SentryThread; import io.sentry.protocol.SentryTransaction; @@ -171,43 +170,45 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setReplayId(event); } - private boolean sample(final @NotNull Double sampleRate, final @NotNull SecureRandom random) { - return !(sampleRate < random.nextDouble()); - } - - private void setReplayId(final @NotNull SentryEvent event) { + private boolean sampleReplay(final @NotNull SentryEvent event) { final @Nullable String replayErrorSampleRate = PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); if (replayErrorSampleRate == null) { - return; + return false; } try { // we have to sample here with the old sample rate, because it may change between app launches final SecureRandom random = this.random != null ? this.random : new SecureRandom(); - final Double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); - if (!sample(replayErrorSampleRateDouble, random)) { + final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); + if (replayErrorSampleRateDouble < random.nextDouble()) { options .getLogger() .log( SentryLevel.DEBUG, "Not capturing replay for ANR %s due to not being sampled.", event.getEventId()); - return; + return false; } } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); - return; + return false; } + return true; + } + + private void setReplayId(final @NotNull SentryEvent event) { String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); final File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); - if (persistedReplayId == null - || persistedReplayId.equals(SentryId.EMPTY_ID.toString()) - || !replayFolder.exists()) { + if (!replayFolder.exists()) { + if (!sampleReplay(event)) { + return; + } // if the replay folder does not exist (e.g. running in buffer mode), we need to find the // latest replay folder that was modified before the ANR event. + persistedReplayId = null; long lastModified = Long.MIN_VALUE; final File[] dirs = new File(options.getCacheDirPath()).listFiles(); if (dirs != null) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 2c381c6285..80ae946711 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -15,13 +15,14 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext -import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME @@ -77,7 +78,9 @@ class AnrV2EventProcessorTest { val tmpDir = TemporaryFolder() class Fixture { - + companion object { + const val REPLAY_ID = "64cf554cc8d74c6eafa3e08b7c984f6d" + } val buildInfo = mock() lateinit var context: Context val options = SentryAndroidOptions().apply { @@ -89,7 +92,8 @@ class AnrV2EventProcessorTest { dir: TemporaryFolder, currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, - populateOptionsCache: Boolean = false + populateOptionsCache: Boolean = false, + replayErrorSampleRate: Double? = null ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" @@ -120,7 +124,7 @@ class AnrV2EventProcessorTest { REQUEST_FILENAME, Request().apply { url = "google.com"; method = "GET" } ) - persistScope(REPLAY_FILENAME, SentryId("64cf554cc8d74c6eafa3e08b7c984f6d")) + persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID)) } if (populateOptionsCache) { @@ -129,7 +133,10 @@ class AnrV2EventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + replayErrorSampleRate?.let { + persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) + } } return AnrV2EventProcessor(context, options, buildInfo) @@ -295,8 +302,6 @@ class AnrV2EventProcessorTest { // contexts assertEquals(1024, processed.contexts.response!!.bodySize) assertEquals("Google Chrome", processed.contexts.browser!!.name) - // replay_id - assertEquals("64cf554cc8d74c6eafa3e08b7c984f6d", processed.contexts[Contexts.REPLAY_ID].toString()) } @Test @@ -549,6 +554,65 @@ class AnrV2EventProcessorTest { assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints) } + @Test + fun `sets replayId when replay folder exists`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayFolder = File(fixture.options.cacheDirPath, "replay_${Fixture.REPLAY_ID}").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(Fixture.REPLAY_ID, processed.contexts[Contexts.REPLAY_ID].toString()) + } + + @Test + fun `does not set replayId when replay folder does not exist and no sample rate persisted`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `does not set replayId when replay folder does not exist and not sampled`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 0.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `set replayId of the last modified folder`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 1.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + replayFolder1.setLastModified(1000) + replayFolder2.setLastModified(500) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + } + private fun processEvent( hint: Hint, populateScopeCache: Boolean = false, diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 86800491b4..2a81b45b82 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -52,6 +52,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; @@ -65,8 +66,6 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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 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 index 7b4a933c0e..c95b72088a 100644 --- 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 @@ -132,7 +132,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { val breadcrumb = this val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] - val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] + val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] return RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time op = "resource.http" 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 index 0dfb3d39c8..a659f7f596 100644 --- 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 @@ -61,6 +61,28 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(400, rrwebEvent.data!!["requestBodySize"]) } + @Test + fun `convert RRWebSpanEvent works with floating timestamps`() { + 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] = 1234.0 + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234.0 + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals(1.234, rrwebEvent.startTimestamp) + assertEquals(2.234, rrwebEvent.endTimestamp) + } + @Test fun `returns null if not eligible for RRWebSpanEvent`() { val converter = fixture.getSut() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 2b2fc18adf..0dae78e723 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -234,6 +234,20 @@ class ReplayCacheTest { assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } + @Test + fun `does not add frame when bitmap is recycled`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } + replayCache.addFrame(bitmap, 1) + + assertTrue(replayCache.frames.isEmpty()) + } + @Test fun `addFrame with File path works`() { val replayCache = fixture.getSut( @@ -247,10 +261,7 @@ class ReplayCacheTest { 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() - } + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } replayCache.addFrame(screenshot, frameTimestamp = 1) val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) @@ -408,7 +419,7 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) assertEquals(ReplayType.SESSION, lastSegment.replayType) - assertEquals(2999, lastSegment.duration) + assertEquals(3543, lastSegment.duration) // duration + 1 frame duration assertTrue { val firstEvent = lastSegment.events.first() as RRWebInteractionEvent firstEvent.timestamp == 1720693523997 && @@ -458,4 +469,60 @@ class ReplayCacheTest { assertEquals(1, lastSegment.cache.frames.first().timestamp) assertEquals("1.jpg", lastSegment.cache.frames.first().screenshot.name) } + + @Test + fun `when videoFile exists and is not empty, deletes it before writing`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 3 + ) + + val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { + it.createNewFile() + it.writeBytes(byteArrayOf(1, 2, 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, oldVideoFile) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `sets segmentId to 0 for buffer mode`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=2 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(0, lastSegment.id) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 93239f81eb..a98e344277 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1,25 +1,49 @@ 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 androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions -import io.sentry.android.replay.ReplayCacheTest.Fixture +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.cache.PersistingScopeObserver import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -27,6 +51,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -40,9 +65,26 @@ class ReplayIntegrationTest { val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + executorService = mock { + doAnswer { + (it.arguments[0] as Runnable).run() + }.whenever(mock).submit(any()) + } + } val hub = mock() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + fun getSut( context: Context, sessionSampleRate: Double = 1.0, @@ -61,7 +103,7 @@ class ReplayIntegrationTest { dateProvider, recorderProvider, recorderConfigProvider = recorderConfigProvider, - replayCacheProvider = null, + replayCacheProvider = { _, _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider ) } @@ -118,7 +160,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start(any(), any(), any(), any()) + verify(captureStrategy, never()).start(any(), any(), any()) } @Test @@ -141,7 +183,11 @@ class ReplayIntegrationTest { replay.start() replay.start() - verify(captureStrategy, times(1)).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -152,7 +198,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, never()).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, never()).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -163,7 +213,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, times(1)).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -203,7 +257,7 @@ class ReplayIntegrationTest { } @Test - fun `sendReplayForEvent does nothing when not recording`() { + fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -212,27 +266,13 @@ class ReplayIntegrationTest { val event = SentryEvent().apply { exceptions = listOf(SentryException()) } - replay.sendReplayForEvent(event, Hint()) + replay.captureReplay(event.isCrashed) - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + verify(captureStrategy, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent does nothing for non errored events`() { - val captureStrategy = mock() - val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) - - replay.register(fixture.hub, fixture.options) - replay.start() - - val event = SentryEvent() - replay.sendReplayForEvent(event, Hint()) - - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) - } - - @Test - fun `sendReplayForEvent does nothing when currentReplayId is not set`() { + fun `captureReplay does nothing when currentReplayId is not set`() { val captureStrategy = mock { whenever(mock.currentReplayId).thenReturn(SentryId.EMPTY_ID) } @@ -244,13 +284,13 @@ class ReplayIntegrationTest { val event = SentryEvent().apply { exceptions = listOf(SentryException()) } - replay.sendReplayForEvent(event, Hint()) + replay.captureReplay(event.isCrashed) - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + verify(captureStrategy, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent calls and converts strategy`() { + fun `captureReplay calls and converts strategy`() { val captureStrategy = mock { whenever(mock.currentReplayId).thenReturn(SentryId()) } @@ -265,9 +305,9 @@ class ReplayIntegrationTest { } event.eventId = id val hint = Hint() - replay.sendReplayForEvent(event, hint) + replay.captureReplay(event.isCrashed) - verify(captureStrategy).sendReplayForEvent(eq(false), eq(id.toString()), eq(hint), any()) + verify(captureStrategy).captureReplay(eq(false), any()) verify(captureStrategy).convert() } @@ -376,4 +416,117 @@ class ReplayIntegrationTest { verify(recorder, times(2)).start(eq(recorderConfig)) assertTrue(configChanged) } + + @Test + fun `register finalizes previous replay`() { + val oldReplayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val oldReplay = + File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } + val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).also { + it.createNewFile() + it.writeText("\"$oldReplayId\"") + } + val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + fixture.options.serializer.serialize( + listOf( + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + } + ), + breadcrumbsFile.writer() + ) + File(oldReplay, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=1 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + + assertTrue(oldReplay.exists()) // should not be deleted until the video is packed into envelope + verify(fixture.hub).captureReplay( + check { + assertEquals(oldReplayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(1, videoEvents?.first()?.segmentId) + + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + + val interactionEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals( + InteractionType.TouchStart, + interactionEvents?.first()?.interactionType + ) + assertEquals(314.29794f, interactionEvents?.first()?.x) + assertEquals(625.4414f, interactionEvents?.first()?.y) + + assertEquals(InteractionType.TouchEnd, interactionEvents?.last()?.interactionType) + assertEquals(322.0039f, interactionEvents?.last()?.x) + assertEquals(424.43848f, interactionEvents?.last()?.y) + } + ) + } + + @Test + fun `register cleans up old replays`() { + val replayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val evenOlderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + + val captureStrategy = mock { + on { currentReplayId }.thenReturn(replayId) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.hub, fixture.options) + + assertTrue(scopeCache.exists()) + assertFalse(evenOlderReplay.exists()) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 22f35b157b..53ef7c009e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -13,17 +13,13 @@ import android.widget.LinearLayout.LayoutParams import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.Hint import io.sentry.IHub import io.sentry.Scope import io.sentry.ScopeCallback -import io.sentry.SentryEvent import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder -import io.sentry.protocol.Mechanism -import io.sentry.protocol.SentryException import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -221,14 +217,7 @@ class ReplaySmokeTest { } catch (e: ConditionTimeoutException) { } - val crash = SentryEvent().apply { - exceptions = listOf( - SentryException().apply { - mechanism = Mechanism().apply { isHandled = false } - } - ) - } - replay.sendReplayForEvent(crash, Hint()) + replay.captureReplay(isTerminating = false) await.timeout(Duration.ofSeconds(5)).untilTrue(captured) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 6dd1b99250..ac593f6c27 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub import io.sentry.Scope import io.sentry.ScopeCallback @@ -13,25 +12,19 @@ import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.DefaultReplayBreadcrumbConverter import io.sentry.android.replay.GeneratedVideo import io.sentry.android.replay.ReplayCache -import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID -import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig -import io.sentry.cache.PersistingScopeObserver import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent -import io.sentry.rrweb.RRWebInteractionEvent -import io.sentry.rrweb.RRWebInteractionEvent.InteractionType import io.sentry.rrweb.RRWebMetaEvent -import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule @@ -123,7 +116,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId, false) + strategy.start(fixture.recorderConfig, 0, replayId) assertEquals(replayId, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -135,7 +128,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId, false) + strategy.start(fixture.recorderConfig, 0, replayId) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -162,124 +155,10 @@ class SessionCaptureStrategyTest { assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) } - @Test - fun `start cleans up old replays`() { - val replayId = SentryId() - - fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val currentReplay = - File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } - val evenOlderReplay = - File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } - val scopeCache = File( - fixture.options.cacheDirPath, - PersistingScopeObserver.SCOPE_CACHE - ).also { it.mkdirs() } - - val strategy = fixture.getSut() - - strategy.start(fixture.recorderConfig, 0, replayId, true) - - // deletes older replay folders, but keeps current and previous replay + everything else - assertTrue(currentReplay.exists()) - assertTrue(scopeCache.exists()) - assertFalse(evenOlderReplay.exists()) - } - - @Test - fun `start finalizes previous replay`() { - val replayId = SentryId() - val oldReplayId = SentryId() - - fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val currentReplay = - File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } - val oldReplay = - File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } - val scopeCache = File( - fixture.options.cacheDirPath, - PersistingScopeObserver.SCOPE_CACHE - ).also { it.mkdirs() } - File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).also { - it.createNewFile() - it.writeText("\"$oldReplayId\"") - } - val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) - fixture.options.serializer.serialize( - listOf( - Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { - category = "navigation" - type = "navigation" - setData("from", "from") - setData("to", "to") - } - ), - breadcrumbsFile.writer() - ) - File(oldReplay, ONGOING_SEGMENT).also { - it.writeText( - """ - $SEGMENT_KEY_HEIGHT=912 - $SEGMENT_KEY_WIDTH=416 - $SEGMENT_KEY_FRAME_RATE=1 - $SEGMENT_KEY_BIT_RATE=75000 - $SEGMENT_KEY_ID=1 - $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z - $SEGMENT_KEY_REPLAY_TYPE=SESSION - $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] - """.trimIndent() - ) - } - - val strategy = fixture.getSut(replayCacheDir = oldReplay) - strategy.start(fixture.recorderConfig, 0, replayId, true) - - assertTrue(currentReplay.exists()) - assertFalse(oldReplay.exists()) - verify(fixture.hub).captureReplay( - check { - assertEquals(oldReplayId, it.replayId) - assertEquals(ReplayType.SESSION, it.replayType) - assertEquals("0.mp4", it.videoFile?.name) - }, - check { - val metaEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(912, metaEvents?.first()?.height) - assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 - - val videoEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(912, videoEvents?.first()?.height) - assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 - assertEquals(5000, videoEvents?.first()?.durationMs) - assertEquals(5, videoEvents?.first()?.frameCount) - assertEquals(1, videoEvents?.first()?.frameRate) - assertEquals(1, videoEvents?.first()?.segmentId) - - val breadcrumbEvents = - it.replayRecording?.payload?.filterIsInstance() - assertEquals("navigation", breadcrumbEvents?.first()?.category) - assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) - - val interactionEvents = - it.replayRecording?.payload?.filterIsInstance() - assertEquals( - InteractionType.TouchStart, - interactionEvents?.first()?.interactionType - ) - assertEquals(314.29794f, interactionEvents?.first()?.x) - assertEquals(625.4414f, interactionEvents?.first()?.y) - - assertEquals(InteractionType.TouchEnd, interactionEvents?.last()?.interactionType) - assertEquals(322.0039f, interactionEvents?.last()?.x) - assertEquals(424.43848f, interactionEvents?.last()?.y) - } - ) - } - @Test fun `pause creates and captures current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId(), false) + strategy.start(fixture.recorderConfig, 0, SentryId()) strategy.pause() @@ -299,7 +178,7 @@ class SessionCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId, false) + strategy.start(fixture.recorderConfig, 0, replayId) strategy.stop() @@ -317,26 +196,26 @@ class SessionCaptureStrategyTest { } @Test - fun `sendReplayForEvent captures last segment for crashed event`() { + fun `captureReplay does nothing for non-crashed event`() { val strategy = fixture.getSut() strategy.start(fixture.recorderConfig) - strategy.sendReplayForEvent(true, "event-id", Hint()) {} + strategy.captureReplay(false) {} - verify(fixture.hub).captureReplay( - argThat { event -> - event is SentryReplayEvent && event.segmentId == 0 - }, - any() - ) + verify(fixture.hub, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent does nothing for non-crashed event`() { - val strategy = fixture.getSut() + fun `when process is crashing, onScreenshotRecorded does not create new segment`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) strategy.start(fixture.recorderConfig) - strategy.sendReplayForEvent(false, "event-id", Hint()) {} + strategy.captureReplay(true) {} + strategy.onScreenshotRecorded(mock()) {} verify(fixture.hub, never()).captureReplay(any(), any()) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 8876efd66d..a506622a1d 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -165,7 +165,7 @@ - + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 708083ea3c..598b2c7b6b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -658,6 +658,7 @@ public abstract interface class io/sentry/IOptionsObserver { public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setProguardUuid (Ljava/lang/String;)V public abstract fun setRelease (Ljava/lang/String;)V + public abstract fun setReplayErrorSampleRate (Ljava/lang/Double;)V public abstract fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public abstract fun setTags (Ljava/util/Map;)V } @@ -1260,14 +1261,13 @@ public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBre } public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public fun captureReplay (Ljava/lang/Boolean;)V 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 @@ -1676,13 +1676,12 @@ public abstract interface class io/sentry/ReplayBreadcrumbConverter { } public abstract interface class io/sentry/ReplayController { + public abstract fun captureReplay (Ljava/lang/Boolean;)V 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 @@ -2120,7 +2119,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 fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;Z)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; @@ -3332,6 +3331,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public static final field OPTIONS_CACHE Ljava/lang/String; public static final field PROGUARD_UUID_FILENAME Ljava/lang/String; public static final field RELEASE_FILENAME Ljava/lang/String; + public static final field REPLAY_ERROR_SAMPLE_RATE_FILENAME Ljava/lang/String; public static final field SDK_VERSION_FILENAME Ljava/lang/String; public static final field TAGS_FILENAME Ljava/lang/String; public fun (Lio/sentry/SentryOptions;)V @@ -3341,6 +3341,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public fun setEnvironment (Ljava/lang/String;)V public fun setProguardUuid (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayErrorSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setTags (Ljava/util/Map;)V } @@ -3372,6 +3373,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V + public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;)V } public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 4db7910d0a..f87a148bf1 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -28,6 +28,8 @@ import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import io.sentry.util.HintUtils import org.junit.Assert.assertArrayEquals +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -67,6 +69,9 @@ import kotlin.test.assertTrue class SentryClientTest { + @get:Rule + val tmpDir = TemporaryFolder() + class Fixture { var transport = mock() var factory = mock() @@ -2362,41 +2367,6 @@ 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") @@ -2761,10 +2731,75 @@ class SentryClientTest { }) val sut = fixture.getSut() - sut.captureMessage("Test", WARNING) + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) assertTrue(called) } + @Test + fun `calls captureReplay on replay controller for crash events and sets isTerminating`() { + var terminated: Boolean? = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + terminated = isTerminating + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + } + ) + assertTrue(terminated == true) + } + + @Test + fun `cleans up replay folder for Backfillable replay events`() { + val dir = File(tmpDir.newFolder().absolutePath) + val sut = fixture.getSut() + val replayEvent = createReplayEvent().apply { + videoFile = File(dir, "hello.txt").apply { writeText("hello") } + } + + sut.captureReplayEvent(replayEvent, createScope(), HintUtils.createWithTypeCheckHint(BackfillableHint())) + + verify(fixture.transport).send( + check { actual -> + val item = actual.items.first() + item.data + assertFalse(dir.exists()) + }, + any() + ) + } + + @Test + fun `does not captureReplay for backfillable events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + }, + HintUtils.createWithTypeCheckHint(BackfillableHint()) + ) + assertFalse(called) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index efc5e5cadf..760d1270e5 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -8,6 +8,8 @@ import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField import io.sentry.vendor.Base64 import org.junit.Assert.assertArrayEquals +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -31,6 +33,9 @@ import kotlin.test.assertNull class SentryEnvelopeItemTest { + @get:Rule + val tmpDir = TemporaryFolder() + private class Fixture { val options = SentryOptions() val serializer = JsonSerializer(options) @@ -469,7 +474,7 @@ class SentryEnvelopeItemTest { } val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording, false) assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) @@ -486,7 +491,7 @@ class SentryEnvelopeItemTest { } val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, false) replayItem.data assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> assertEquals(1, mapSize) @@ -503,10 +508,28 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, false) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + + @Test + fun `fromReplay cleans up video folder if cleanupReplayFolder is set`() { + val dir = File(tmpDir.newFolder().absolutePath) + val file = File(dir, 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, true) assert(file.exists()) replayItem.data assertFalse(file.exists()) + assertFalse(dir.exists()) } private fun createSession(): Session { diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 0f4966b44a..5ef764bc5d 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -737,6 +737,7 @@ class SentryTest { it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") it.environment = "debug" it.setTag("one", "two") + it.experimental.sessionReplay.errorSampleRate = 0.5 } assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) @@ -745,6 +746,7 @@ class SentryTest { assertEquals("uuid", optionsObserver.proguardUuid) assertEquals(mapOf("one" to "two"), optionsObserver.tags) assertEquals(SdkVersion("sentry.java.android", "6.13.0"), optionsObserver.sdkVersion) + assertEquals(0.5, optionsObserver.replayErrorSampleRate) } @Test @@ -1164,6 +1166,8 @@ class SentryTest { private set var tags: Map = mapOf() private set + var replayErrorSampleRate: Double? = null + private set override fun setRelease(release: String?) { this.release = release @@ -1188,6 +1192,10 @@ class SentryTest { override fun setTags(tags: MutableMap) { this.tags = tags } + + override fun setReplayErrorSampleRate(replayErrorSampleRate: Double?) { + this.replayErrorSampleRate = replayErrorSampleRate + } } private class CustomMainThreadChecker : IMainThreadChecker { diff --git a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt index ded3908f14..3c325bd640 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt @@ -5,6 +5,7 @@ import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME import io.sentry.protocol.SdkVersion @@ -28,13 +29,18 @@ class DeleteOptionsValue(private val delete: PersistingOptionsObserver.() -> Uni } } +class ReadOptionsValue(private val read: (options: SentryOptions) -> T) { + operator fun invoke(options: SentryOptions) = read(options) +} + @RunWith(Parameterized::class) class PersistingOptionsObserverTest( private val entity: T, private val store: StoreOptionsValue, private val filename: String, private val delete: DeleteOptionsValue, - private val deletedEntity: T? + private val deletedEntity: T?, + private val read: ReadOptionsValue? ) { @get:Rule @@ -60,7 +66,7 @@ class PersistingOptionsObserverTest( val sut = fixture.getSut(tmpDir) store(entity, sut) - val persisted = read() + val persisted = read?.invoke(fixture.options) ?: read() assertEquals(entity, persisted) delete(sut) @@ -81,6 +87,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setRelease(it) }, RELEASE_FILENAME, DeleteOptionsValue { setRelease(null) }, + null, null ) @@ -89,6 +96,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setProguardUuid(it) }, PROGUARD_UUID_FILENAME, DeleteOptionsValue { setProguardUuid(null) }, + null, null ) @@ -97,6 +105,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setSdkVersion(it) }, SDK_VERSION_FILENAME, DeleteOptionsValue { setSdkVersion(null) }, + null, null ) @@ -105,6 +114,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setDist(it) }, DIST_FILENAME, DeleteOptionsValue { setDist(null) }, + null, null ) @@ -113,6 +123,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setEnvironment(it) }, ENVIRONMENT_FILENAME, DeleteOptionsValue { setEnvironment(null) }, + null, null ) @@ -124,7 +135,23 @@ class PersistingOptionsObserverTest( StoreOptionsValue> { setTags(it) }, TAGS_FILENAME, DeleteOptionsValue { setTags(emptyMap()) }, - emptyMap() + emptyMap(), + null + ) + + private fun replaysErrorSampleRate(): Array = arrayOf( + 0.5, + StoreOptionsValue { setReplayErrorSampleRate(it) }, + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + DeleteOptionsValue { setReplayErrorSampleRate(null) }, + null, + ReadOptionsValue { + PersistingOptionsObserver.read( + it, + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String::class.java + )!!.toDouble() + } ) @JvmStatic @@ -136,7 +163,8 @@ class PersistingOptionsObserverTest( dist(), environment(), sdkVersion(), - tags() + tags(), + replaysErrorSampleRate() ) } } From 4e4a4378a0ea1363d87d4ca39ba099bba951fc9a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 29 Jul 2024 23:52:59 +0200 Subject: [PATCH 178/184] Add buffer strategy test --- .../android/replay/ReplayIntegration.kt | 2 +- .../replay/capture/BufferCaptureStrategy.kt | 31 +- .../ReplayIntegrationWithRecorderTest.kt | 5 +- .../capture/BufferCaptureStrategyTest.kt | 270 ++++++++++++++++++ 4 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 1a864fef45..c1a18eddd9 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -150,7 +150,7 @@ public class ReplayIntegration( captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider) + BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider = replayCacheProvider) } captureStrategy?.start(recorderConfig) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index b836b31553..03ec9aa2d0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -20,14 +20,16 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.security.SecureRandom +import java.util.concurrent.ScheduledExecutorService internal class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, private val random: SecureRandom, + executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() @@ -138,19 +140,6 @@ internal class BufferCaptureStrategy( } } - 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) { createCurrentSegment("configuration_changed") { segment -> if (segment is ReplaySegment.Created) { @@ -194,6 +183,19 @@ internal class BufferCaptureStrategy( } } + 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) + } + } + private fun MutableList.capture() { var bufferedSegment = removeFirstOrNull() while (bufferedSegment != null) { @@ -204,6 +206,7 @@ internal class BufferCaptureStrategy( } private fun MutableList.rotate(bufferLimit: Long) { + // TODO: can be a single while-loop var removed = false removeAll { // it can be that the buffered segment is half-way older than the buffer limit, but diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index f7e4da2304..8e3bef2c2f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -22,6 +22,7 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.thread.NoOpMainThreadChecker import org.awaitility.kotlin.await import org.junit.Rule import org.junit.Test @@ -49,7 +50,9 @@ class ReplayIntegrationWithRecorderTest { val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + mainThreadChecker = NoOpMainThreadChecker.getInstance() + } val hub = mock() var encoder: SimpleVideoEncoder? = null diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt new file mode 100644 index 0000000000..5e5130aae8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -0,0 +1,270 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayFrame +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BufferCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = mutableMapOf() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + errorSampleRate: Double = 1.0, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): BufferCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + options.run { + experimental.sessionReplay.errorSampleRate = errorSampleRate + } + return BufferCaptureStrategy( + options, + hub, + dateProvider, + SecureRandom(), + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + + fun mockedMotionEvent(action: Int): MotionEvent = mock { + on { actionMasked }.thenReturn(action) + on { getPointerId(anyInt()) }.thenReturn(0) + on { findPointerIndex(anyInt()) }.thenReturn(0) + on { getX(anyInt()) }.thenReturn(1f) + on { getY(anyInt()) }.thenReturn(1f) + } + } + + private val fixture = Fixture() + + @Test + fun `start does not set replayId on scope for buffered session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.BUFFER.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates but does not capture current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + await.until { strategy.currentSegment == 1 } + + verify(fixture.hub, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop clears replay cache dir`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.hub, never()).captureReplay(any(), any()) + + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `onScreenshotRecorded adds screenshot to cache`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + } + + @Test + fun `onScreenshotRecorded rotates screenshots when out of buffer bounds`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + verify(fixture.replayCache).rotate(eq(now - fixture.options.experimental.sessionReplay.errorReplayDuration)) + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + await.until { strategy.currentSegment == 1 } + + verify(fixture.hub, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `convert does nothing when process is terminating`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + + @Test + fun `convert converts to session strategy and sets replayId to scope`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val converted = strategy.convert() + assertTrue(converted is SessionCaptureStrategy) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay does not replayId to scope when not sampled`() { + val strategy = fixture.getSut(errorSampleRate = 0.0) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + } + + @Test + fun `captureReplay sets replayId to scope and captures buffered segments`() { + var called = false + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) { + called = true + } + + // buffered + current = 2 + verify(fixture.hub, times(2)).captureReplay(any(), any()) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + assertTrue(called) + } +} From a45ade2544ead1d3967a7888f7d336f56f1ad93d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jul 2024 17:17:02 +0200 Subject: [PATCH 179/184] Improve ReplayCache persistence logic --- .../io/sentry/android/replay/ReplayCache.kt | 40 ++++++++++++------- .../sentry/android/replay/util/Persistable.kt | 2 +- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index aaa0223aa7..d7dda8bbd7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -71,6 +71,19 @@ public class ReplayCache internal constructor( // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() + private val ongoingSegment = LinkedHashMap() + private val ongoingSegmentFile: File? by lazy { + if (replayCacheDir == null) { + return@lazy null + } + + val file = File(replayCacheDir, ONGOING_SEGMENT) + if (!file.exists()) { + file.createNewFile() + } + file + } + /** * 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 @@ -249,23 +262,20 @@ public class ReplayCache internal constructor( if (isClosed.get()) { return } - val file = File(replayCacheDir, ONGOING_SEGMENT) - if (!file.exists()) { - file.createNewFile() - } - val map = LinkedHashMap() - file.useLines { lines -> - lines.associateTo(map) { - val (k, v) = it.split("=", limit = 2) - k to v - } - if (value == null) { - map.remove(key) - } else { - map[key] = value + if (ongoingSegment.isEmpty()) { + ongoingSegmentFile?.useLines { lines -> + lines.associateTo(ongoingSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } } } - file.writeText(map.entries.joinToString("\n") { (k, v) -> "$k=$v" }) + if (value == null) { + ongoingSegment.remove(key) + } else { + ongoingSegment[key] = value + } + ongoingSegmentFile?.writeText(ongoingSegment.entries.joinToString("\n") { (k, v) -> "$k=$v" }) } companion object { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt index 2011e7d5ad..553bae8dee 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -37,7 +37,7 @@ internal class PersistableLinkedList( private fun persistRecording() { val cache = cacheProvider() ?: return - val recording = ReplayRecording().apply { payload = this@PersistableLinkedList } + val recording = ReplayRecording().apply { payload = ArrayList(this@PersistableLinkedList) } if (options.mainThreadChecker.isMainThread) { persistingExecutor.submit { val stringWriter = StringWriter() From c503b1d3df8d07bae32b732578043894734f8121 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jul 2024 17:26:26 +0200 Subject: [PATCH 180/184] Address PR feedback --- .../io/sentry/android/core/AnrV2EventProcessor.java | 2 +- .../android/replay/capture/BaseCaptureStrategy.kt | 13 +++++++------ sentry/src/main/java/io/sentry/Scope.java | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 1b386c38e0..29c38ef6c2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -157,7 +157,7 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje } private void setReplayId(final @NotNull SentryEvent event) { - final String persistedReplayId = + final @Nullable String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); if (persistedReplayId == null) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6da7e9991f..8aa2b41c85 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -524,10 +524,8 @@ internal abstract class BaseCaptureStrategy( private inline fun persistableAtomicNullable( initialValue: T? = null, - propertyName: String? = null, + propertyName: String, crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> - propertyName ?: error("Can't persist value without a property name") - if (options.mainThreadChecker.isMainThread) { persistingExecutor.submit { cache?.persistSegmentValues(propertyName, newValue.toString()) @@ -556,10 +554,8 @@ internal abstract class BaseCaptureStrategy( private inline fun persistableAtomic( initialValue: T? = null, - propertyName: String? = null, + propertyName: String, crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> - propertyName ?: error("Can't persist value without a property name") - if (options.mainThreadChecker.isMainThread) { persistingExecutor.submit { cache?.persistSegmentValues(propertyName, newValue.toString()) @@ -570,4 +566,9 @@ internal abstract class BaseCaptureStrategy( } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty + + private inline fun persistableAtomic( + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit + ): ReadWriteProperty = + persistableAtomicNullable(null, "", onChange) as ReadWriteProperty } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 207919ed7b..f071cb8c5e 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -943,8 +943,9 @@ public void clearSession() { public void setPropagationContext(final @NotNull PropagationContext propagationContext) { this.propagationContext = propagationContext; + final @NotNull SpanContext spanContext = propagationContext.toSpanContext(); for (final IScopeObserver observer : options.getScopeObservers()) { - observer.setTrace(propagationContext.toSpanContext(), this); + observer.setTrace(spanContext, this); } } From aadb364bcf9e469f879c4948756331a522d38bf8 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 30 Jul 2024 15:41:38 +0000 Subject: [PATCH 181/184] Format code --- .../main/java/io/sentry/android/core/AnrV2EventProcessor.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 6c76a02985..6f54265d65 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -200,7 +200,8 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { } private void setReplayId(final @NotNull SentryEvent event) { - @Nullable String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + @Nullable + String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); final File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); if (!replayFolder.exists()) { if (!sampleReplay(event)) { From 5c2f8fc1ebeec87bdd788b035f51f69a181bdfd7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jul 2024 17:44:07 +0200 Subject: [PATCH 182/184] Revert sample rate to session --- .../sentry-samples-android/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index a506622a1d..8876efd66d 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -165,7 +165,7 @@ - + From b98445550d40a5ecd26855617437ef51ce547d53 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jul 2024 20:46:28 +0200 Subject: [PATCH 183/184] Address PR feedback --- .../sentry/android/core/AnrV2EventProcessor.java | 5 +++-- .../sentry/android/replay/ReplayIntegration.kt | 6 +++--- .../replay/capture/BufferCaptureStrategy.kt | 6 +++++- .../android/replay/capture/CaptureStrategy.kt | 16 ++++++++-------- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 6f54265d65..b1751d5cc8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -180,7 +180,7 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { try { // we have to sample here with the old sample rate, because it may change between app launches - final SecureRandom random = this.random != null ? this.random : new SecureRandom(); + final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom(); final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); if (replayErrorSampleRateDouble < random.nextDouble()) { options @@ -202,7 +202,8 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { private void setReplayId(final @NotNull SentryEvent event) { @Nullable String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); - final File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); + final @NotNull File replayFolder = + new File(options.getCacheDirPath(), "replay_" + persistedReplayId); if (!replayFolder.exists()) { if (!sampleReplay(event)) { return; diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c1a18eddd9..e99aec2c90 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -260,14 +260,14 @@ public class ReplayIntegration( private fun cleanupReplays(unfinishedReplayId: String = "") { // clean up old replays options.cacheDirPath?.let { cacheDir -> - File(cacheDir).listFiles { dir, name -> + File(cacheDir).listFiles()?.forEach { file -> + val name = file.name if (name.startsWith("replay_") && !name.contains(replayId.toString()) && !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) ) { - FileUtils.deleteRecursively(File(dir, name)) + FileUtils.deleteRecursively(file) } - false } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 03ec9aa2d0..a49c7bf789 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -40,6 +40,7 @@ internal class BufferCaptureStrategy( internal companion object { private const val TAG = "BufferCaptureStrategy" + private const val ENVELOPE_PROCESSING_DELAY: Long = 100L } override fun start( @@ -201,7 +202,10 @@ internal class BufferCaptureStrategy( while (bufferedSegment != null) { bufferedSegment.capture(hub) bufferedSegment = removeFirstOrNull() - Thread.sleep(100L) + // a short delay between processing envelopes to avoid bursting our server and hitting + // another rate limit https://develop.sentry.dev/sdk/features/#additional-capabilities + // InterruptedException will be handled by the outer try-catch + Thread.sleep(ENVELOPE_PROCESSING_DELAY) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 712e632abd..c3be520b84 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -127,13 +127,13 @@ internal interface CaptureStrategy { ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = SentryReplayEvent().apply { - eventId = currentReplayId - replayId = currentReplayId + this.eventId = currentReplayId + this.replayId = currentReplayId this.segmentId = segmentId this.timestamp = endTimestamp - replayStartTimestamp = segmentTimestamp + this.replayStartTimestamp = segmentTimestamp this.replayType = replayType - videoFile = video + this.videoFile = video } val recordingPayload = mutableListOf() @@ -147,13 +147,13 @@ internal interface CaptureStrategy { this.segmentId = segmentId this.durationMs = videoDuration this.frameCount = frameCount - size = video.length() + this.size = video.length() this.frameRate = frameRate this.height = height this.width = width // TODO: support non-fullscreen windows later - left = 0 - top = 0 + this.left = 0 + this.top = 0 } val urls = LinkedList() @@ -189,7 +189,7 @@ internal interface CaptureStrategy { val recording = ReplayRecording().apply { this.segmentId = segmentId - payload = recordingPayload.sortedBy { it.timestamp } + this.payload = recordingPayload.sortedBy { it.timestamp } } replay.urls = urls From dc8b49a3b9fa51f90371e773713ef1668a562bfc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jul 2024 21:21:53 +0200 Subject: [PATCH 184/184] Changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e24537f31..7037e72370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Features + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Capture remaining replay segment for ANRs on next app launch + - Capture remaining replay segment for unhandled crashes on next app launch + +### Fixes + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Fix stopping replay in `session` mode at 1 hour deadline + - Never encode full frames for a video segment, only do partial updates. This further reduces size of the replay segment + - Use propagation context when no active transaction for ANRs + ### Dependencies - Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541))