Skip to content

Commit 11437d4

Browse files
authored
[SR] Allow RRWeb breadcrumb customization from hybrid SDKs
2 parents 50443a4 + 4aa50c2 commit 11437d4

File tree

13 files changed

+521
-139
lines changed

13 files changed

+521
-139
lines changed

sentry-android-core/proguard-rules.pro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,6 @@
7575

7676
##---------------Begin: proguard configuration for sentry-android-replay ----------
7777
-dontwarn io.sentry.android.replay.ReplayIntegration
78-
-dontwarn io.sentry.android.replay.ReplayIntegrationKt
78+
-dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter
7979
-keepnames class io.sentry.android.replay.ReplayIntegration
8080
##---------------End: proguard configuration for sentry-android-replay ----------

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
2323
import io.sentry.android.core.performance.AppStartMetrics;
2424
import io.sentry.android.fragment.FragmentLifecycleIntegration;
25+
import io.sentry.android.replay.DefaultReplayBreadcrumbConverter;
2526
import io.sentry.android.replay.ReplayIntegration;
2627
import io.sentry.android.timber.SentryTimberIntegration;
2728
import io.sentry.cache.PersistingOptionsObserver;
@@ -308,6 +309,7 @@ static void installDefaultIntegrations(
308309
if (isReplayAvailable) {
309310
final ReplayIntegration replay =
310311
new ReplayIntegration(context, CurrentDateProvider.getInstance());
312+
replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter());
311313
options.addIntegration(replay);
312314
options.setReplayController(replay);
313315
}

sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ public final class SentryAndroid {
3636
static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME =
3737
"io.sentry.android.replay.ReplayIntegration";
3838

39-
private static boolean isReplayAvailable = false;
40-
4139
private static final String TIMBER_CLASS_NAME = "timber.log.Timber";
4240
private static final String FRAGMENT_CLASS_NAME =
4341
"androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks";
@@ -104,7 +102,7 @@ public static synchronized void init(
104102
final boolean isTimberAvailable =
105103
(isTimberUpstreamAvailable
106104
&& classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options));
107-
isReplayAvailable =
105+
final boolean isReplayAvailable =
108106
classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options);
109107

110108
final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger);

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ public final class io/sentry/android/replay/BuildConfig {
66
public fun <init> ()V
77
}
88

