From f37c593b1bb00e2f529a9a7291aa2e16b21a515b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 May 2024 14:38:11 +0200 Subject: [PATCH 1/5] 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 2e83c497007..2d559fd7817 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -22,6 +22,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 ffaf60382cd..b8a8d4a7788 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 00000000000..4bf7f1d1407 --- /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 0a6418514bf..c1ee1f9ae81 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 e97eb80ebf7..d0c8bc226d9 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 6fa74a2ba53..d836a6c8a24 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 00000000000..d71a57e440f --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java @@ -0,0 +1,21 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpReplayBreadcrumbConverter implements ReplayBreadcrumbConverter { + + private static final NoOpReplayBreadcrumbConverter instance = new NoOpReplayBreadcrumbConverter(); + + public static NoOpReplayBreadcrumbConverter getInstance() { + return instance; + } + + private NoOpReplayBreadcrumbConverter() {} + + @Override + public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) { + return null; + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index a1a715318c3..d365f650ea6 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 00000000000..dadd5d9b6fd --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java @@ -0,0 +1,12 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebEvent; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public interface ReplayBreadcrumbConverter { + @Nullable + RRWebEvent convert(@NotNull Breadcrumb breadcrumb); +} diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index a85cdacc939..caaa847423d 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 2/5] 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 a78a5a14a19..0c6d47e5ecb 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 676bb2173a8..02db612a5da 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 4bf7f1d1407..3affe6f7a6f 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 3/5] 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 00000000000..650e3fe0813 --- /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 4/5] 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 650e3fe0813..0dfb3d39c8b 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 4aa50c26328af69936924e7c03fc999e77ef5297 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 18 Jun 2024 15:26:07 +0200 Subject: [PATCH 5/5] 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 3affe6f7a6f..198b9f86a4d 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 {