Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 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
ef79439
Formatting / prettier
43jay Nov 4, 2025
13c1da8
Revert "Disable Network Detail extraction"
43jay Nov 4, 2025
4937d69
Modify ReplayBreadcrumbConverter API to avoid breaking flutter/RN SDKs
43jay Nov 4, 2025
c2e4477
Move ReplayBreadcrumbConverter initialization after SDKOptions initia…
43jay Nov 4, 2025
ad8e402
Fix up DefaultReplayBreadcrumbConverterTest
43jay Nov 4, 2025
584df60
Make test names more meaningful
43jay Nov 4, 2025
9545155
Formatter / prettify
43jay Nov 4, 2025
8c9333b
Address reviewer comment
43jay Nov 4, 2025
bcb4efa
Make function parameters and class members final
43jay Nov 4, 2025
38b8919
Log WARNING or ERROR instead of DEBUG and fix logger scope
43jay Nov 4, 2025
b887de7
Introduce SENTRY_REPLAY_NETWORK_DETAILS TypeCheckHint
43jay Nov 4, 2025
adb0482
Reapply "Disable Network Detail extraction"
43jay Nov 4, 2025
a575db7
Fewer allocations when creating map of okhttp3 Header
43jay Nov 6, 2025
74c8c6b
Update javadoc
43jay Nov 6, 2025
85cda10
Custom #equals/#hashcode for http breadcrumbs
43jay Nov 6, 2025
3fc8376
More formatting
43jay Nov 6, 2025
0784c51
./gradlew apiDump
43jay Nov 6, 2025
9a80627
lint
43jay Nov 6, 2025
37a6b27
Add HTTP Request Trigger to sentry-samples-android app
43jay Oct 7, 2025
9b09be4
Update dsn for testing
43jay Oct 7, 2025
2ddca3b
Enable replay verbose logging
43jay Oct 7, 2025
10a52b4
Point dsn to sentry-android project
43jay Oct 7, 2025
d749c9a
Merge branch 'main' into 43jay/MOBILE-935
43jay Nov 7, 2025
2e6cf74
re-run ./gradlew apiDump after git merge main
43jay Nov 7, 2025
ec9985f
Format code
getsentry-bot Nov 7, 2025
71c1f93
Handle bad merge conflict resolution in sentry-samples
43jay Nov 7, 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 @@ -16,6 +16,7 @@
import io.sentry.NoOpCompositePerformanceCollector;
import io.sentry.NoOpConnectionStatusProvider;
import io.sentry.NoOpContinuousProfiler;
import io.sentry.NoOpReplayBreadcrumbConverter;
import io.sentry.NoOpSocketTagger;
import io.sentry.NoOpTransactionProfiler;
import io.sentry.NoopVersionDetector;
Expand Down Expand Up @@ -253,6 +254,13 @@ static void initializeIntegrationsAndProcessors(
options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options));
}

if (options.getReplayController().getBreadcrumbConverter()
instanceof NoOpReplayBreadcrumbConverter) {
options
.getReplayController()
.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter(options));
}

// 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 Expand Up @@ -406,7 +414,6 @@ static void installDefaultIntegrations(
if (isReplayAvailable) {
final ReplayIntegration replay =
new ReplayIntegration(context, CurrentDateProvider.getInstance());
replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter());
options.addIntegration(replay);
options.setReplayController(replay);
}
Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public final class io/sentry/android/replay/BuildConfig {
public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter {
public static final field $stable I
public fun <init> ()V
public fun <init> (Lio/sentry/SentryOptions;)V
public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
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
import io.sentry.SentryOptions.BeforeBreadcrumbCallback
import io.sentry.SpanDataConvention
import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS
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 {
public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter {
private var options: SentryOptions? = null

public constructor(options: SentryOptions) : this() {
// We modify options, so keep it around to make that explicit.
this.options = options
this.options?.beforeBreadcrumb = ReplayBeforeBreadcrumbCallback(options.beforeBreadcrumb)
}

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")
Expand All @@ -23,16 +39,68 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
}
}

/**
* Intercept the breadcrumb to process any Network Details data on the hint. Delegate to any
* user-provided callback to provide the actual breadcrumb to process.
*/
private inner class ReplayBeforeBreadcrumbCallback(
private val delegate: BeforeBreadcrumbCallback?
) : BeforeBreadcrumbCallback {
override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? {
val resultBreadcrumb =
if (delegate != null) {
delegate.execute(breadcrumb, hint)
} else {
breadcrumb
}

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

return resultBreadcrumb
}

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

return breadcrumbHint.get(SENTRY_REPLAY_NETWORK_DETAILS) as? NetworkRequestData
}
}

private var lastConnectivityState: String? = null

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

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 +110,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 @@ -53,8 +122,9 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
breadcrumbCategory = "navigation"
breadcrumbData["to"] =
when {
breadcrumb.data["state"] == "resumed" ->
breadcrumb.data["state"] == "resumed" -> {
(breadcrumb.data["screen"] as? String)?.substringAfterLast('.')
}
"to" in breadcrumb.data -> breadcrumb.data["to"] as? String
else -> null
} ?: return null
Expand All @@ -67,6 +137,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
?: breadcrumb.data["view.tag"]
?: breadcrumb.data["view.class"])
as? String ?: return null

breadcrumbData.putAll(breadcrumb.data)
}

Expand All @@ -75,18 +146,18 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
breadcrumbData["state"] =
when {
breadcrumb.data["action"] == "NETWORK_LOST" -> "offline"
"network_type" in breadcrumb.data ->
"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 @@ -105,6 +176,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
breadcrumbData.putAll(breadcrumb.data)
}
}

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

private fun Breadcrumb.isValidForRRWebSpan(): Boolean =
!(data["url"] as? String).isNullOrEmpty() &&
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

// 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
Expand All @@ -151,13 +228,54 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
}

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.value }

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.value }

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) {
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
Loading