Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -400,7 +400,11 @@ static void installDefaultIntegrations(
if (isReplayAvailable) {
final ReplayIntegration replay =
new ReplayIntegration(context, CurrentDateProvider.getInstance());
replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter());
DefaultReplayBreadcrumbConverter replayBreadcrumbConverter = new DefaultReplayBreadcrumbConverter(options.getBeforeBreadcrumb());
options.setBeforeBreadcrumb(
replayBreadcrumbConverter
);
Comment thread
43jay marked this conversation as resolved.
Outdated
replay.setBreadcrumbConverter(replayBreadcrumbConverter);
options.addIntegration(replay);
options.setReplayController(replay);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package io.sentry.android.replay

import android.util.Log
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.rrweb.RRWebBreadcrumbEvent
import io.sentry.rrweb.RRWebEvent
import io.sentry.rrweb.RRWebSpanEvent
import io.sentry.util.network.NetworkRequestData
import kotlin.LazyThreadSafetyMode.NONE

public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
public open class DefaultReplayBreadcrumbConverter(
private val userBeforeBreadcrumbCallback: BeforeBreadcrumbCallback? = null
) : ReplayBreadcrumbConverter, SentryOptions.BeforeBreadcrumbCallback {
internal companion object {
private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() }
private val supportedNetworkData =
Expand All @@ -24,10 +31,11 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
}

private var lastConnectivityState: String? = null
private val httpBreadcrumbData = mutableMapOf<Breadcrumb, NetworkRequestData>()
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
43jay marked this conversation as resolved.
Outdated

override fun convert(breadcrumb: Breadcrumb): RRWebEvent? {
var breadcrumbMessage: String? = null
var breadcrumbCategory: String? = null
var breadcrumbCategory: String?
var breadcrumbLevel: SentryLevel? = null
val breadcrumbData = mutableMapOf<String, Any?>()
when {
Expand Down Expand Up @@ -120,10 +128,62 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
}
}

private fun Breadcrumb.isValidForRRWebSpan(): Boolean =
!(data["url"] as? String).isNullOrEmpty() &&
SpanDataConvention.HTTP_START_TIMESTAMP in data &&
SpanDataConvention.HTTP_END_TIMESTAMP in data
/**
* By default, ReplayIntegration provides its own BeforeBreadcrumbCallback,
* delegating to user-provided callback (if exists).
*/
override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? {
Log.d("SentryNetwork", "SentryNetwork: BeforeBreadcrumbCallback - Hint: $hint, Breadcrumb: $breadcrumb")
return userBeforeBreadcrumbCallback?.let {
it.execute(breadcrumb, hint)?.also { processedBreadcrumb ->
extractNetworkRequestDataFromHint(processedBreadcrumb, hint)?.let { networkData ->
httpBreadcrumbData[processedBreadcrumb] = networkData
}
}
} ?: run {
// No user callback - store hint and return original breadcrumb
extractNetworkRequestDataFromHint(breadcrumb, hint)?.let { networkData ->
httpBreadcrumbData[breadcrumb] = networkData
}
breadcrumb
}
}

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

// First try to get the structured network data from the hint
val networkDetails = breadcrumbHint.get("replay:networkDetails") as? NetworkRequestData
if (networkDetails != null) {
Log.d("SentryNetwork", "SentryNetwork: Found structured NetworkRequestData in hint: $networkDetails")
Comment thread
43jay marked this conversation as resolved.
Outdated
return networkDetails
}

Log.d("SentryNetwork", "SentryNetwork: No structured NetworkRequestData found on hint")
return null
}

private fun Breadcrumb.isValidForRRWebSpan(): Boolean {
val url = data["url"] as? String
val hasStartTimestamp = SpanDataConvention.HTTP_START_TIMESTAMP in data
val hasEndTimestamp = SpanDataConvention.HTTP_END_TIMESTAMP in data

val urlValid = !url.isNullOrEmpty()
val isValid = urlValid && hasStartTimestamp && hasEndTimestamp

val reasons = mutableListOf<String>()
if (!urlValid) reasons.add("missing or empty URL")
if (!hasStartTimestamp) reasons.add("missing start timestamp")
if (!hasEndTimestamp) reasons.add("missing end timestamp")

Log.d("SentryReplay", "Breadcrumb RRWeb span validation: ${if (isValid) "VALID" else "INVALID"}" +
if (!isValid) " (${reasons.joinToString(", ")})" else "" +
" - URL: ${url ?: "null"}, Category: ${category}")

return isValid
}

