Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
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 on lines +404 to +406
Copy link

Choose a reason for hiding this comment

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

Potential bug: A user-configured BeforeBreadcrumbCallback overwrites the SDK's replay converter during initialization, silently disabling network capture for replays.
  • Description: During SentryAndroid.init(), the DefaultReplayBreadcrumbConverter is set as the BeforeBreadcrumbCallback before the user's configuration callback is executed. If a user provides their own BeforeBreadcrumbCallback in the configuration, it replaces the SDK's converter. This breaks the replay feature's ability to capture network request data, as the logic in DefaultReplayBreadcrumbConverter is never called. The feature fails silently for any user following the common practice of setting a breadcrumb callback.

  • Suggested fix: Modify the initialization logic to chain the callbacks instead of overwriting. The DefaultReplayBreadcrumbConverter should be initialized with the user's callback, which is retrieved after the user's configuration has run. The converter would then execute the user's callback before its own logic.
    severity: 0.7, confidence: 0.99

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member

Choose a reason for hiding this comment

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

this is legit, so we probably have to move setting the beforeBreadcrumb callback over to initializeIntegrationsAndProcessors which is called after the user config. You can access the converter via options.getReplayController().getBreadcrumbConverter later on

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

nice one 🙈 thanks

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>()
Copy link

Choose a reason for hiding this comment

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

Bug: Memory Leak in Breadcrumb Data Storage

The httpBreadcrumbData map stores Breadcrumb objects as keys but lacks a cleanup mechanism. This causes breadcrumbs to accumulate indefinitely, leading to a memory leak.

Fix in Cursor Fix in Web

Copy link
Member

Choose a reason for hiding this comment

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

not sure if you want to use a ring buffer here, but if you do we already have an implementation in the SDK available: https://github.com/getsentry/sentry-java/blob/main/sentry/src/main/java/io/sentry/CircularFifoQueue.java

Copy link

Choose a reason for hiding this comment

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

Potential bug: The singleton's httpBreadcrumbData map is not thread-safe and grows indefinitely, leading to concurrency crashes and out-of-memory errors.
  • Description: The DefaultReplayBreadcrumbConverter is a singleton that uses a non-thread-safe mutableMapOf named httpBreadcrumbData. This leads to two issues. First, because OkHttp interceptors can run on concurrent threads, simultaneous writes to the map can cause ConcurrentModificationException crashes. Second, entries are added to the map for every HTTP request but are never removed. This causes unbounded memory growth in the long-lived singleton, eventually leading to an OutOfMemoryError.

  • Suggested fix: Replace mutableMapOf with a thread-safe collection like ConcurrentHashMap. Also, ensure entries are removed from the map after the corresponding breadcrumb data has been processed for the replay segment to prevent unbounded memory growth.
    severity: 0.9, confidence: 0.98

Did we get this right? 👍 / 👎 to inform future reviews.


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")
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 you're gonna clean these up, but if you wanna keep some logs please use options.logger as it will no-op in production builds

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) {
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
Copy link
Member

Choose a reason for hiding this comment

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

these are missing from the PR, but I assume they are just data holders so nothing fancy there :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yikes. adding now - and yes i just copied the naming + types from JS
NetworkRequestData
ReplayNetworkRequestOrResponseData

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)
Copy link
Member

Choose a reason for hiding this comment

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

we should probably still keep these because some of our customer may rely on them being present in the hint

// 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> {
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
Copy link
Member

@romtsn romtsn Oct 13, 2025

Choose a reason for hiding this comment

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

hm, is it not possible to consume/copy it? I think would be great if we could do that, given that the JS sdk does that too. Otherwise, it's probably not very helpful to just have the headers and some metadata

*/
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"
)
Copy link

Choose a reason for hiding this comment

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

Bug: Logging Overload and Network Data Loss

Debug logging statements (Android Log.d, println, Sentry logger INFO level) were committed, causing excessive log output. Separately, the DefaultReplayBreadcrumbConverter loses network data for Replay when a user's BeforeBreadcrumbCallback returns a new Breadcrumb instance, as the httpBreadcrumbData map lookup fails due to a key mismatch.

Additional Locations (5)

Fix in Cursor Fix in Web


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://[email protected]/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