-
-
Notifications
You must be signed in to change notification settings - Fork 460
feat(replay): Adding OkHttp Request/Response bodies #4796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b8555e2
ca28040
5397e9d
71ed70e
f2ce22e
201102a
724ec42
2d08e7b
ebc5ff3
122a8a6
6964a53
25c42c7
308072b
5836ef1
d9f8254
6b6f3fe
cadc529
465b6e5
6ab8e03
d3f1a24
9eba521
8deffe9
669c8a3
8b29cdd
4bc5b77
177ae0c
a204a40
6629e94
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice use of |
||
| 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" -> { | ||
|
|
@@ -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 { | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -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 -> { | ||
|
|
@@ -105,6 +131,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { | |
| breadcrumbData.putAll(breadcrumb.data) | ||
| } | ||
| } | ||
|
|
||
| return if (!breadcrumbCategory.isNullOrEmpty()) { | ||
| RRWebBreadcrumbEvent().apply { | ||
| timestamp = breadcrumb.timestamp.time | ||
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
-replaymodule from the build, then this would return aNoOpReplayBreadcrumbConverterimplementation which does not currently delegate to the user-definedbeforeBreadcrumbcallback. 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
DefaultReplayBreadcrumbConverterhere: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.