private fun String.snakeToCamelCase(): String =
replace(snakecasePattern) { it.value.last().toString().uppercase() }
Expand All @@ -132,6 +192,14 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
val breadcrumb = this
val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP]
val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP]

// Get the NetworkRequestData if available
val networkRequestData = httpBreadcrumbData[breadcrumb]

Log.d("SentryNetwork", "SentryNetwork: convert(breadcrumb=${breadcrumb.type}) httpBreadcrumbData map size: ${httpBreadcrumbData.size}, " +
"contains current breadcrumb: ${httpBreadcrumbData.containsKey(breadcrumb)}, " +
"network data for current: ${httpBreadcrumbData[breadcrumb]}")

return RRWebSpanEvent().apply {
timestamp = breadcrumb.timestamp.time
op = "resource.http"
Expand All @@ -151,13 +219,50 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
}

val breadcrumbData = mutableMapOf<String, Any?>()

// Add data from NetworkRequestData if available
if (networkRequestData != null) {
networkRequestData.method?.let { breadcrumbData["method"] = it }
networkRequestData.statusCode?.let { breadcrumbData["statusCode"] = it }
networkRequestData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it }
networkRequestData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it }

// Add request and response data if available
networkRequestData.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
}
}

networkRequestData.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 data processing
// TODO: Remove if superceded by more detailed data (above).
for ((key, value) in breadcrumb.data) {
if (key in supportedNetworkData) {
Comment thread
romtsn marked this conversation as resolved.
breadcrumbData[
key.replace("content_length", "body_size").substringAfter(".").snakeToCamelCase(),
] = value
}
}


data = breadcrumbData
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import io.sentry.util.PropagationTargetsUtils
import io.sentry.util.SpanUtils
import io.sentry.util.TracingUtils
import io.sentry.util.UrlUtils
import io.sentry.util.network.NetworkRequestData
Comment thread
43jay marked this conversation as resolved.
import io.sentry.util.network.ReplayNetworkRequestOrResponse
import java.io.IOException
import okhttp3.Interceptor
import okhttp3.Request
Expand Down Expand Up @@ -172,18 +174,30 @@ public open class SentryOkHttpInterceptor(
startTimestamp: Long,
) {
val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code)

// Track request and response body sizes for the breadcrumb
var requestBodySize: Long? = null
var responseBodySize: Long? = null

request.body?.contentLength().ifHasValidLength {
breadcrumb.setData("http.request_content_length", it)
requestBodySize = it
}

val hint = Hint().also { it.set(OKHTTP_REQUEST, request) }
response?.let {
it.body?.contentLength().ifHasValidLength { responseBodySize ->
breadcrumb.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, responseBodySize)
}
response?.body?.contentLength().ifHasValidLength {
breadcrumb.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it)
responseBodySize = it
}

hint[OKHTTP_RESPONSE] = it
val hint = Hint().also {
// Set the structured network data for replay
val networkData = createNetworkRequestData(request, response, requestBodySize, responseBodySize)
it.set("replay:networkDetails", networkData)

// it.set(OKHTTP_REQUEST, request)
Comment thread
43jay marked this conversation as resolved.
Outdated
// response?.let { resp -> it[OKHTTP_RESPONSE] = resp }
}

