Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b8555e2
Update dsn for testing
43jay Oct 7, 2025
ca28040
Enable replay verbose logging
43jay Oct 7, 2025
5397e9d
Point dsn to sentry-android project
43jay Oct 7, 2025
71ed70e
Add HTTP Request Trigger to sentry-samples-android app
43jay Oct 7, 2025
f2ce22e
[replay] Make DefaultReplayBreadcrumbConverter the default BeforeBrea…
43jay Oct 9, 2025
201102a
[replay] Add data classes for NetworkDetails
43jay Oct 13, 2025
724ec42
[DNL] Force dashboard to show request/response bodies
43jay Oct 13, 2025
2d08e7b
Move BeforeBreadcrumbCallback initialization to after user config
43jay Oct 13, 2025
ebc5ff3
bugfix: Update Breadcrumb #hashcode to be consistent with #equals
43jay Oct 15, 2025
122a8a6
Initial NetworkDetails extraction logic
43jay Oct 13, 2025
6964a53
Add FAKE_OPTIONS for testing
43jay Oct 23, 2025
25c42c7
DefaultReplayBreadcrumbConverter properly manages NetworkRequestData …
43jay Oct 23, 2025
308072b
Extract bodies of okhttp requests/responses
43jay Oct 24, 2025
5836ef1
Check-in sentry-android-replay.api
43jay Oct 24, 2025
d9f8254
Cleanup
43jay Oct 23, 2025
6b6f3fe
Linter
43jay Oct 27, 2025
cadc529
Linter and clean-up unused code
43jay Oct 27, 2025
465b6e5
Add additional http request types to sentry-samples app for testing
43jay Oct 27, 2025
6ab8e03
Cleaning up logging
43jay Oct 27, 2025
d3f1a24
Formatting
43jay Oct 27, 2025
9eba521
Add body too large http request types to sentry-samples app
43jay Oct 27, 2025
8deffe9
Properly handle content bodies that are too large
43jay Oct 27, 2025
669c8a3
Cleanup DefaultReplayBreadcrumbConverterTest
43jay Oct 27, 2025
8b29cdd
Address cursor[bot] nullpointer dereference comment
43jay Oct 27, 2025
4bc5b77
Disable Network Detail extraction
43jay Oct 27, 2025
177ae0c
Revert "Point dsn to sentry-android project"
43jay Oct 27, 2025
a204a40
Revert "Enable replay verbose logging"
43jay Oct 27, 2025
6629e94
Revert "Update dsn for testing"
43jay Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import io.sentry.NoOpSocketTagger;
import io.sentry.NoOpTransactionProfiler;
import io.sentry.NoopVersionDetector;
import io.sentry.ReplayBreadcrumbConverter;
import io.sentry.ScopeType;
import io.sentry.SendFireAndForgetEnvelopeSender;
import io.sentry.SendFireAndForgetOutboxSender;
Expand Down Expand Up @@ -247,6 +248,13 @@ static void initializeIntegrationsAndProcessors(
options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options));
}

ReplayBreadcrumbConverter replayBreadcrumbConverter = options.getReplayController().getBreadcrumbConverter();
Copy link
Member

@romtsn romtsn Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this currently has a bug if someone excludes the -replay module from the build, then this would return a NoOpReplayBreadcrumbConverter implementation which does not currently delegate to the user-defined beforeBreadcrumb callback. So we'd be effectively swallowing the breadcrumb in here.

Given my comment here, if we go for that impl, we could change to instantiating and setting DefaultReplayBreadcrumbConverter here:

final @NotNull  converter = options.getReplayController().getBreadcrumbConverter();
if (converter instanceof NoOpReplayBreadcrumbConverter) {
  options.getReplayController().setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter(options));
}

I guess we still have to check for no-op here, because RN and Flutter define their own converters, e.g. see this, so we shouldn't be overwriting whatever they have set.

