Skip to content

Commit f37c593

Browse files
committed
Allow rrweb breadcrumb customization from hybrid SDKs
1 parent 50443a4 commit f37c593

File tree

10 files changed

+231
-135
lines changed

10 files changed

+231
-135
lines changed

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-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+
11+
public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
12+
internal companion object {
13+
private val snakecasePattern = "_[a-z]".toRegex()
14+
private val supportedNetworkData = setOf(
15+
"status_code",
16+
"method",
17+
"response_content_length",
18+
"request_content_length",
19+
"http.response_content_length",
20+
"http.request_content_length"
21+
)
22+
}
23+
24+
override fun convert(breadcrumb: Breadcrumb): RRWebEvent? {
25+
var rrwebBreadcrumb: RRWebBreadcrumbEvent? = null
26+
27+
var breadcrumbMessage: String? = null
28+
var breadcrumbCategory: String? = null
29+
var breadcrumbLevel: SentryLevel? = null
30+
val breadcrumbData = mutableMapOf<String, Any?>()
31+
when {
32+
breadcrumb.category == "http" -> {
33+
return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null
34+
}
35+
36+
breadcrumb.type == "navigation" &&
37+
breadcrumb.category == "app.lifecycle" -> {
38+
breadcrumbCategory = "app.${breadcrumb.data["state"]}"
39+
}
40+
41+
breadcrumb.type == "navigation" &&
42+
breadcrumb.category == "device.orientation" -> {
43+
breadcrumbCategory = breadcrumb.category!!
44+
val position = breadcrumb.data["position"]
45+
if (position == "landscape" || position == "portrait") {
46+
breadcrumbData["position"] = position
47+
} else {
48+
return null
49+
}
50+
}
51+
52+
breadcrumb.type == "navigation" -> {
53+
breadcrumbCategory = "navigation"
54+
breadcrumbData["to"] = when {
55+
breadcrumb.data["state"] == "resumed" -> (breadcrumb.data["screen"] as? String)?.substringAfterLast('.')
56+
"to" in breadcrumb.data -> breadcrumb.data["to"] as? String
57+
else -> null
58+
} ?: return null
59+
}
60+
61+
breadcrumb.category == "ui.click" -> {
62+
breadcrumbCategory = "ui.tap"
63+
breadcrumbMessage = (
64+
breadcrumb.data["view.id"]
65+
?: breadcrumb.data["view.tag"]
66+
?: breadcrumb.data["view.class"]
67+
) as? String ?: return null
68+
breadcrumbData.putAll(breadcrumb.data)
69+
}
70+
71+
breadcrumb.type == "system" && breadcrumb.category == "network.event" -> {
72+
breadcrumbCategory = "device.connectivity"
73+
breadcrumbData["state"] = when {
74+
breadcrumb.data["action"] == "NETWORK_LOST" -> "offline"
75+
"network_type" in breadcrumb.data -> if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) {
76+
breadcrumb.data["network_type"]
77+
} else {
78+
return null
79+
}
80+
81+
else -> return null
82+
}
83+
}
84+
85+
breadcrumb.data["action"] == "BATTERY_CHANGED" -> {
86+
breadcrumbCategory = "device.battery"
87+
breadcrumbData.putAll(
88+
breadcrumb.data.filterKeys { it == "level" || it == "charging" }
89+
)
90+
}
91+
92+
else -> {
93+
breadcrumbCategory = breadcrumb.category
94+
breadcrumbMessage = breadcrumb.message
95+
breadcrumbLevel = breadcrumb.level
96+
breadcrumbData.putAll(breadcrumb.data)
97+
}
98+
}
99+
if (!breadcrumbCategory.isNullOrEmpty()) {
100+
rrwebBreadcrumb = RRWebBreadcrumbEvent().apply {
101+
timestamp = breadcrumb.timestamp.time
102+
breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0
103+
breadcrumbType = "default"
104+
category = breadcrumbCategory
105+
message = breadcrumbMessage
106+
level = breadcrumbLevel
107+
data = breadcrumbData
108+
}
109+
}
110+
return rrwebBreadcrumb
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)