9+
public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter {
10+
public fun <init> ()V
11+
public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent;
12+
}
13+
914
public final class io/sentry/android/replay/GeneratedVideo {
1015
public fun <init> (Ljava/io/File;IJ)V
1116
public final fun component1 ()Ljava/io/File;
@@ -42,6 +47,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
4247
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
4348
public synthetic fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
4449
public fun close ()V
50+
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
4551
public final fun getReplayCacheDir ()Ljava/io/File;
4652
public fun getReplayId ()Lio/sentry/protocol/SentryId;
4753
public fun isRecording ()Z
@@ -55,6 +61,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
5561
public fun resume ()V
5662
public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V
5763
public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V
64+
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
5865
public fun start ()V
5966
public fun stop ()V
6067
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package io.sentry.android.replay
2+
3+
import io.sentry.Breadcrumb
4+
import io.sentry.ReplayBreadcrumbConverter
5+
import io.sentry.SentryLevel
6+
import io.sentry.SpanDataConvention
7+
import io.sentry.rrweb.RRWebBreadcrumbEvent
8+
import io.sentry.rrweb.RRWebEvent
9+
import io.sentry.rrweb.RRWebSpanEvent
10+
import kotlin.LazyThreadSafetyMode.NONE
11+
12+
public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
13+
internal companion object {
14+
private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() }
15+
private val supportedNetworkData = setOf(
16+
"status_code",
17+
"method",
18+
"response_content_length",
19+
"request_content_length",
20+
"http.response_content_length",
21+
"http.request_content_length"
22+
)
23+
}
24+
25+
override fun convert(breadcrumb: Breadcrumb): RRWebEvent? {
26+
var breadcrumbMessage: String? = null
27+
var breadcrumbCategory: String? = null
28+
var breadcrumbLevel: SentryLevel? = null
29+
val breadcrumbData = mutableMapOf<String, Any?>()
30+
when {
31+
breadcrumb.category == "http" -> {
32+
return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null
33+
}
34+
35+
breadcrumb.type == "navigation" &&
36+
breadcrumb.category == "app.lifecycle" -> {
37+
breadcrumbCategory = "app.${breadcrumb.data["state"]}"
38+
}
39+
40+
breadcrumb.type == "navigation" &&
41+
breadcrumb.category == "device.orientation" -> {
42+
breadcrumbCategory = breadcrumb.category!!
43+
val position = breadcrumb.data["position"]
44+
if (position == "landscape" || position == "portrait") {
45+
breadcrumbData["position"] = position
46+
} else {
47+
return null
48+
}
49+
}
50+
51+
breadcrumb.type == "navigation" -> {
52+
breadcrumbCategory = "navigation"
53+
breadcrumbData["to"] = when {
54+
breadcrumb.data["state"] == "resumed" -> (breadcrumb.data["screen"] as? String)?.substringAfterLast('.')
55+
"to" in breadcrumb.data -> breadcrumb.data["to"] as? String
56+
else -> null
57+
} ?: return null
58+
}
59+
60+
breadcrumb.category == "ui.click" -> {
61+
breadcrumbCategory = "ui.tap"
62+
breadcrumbMessage = (
63+
breadcrumb.data["view.id"]
64+
?: breadcrumb.data["view.tag"]
65+
?: breadcrumb.data["view.class"]
66+
) as? String ?: return null
67+
breadcrumbData.putAll(breadcrumb.data)
68+
}
69+
70+
breadcrumb.type == "system" && breadcrumb.category == "network.event" -> {
71+
breadcrumbCategory = "device.connectivity"
72+
breadcrumbData["state"] = when {
73+
breadcrumb.data["action"] == "NETWORK_LOST" -> "offline"
74+
"network_type" in breadcrumb.data -> if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) {
75+
breadcrumb.data["network_type"]
76+
} else {
77+
return null
78+
}
79+
80+
else -> return null
81+
}
82+
}
83+
84+
breadcrumb.data["action"] == "BATTERY_CHANGED" -> {
85+
breadcrumbCategory = "device.battery"
86+
breadcrumbData.putAll(
87+
breadcrumb.data.filterKeys { it == "level" || it == "charging" }
88+
)
89+
}
90+
91+
else -> {
92+
breadcrumbCategory = breadcrumb.category
93+
breadcrumbMessage = breadcrumb.message
94+
breadcrumbLevel = breadcrumb.level
95+
breadcrumbData.putAll(breadcrumb.data)
96+
}
97+
}
98+
return if (!breadcrumbCategory.isNullOrEmpty()) {
99+
RRWebBreadcrumbEvent().apply {
100+
timestamp = breadcrumb.timestamp.time
101+
breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0
102+
breadcrumbType = "default"
103+
category = breadcrumbCategory
104+
message = breadcrumbMessage
105+
level = breadcrumbLevel
106+
data = breadcrumbData
107+
}
108+
} else {
109+
null
110+
}
111+
}
112+
113+
private fun Breadcrumb.isValidForRRWebSpan(): Boolean {
114+
return !(data["url"] as? String).isNullOrEmpty() &&
115+
SpanDataConvention.HTTP_START_TIMESTAMP in data &&
116+
SpanDataConvention.HTTP_END_TIMESTAMP in data
117+
}
118+
119+
private fun String.snakeToCamelCase(): String {
120+
return replace(snakecasePattern) { it.value.last().uppercase() }
121+
}
122+
123+
private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent {
124+
val breadcrumb = this
125+
return RRWebSpanEvent().apply {
126+
timestamp = breadcrumb.timestamp.time
127+
op = "resource.http"
128+
description = breadcrumb.data["url"] as String
129+
startTimestamp =
130+
(breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0
131+
endTimestamp =
132+
(breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0
133+
134+
val breadcrumbData = mutableMapOf<String, Any?>()
135+
for ((key, value) in breadcrumb.data) {
136+
if (key in supportedNetworkData) {
137+
breadcrumbData[
138+
key
139+
.replace("content_length", "body_size")
140+
.substringAfter(".")
141+
.snakeToCamelCase()
142+
] = value
143+
}
144+
}
145+
data = breadcrumbData
146+
}
147+
}
148+
}

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import android.view.MotionEvent
99
import io.sentry.Hint
1010
import io.sentry.IHub
1111
import io.sentry.Integration
12+
import io.sentry.NoOpReplayBreadcrumbConverter
13+
import io.sentry.ReplayBreadcrumbConverter
1214
import io.sentry.ReplayController
1315
import io.sentry.SentryEvent
1416
import io.sentry.SentryIntegrationPackageStorage
@@ -54,6 +56,7 @@ public class ReplayIntegration(
5456
private val isRecording = AtomicBoolean(false)
5557
private var captureStrategy: CaptureStrategy? = null
5658
public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir
59+
private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance()
5760

5861
private lateinit var recorderConfig: ScreenshotRecorderConfig
5962

@@ -158,6 +161,12 @@ public class ReplayIntegration(
158161

159162
override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID
160163

164+
override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) {
165+
replayBreadcrumbConverter = converter
166+
}
167+
168+
override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter
169+
161170
override fun pause() {
162171
if (!isEnabled.get() || !isRecording.get()) {
163172
return

0 commit comments

Comments
 (0)