// needs this as unix timestamp for rrweb
breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp)
breadcrumb.setData(
Expand All @@ -194,6 +208,116 @@ public open class SentryOkHttpInterceptor(
scopes.addBreadcrumb(breadcrumb, hint)
}

/**
* Extracts headers from OkHttp Headers object into a map
*/
private fun okhttp3.Headers.toMap(): Map<String, String> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

wondering if we can make this potentially faster with less allocations, e.g.:

private fun okhttp3.Headers.toMap(): Map<String, String> {
  val headers = LinkedHashMap<String, String>(size)
  for (i in 0 until size) {
    headers[name(i)] = value(i)
  }
  return headers
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

still pending

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

done

val headers = mutableMapOf<String, String>()
for (name in names()) {
headers[name] = get(name) ?: ""
}
return headers
}

/**
* Extracts body metadata from OkHttp RequestBody or ResponseBody
* Note: We don't consume the actual body stream to avoid interfering with the request/response
Comment thread
43jay marked this conversation as resolved.
Outdated
*/
private fun extractBodyMetadata(
contentLength: Long?,
contentType: okhttp3.MediaType?
): Pair<Long?, Any?> {
val bodySize = contentLength?.takeIf { it >= 0 }
val bodyInfo = if (contentLength != null && contentLength != 0L) {
mapOf(
"contentType" to contentType?.toString(),
"hasBody" to true
)
} else null

return bodySize to bodyInfo
}

/**
* Creates a NetworkRequestData object from the request and response
*/
private fun createNetworkRequestData(
request: Request,
response: Response?,
requestBodySize: Long?,
responseBodySize: Long?
): NetworkRequestData {
// Log the incoming request details
println("SentryNetwork: Creating NetworkRequestData for: ${request.method} ${request.url}")
scopes.options.logger.log(
io.sentry.SentryLevel.INFO,
"SentryNetwork: Creating NetworkRequestData for: ${request.method} ${request.url}"
)

// Extract request data
val requestHeaders = request.headers.toMap()
val (reqBodySize, reqBodyInfo) = extractBodyMetadata(
request.body?.contentLength(),
request.body?.contentType()
)

scopes.options.logger.log(
io.sentry.SentryLevel.INFO,
"SentryNetwork: Request - Headers count: ${requestHeaders.size}, Body size: $reqBodySize, Body info: $reqBodyInfo"
)
Comment thread
43jay marked this conversation as resolved.
Outdated

val requestData = ReplayNetworkRequestOrResponse(
reqBodySize,
reqBodyInfo,
requestHeaders
)

// Extract response data if available
val responseData = response?.let {
val responseHeaders = it.headers.toMap()
val (respBodySize, respBodyInfo) = extractBodyMetadata(
it.body?.contentLength(),
it.body?.contentType()
)

scopes.options.logger.log(
io.sentry.SentryLevel.INFO,
"SentryNetwork: Response - Status: ${it.code}, Headers count: ${responseHeaders.size}, Body size: $respBodySize, Body info: $respBodyInfo"
)

ReplayNetworkRequestOrResponse(
respBodySize,
respBodyInfo,
responseHeaders
)
}

// Determine final body sizes (prefer the explicit sizes passed in)
val finalResponseBodySize = response?.let {
val (respBodySize, _) = extractBodyMetadata(
it.body?.contentLength(),
it.body?.contentType()
)
responseBodySize ?: respBodySize
}

val networkData = NetworkRequestData(
request.method,
response?.code,
requestBodySize ?: reqBodySize,
finalResponseBodySize,
requestData,
responseData
)

scopes.options.logger.log(
io.sentry.SentryLevel.INFO,
"SentryNetwork: Created NetworkRequestData: $networkData"
)

return networkData
}

private fun finishSpan(
span: ISpan?,
request: Request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,17 @@
<activity android:name=".FrameDataForSpansActivity"
android:exported="false"/>

<activity android:name=".TriggerHttpRequestActivity"
android:exported="false"/>

<!-- NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard-->
<meta-data android:name="io.sentry.dsn" android:value="https://1053864c67cc410aa1ffc9701bd6f93d@o447951.ingest.us.sentry.io/5428559" />

<!-- how to enable Sentry's debug mode-->
<meta-data android:name="io.sentry.debug" android:value="${sentryDebug}" />

<!-- how to disable verbose logging of the session replay feature-->
<meta-data android:name="io.sentry.session-replay.debug" android:value="false" />
<meta-data android:name="io.sentry.session-replay.debug" android:value="true" />

<!-- how to set a custom debug level-->
<!-- <meta-data android:name="io.sentry.debug.level" android:value="info" />-->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,9 @@ public void run() {
});
});

binding.openHttpRequestActivity.setOnClickListener(
view -> startActivity(new Intent(this, TriggerHttpRequestActivity.class)));

Sentry.logger().log(SentryLogLevel.INFO, "Creating content view");
setContentView(binding.getRoot());

Expand Down
Loading