replayBreadcrumbConverter.setUserBeforeBreadcrumbCallback(options.getBeforeBreadcrumb());
options.setBeforeBreadcrumb(
replayBreadcrumbConverter
);


// Check if the profiler was already instantiated in the app start.
// We use the Android profiler, that uses a global start/stop api, so we need to preserve the
// state of the profiler, and it's only possible retaining the instance.
Expand Down
2 changes: 2 additions & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sent
public static final field $stable I
public fun <init> ()V
public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent;
public fun execute (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)Lio/sentry/Breadcrumb;
public fun setUserBeforeBreadcrumbCallback (Lio/sentry/SentryOptions$BeforeBreadcrumbCallback;)V
}

public final class io/sentry/android/replay/GeneratedVideo {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,61 @@
package io.sentry.android.replay

import io.sentry.Breadcrumb
import io.sentry.Hint
import io.sentry.ReplayBreadcrumbConverter
import io.sentry.SentryLevel
import io.sentry.SentryOptions.BeforeBreadcrumbCallback
import io.sentry.SpanDataConvention
import io.sentry.rrweb.RRWebBreadcrumbEvent
import io.sentry.rrweb.RRWebEvent
import io.sentry.rrweb.RRWebSpanEvent
import io.sentry.util.network.NetworkRequestData
import java.util.Collections
import kotlin.LazyThreadSafetyMode.NONE

public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {

internal companion object {
private const val MAX_HTTP_NETWORK_DETAILS = 32
private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() }
private val supportedNetworkData =
HashSet<String>().apply {
add("status_code")
add("method")
add("response_content_length")
add("request_content_length")
add("http.response_content_length")
add("http.request_content_length")
}

private val supportedNetworkData = HashSet<String>().apply {
add("status_code")
add("method")
add("response_content_length")
add("request_content_length")
add("http.response_content_length")
add("http.request_content_length")
}
}

private var lastConnectivityState: String? = null

private val httpNetworkDetails = Collections.synchronizedMap(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice use of removeEldestEntry!

object : LinkedHashMap<Breadcrumb, NetworkRequestData>() {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<Breadcrumb, NetworkRequestData>?
): Boolean {
return size > MAX_HTTP_NETWORK_DETAILS
}
}
)

private var userBeforeBreadcrumbCallback: BeforeBreadcrumbCallback? = null

override fun convert(breadcrumb: Breadcrumb): RRWebEvent? {
var breadcrumbMessage: String? = null
var breadcrumbCategory: String? = null
val breadcrumbCategory: String?
var breadcrumbLevel: SentryLevel? = null
val breadcrumbData = mutableMapOf<String, Any?>()

when {
breadcrumb.category == "http" -> {
return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null
return if (breadcrumb.isValidForRRWebSpan()) {
breadcrumb.toRRWebSpanEvent()
} else {
null
}
}

breadcrumb.type == "navigation" && breadcrumb.category == "app.lifecycle" -> {
Expand All @@ -42,6 +65,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
breadcrumb.type == "navigation" && breadcrumb.category == "device.orientation" -> {
breadcrumbCategory = breadcrumb.category!!
val position = breadcrumb.data["position"]

if (position == "landscape" || position == "portrait") {
breadcrumbData["position"] = position
} else {
Expand All @@ -51,42 +75,42 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {

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
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"]
breadcrumbMessage = (
breadcrumb.data["view.id"]
?: breadcrumb.data["view.tag"]
?: breadcrumb.data["view.class"])
as? String ?: return null
?: 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
breadcrumbData["state"] = when {
breadcrumb.data["action"] == "NETWORK_LOST" -> "offline"
"network_type" in breadcrumb.data -> {
if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) {
breadcrumb.data["network_type"]
} else {
return null
}
}
else -> return null
}

if (lastConnectivityState == breadcrumbData["state"]) {
// debounce same state
// Debounce same state
return null
}

Expand All @@ -95,7 +119,9 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {

breadcrumb.data["action"] == "BATTERY_CHANGED" -> {
breadcrumbCategory = "device.battery"
breadcrumbData.putAll(breadcrumb.data.filterKeys { it == "level" || it == "charging" })
breadcrumbData.putAll(
breadcrumb.data.filterKeys { it == "level" || it == "charging" }
)
}

else -> {
Expand All @@ -105,6 +131,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
breadcrumbData.putAll(breadcrumb.data)
}
}

return if (!breadcrumbCategory.isNullOrEmpty()) {
RRWebBreadcrumbEvent().apply {
timestamp = breadcrumb.timestamp.time
Expand All @@ -120,44 +147,123 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
}
}

private fun Breadcrumb.isValidForRRWebSpan(): Boolean =
!(data["url"] as? String).isNullOrEmpty() &&
override fun setUserBeforeBreadcrumbCallback(beforeBreadcrumbCallback: BeforeBreadcrumbCallback?) {
this.userBeforeBreadcrumbCallback = beforeBreadcrumbCallback
}

/** Delegate to user-provided callback (if exists) to provide the final breadcrumb to process. */
override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? {
val callback = userBeforeBreadcrumbCallback
val result = if (callback != null) {
callback.execute(breadcrumb, hint)
} else {
breadcrumb
}

result?.let { finalBreadcrumb ->
extractNetworkRequestDataFromHint(finalBreadcrumb, hint)?.let { networkData ->
httpNetworkDetails[finalBreadcrumb] = networkData
}
}

return result
}

private fun extractNetworkRequestDataFromHint(
breadcrumb: Breadcrumb,
breadcrumbHint: Hint,
): NetworkRequestData? {
if (breadcrumb.type != "http" && breadcrumb.category != "http") {
return null
}

return breadcrumbHint.get("replay:networkDetails") as? NetworkRequestData
}

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 =
replace(snakecasePattern) { it.value.last().toString().uppercase() }
private fun String.snakeToCamelCase(): String {
return replace(snakecasePattern) { it.value.last().toString().uppercase() }
}

private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent {
val breadcrumb = this
val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP]
val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP]

return RRWebSpanEvent().apply {
timestamp = breadcrumb.timestamp.time
op = "resource.http"
description = breadcrumb.data["url"] as String
// 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
}

// 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<String, Any?>()

val networkDetailData = httpNetworkDetails.remove(breadcrumb)

// Add Network Details data when available
networkDetailData?.let { networkData ->
networkData.method?.let { breadcrumbData["method"] = it }
networkData.statusCode?.let { breadcrumbData["statusCode"] = it }
networkData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it }
networkData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it }

networkData.request?.let { request ->
val requestData = mutableMapOf<String, Any?>()
request.size?.let { requestData["size"] = it }
request.body?.let { requestData["body"] = it }

if (request.headers.isNotEmpty()) {
requestData["headers"] = request.headers
}

if (requestData.isNotEmpty()) {
breadcrumbData["request"] = requestData
}
}

networkData.response?.let { response ->
val responseData = mutableMapOf<String, Any?>()
response.size?.let { responseData["size"] = it }
response.body?.let { responseData["body"] = it }

if (response.headers.isNotEmpty()) {
responseData["headers"] = response.headers
}

if (responseData.isNotEmpty()) {
breadcrumbData["response"] = responseData
}
}
}

// Original breadcrumb http data
for ((key, value) in breadcrumb.data) {
if (key in supportedNetworkData) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess some of these keys can override what you set above right? (e.g. responseBodySize). But I guess it should be the same value, so doesn't matter much probably

breadcrumbData[
key.replace("content_length", "body_size").substringAfter(".").snakeToCamelCase(),
] = value
val formattedKey = key
.replace("content_length", "body_size")
.substringAfter(".")
.snakeToCamelCase()
breadcrumbData[formattedKey] = value
}
}

data = breadcrumbData
}
}
Expand Down
Loading