Skip to content

Commit bbc35bb

Browse files
43jaygetsentry-bot
andauthored
feat(replay): Adding OkHttp Request/Response bodies (#4796)
* Add HTTP Request Trigger to sentry-samples-android app * [replay] Make DefaultReplayBreadcrumbConverter the default BeforeBreadcrumbCallback to extract NetworkRequestData from Hint -> NetworkRequestData is landing on DefaultReplayBreadcrumbConverter via Hint.get(replay:networkDetails) * [replay] Add data classes for NetworkDetails These get set on the breadcrumb Hint, and then hang around in DefaultReplayBreadcrumbConverter until the replay segment has been sent off See https://github.com/getsentry/sentry-javascript/blob/632f0b953d99050c11b0edafb9f80b5f3ba88045/packages/replay-internal/src/types/performance.ts#L133-L140 https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/types/request.ts#L12 * [DNL] Force dashboard to show request/response bodies Manually set networkDetailHasUrls to pass this check https://github.com/getsentry/sentry-javascript/blob/d1646c8a281dd8795c5a6a3b8e18f2e7069e7fa9/packages/replay-internal/src/util/handleRecordingEmit.ts#L134 https://github.com/getsentry/sentry/blob/master/static/app/views/replays/detail/network/details/getOutputType.tsx#L33 https://github.com/getsentry/sentry/blob/master/static/app/views/replays/detail/network/details/content.tsx#L55-L56 * Move BeforeBreadcrumbCallback initialization to after user config added some unit tests: ./gradlew :sentry-android-replay:testDebugUnitTest --tests="*DefaultReplayBreadcrumbConverterTest*" * bugfix: Update Breadcrumb #hashcode to be consistent with #equals Breadcrumb.java has several timestamp fields: `timestamp: Date`, `timestampMs: Long`, `nanos: Long` `hashcode` was relying solely on `timestamp`, which can be null depending on which constructor was used. => Change to use getTimestamp as 1. this is what equals does (consistency) 2. getTimestamp initialises timestamp if null. * Initial NetworkDetails extraction logic Entrypoint is NetworkDetailCaptureUtils (initializeForUrl) called from SentryOkHttpInterceptor - common logic to handle checking sdk options. - Accept data from http client via NetworkBodyExtractor, NetworkHeaderExtractor interfaces that can be reused in future (if needed) Placeholder impl for req/resp bodies. From https://docs.sentry.io/platforms/javascript/session-replay/configuration/ - networkDetailAllowUrls, networkDetailDenyUrls, - networkCaptureBodies - networkRequestHeaders, networkResponseHeaders These SDKOptions don't exist yet => impl acts as if they do, but have not been enabled. * DefaultReplayBreadcrumbConverter properly manages NetworkRequestData entries Removes entry when creating RRWebSpanEvent Uses syncrhonized LinkedHashMap with impl to cap size of map (avoid memory bloat) * Extract bodies of okhttp requests/responses Replaces previous placeholder logic. Now NetworkBodyParser uses io.sentry.JsonObjectReader to extract body into JSONObject, JSONArray, with fallback to plain-text String (or nothing) * Check-in sentry-android-replay.api * Add additional http request types to sentry-samples app for testing One-shot bodies Form bodies plain text bodies binary bodies * Add body too large http request types to sentry-samples app * Properly handle content bodies that are too large based on cursor[bot] feedback - https://github.com/getsentry/sentry-java/pull/4796/files#r2466871208 * Disable Network Detail extraction Initialize the values in FAKE_OPTIONS so that implementation acts as if dev never turned it on. Leaves in the FAKE_OPTIONS variable to be easy to update reference to real `options` later on. * Modify ReplayBreadcrumbConverter API to avoid breaking flutter/RN SDKs use composition via optional constructor on DefaultReplayBreadcrumbConverter Instead of modifying the parent interface ReplayBreadcrumbConverter to force all subclasses to implement BeforeBreadcrumbCallback (breaking / harder to manage change) * Move ReplayBreadcrumbConverter initialization after SDKOptions initialization DefaultReplayBreadcrumbConverter depends on SDKOptions as it now needs to delegate to any user-provided BeforeBreadcrumbCallback * Custom #equals/#hashcode for http breadcrumbs review thread - https://github.com/getsentry/sentry-java/pull/4796/files#r2461102218 * ./gradlew apiDump --------- Co-authored-by: Sentry Github Bot <[email protected]>
1 parent 7c81e67 commit bbc35bb

File tree

20 files changed

+2180
-32
lines changed

20 files changed

+2180
-32
lines changed

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import io.sentry.NoOpCompositePerformanceCollector;
1717
import io.sentry.NoOpConnectionStatusProvider;
1818
import io.sentry.NoOpContinuousProfiler;
19+
import io.sentry.NoOpReplayBreadcrumbConverter;
1920
import io.sentry.NoOpSocketTagger;
2021
import io.sentry.NoOpTransactionProfiler;
2122
import io.sentry.NoopVersionDetector;
@@ -253,6 +254,13 @@ static void initializeIntegrationsAndProcessors(
253254
options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options));
254255
}
255256

257+
if (options.getReplayController().getBreadcrumbConverter()
258+
instanceof NoOpReplayBreadcrumbConverter) {
259+
options
260+
.getReplayController()
261+
.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter(options));
262+
}
263+
256264
// Check if the profiler was already instantiated in the app start.
257265
// We use the Android profiler, that uses a global start/stop api, so we need to preserve the
258266
// state of the profiler, and it's only possible retaining the instance.
@@ -406,7 +414,6 @@ static void installDefaultIntegrations(
406414
if (isReplayAvailable) {
407415
final ReplayIntegration replay =
408416
new ReplayIntegration(context, CurrentDateProvider.getInstance());
409-
replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter());
410417
options.addIntegration(replay);
411418
options.setReplayController(replay);
412419
}

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public final class io/sentry/android/replay/BuildConfig {
99
public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter {
1010
public static final field $stable I
1111
public fun <init> ()V
12+
public fun <init> (Lio/sentry/SentryOptions;)V
1213
public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent;
1314
}
1415

sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt

Lines changed: 133 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
package io.sentry.android.replay
22

33
import io.sentry.Breadcrumb
4+
import io.sentry.Hint
45
import io.sentry.ReplayBreadcrumbConverter
56
import io.sentry.SentryLevel
7+
import io.sentry.SentryOptions
8+
import io.sentry.SentryOptions.BeforeBreadcrumbCallback
69
import io.sentry.SpanDataConvention
10+
import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS
711
import io.sentry.rrweb.RRWebBreadcrumbEvent
812
import io.sentry.rrweb.RRWebEvent
913
import io.sentry.rrweb.RRWebSpanEvent
14+
import io.sentry.util.network.NetworkRequestData
15+
import java.util.Collections
1016
import kotlin.LazyThreadSafetyMode.NONE
1117

12-
public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
18+
public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter {
19+
private var options: SentryOptions? = null
20+
21+
public constructor(options: SentryOptions) : this() {
22+
// We modify options, so keep it around to make that explicit.
23+
this.options = options
24+
this.options?.beforeBreadcrumb = ReplayBeforeBreadcrumbCallback(options.beforeBreadcrumb)
25+
}
26+
1327
internal companion object {
28+
private const val MAX_HTTP_NETWORK_DETAILS = 32
1429
private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() }
30+
1531
private val supportedNetworkData =
1632
HashSet<String>().apply {
1733
add("status_code")
@@ -23,16 +39,68 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
2339
}
2440
}
2541

42+
/**
43+
* Intercept the breadcrumb to process any Network Details data on the hint. Delegate to any
44+
* user-provided callback to provide the actual breadcrumb to process.
45+
*/
46+
private inner class ReplayBeforeBreadcrumbCallback(
47+
private val delegate: BeforeBreadcrumbCallback?
48+
) : BeforeBreadcrumbCallback {
49+
override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? {
50+
val resultBreadcrumb =
51+
if (delegate != null) {
52+
delegate.execute(breadcrumb, hint)
53+
} else {
54+
breadcrumb
55+
}
56+
57+
resultBreadcrumb?.let { finalBreadcrumb ->
58+
extractNetworkRequestDataFromHint(finalBreadcrumb, hint)?.let { networkData ->
59+
httpNetworkDetails[finalBreadcrumb] = networkData
60+
}
61+
}
62+
63+
return resultBreadcrumb
64+
}
65+
66+
private fun extractNetworkRequestDataFromHint(
67+
breadcrumb: Breadcrumb,
68+
breadcrumbHint: Hint,
69+
): NetworkRequestData? {
70+
if (breadcrumb.type != "http" && breadcrumb.category != "http") {
71+
return null
72+
}
73+
74+
return breadcrumbHint.get(SENTRY_REPLAY_NETWORK_DETAILS) as? NetworkRequestData
75+
}
76+
}
77+
2678
private var lastConnectivityState: String? = null
2779

80+
private val httpNetworkDetails =
81+
Collections.synchronizedMap(
82+
object : LinkedHashMap<Breadcrumb, NetworkRequestData>() {
83+
override fun removeEldestEntry(
84+
eldest: MutableMap.MutableEntry<Breadcrumb, NetworkRequestData>?
85+
): Boolean {
86+
return size > MAX_HTTP_NETWORK_DETAILS
87+
}
88+
}
89+
)
90+
2891
override fun convert(breadcrumb: Breadcrumb): RRWebEvent? {
2992
var breadcrumbMessage: String? = null
30-
var breadcrumbCategory: String? = null
93+
val breadcrumbCategory: String?
3194
var breadcrumbLevel: SentryLevel? = null
3295
val breadcrumbData = mutableMapOf<String, Any?>()
96+
3397
when {
3498
breadcrumb.category == "http" -> {
35-
return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null
99+
return if (breadcrumb.isValidForRRWebSpan()) {
100+
breadcrumb.toRRWebSpanEvent()
101+
} else {
102+
null
103+
}
36104
}
37105

38106
breadcrumb.type == "navigation" && breadcrumb.category == "app.lifecycle" -> {
@@ -42,6 +110,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
42110
breadcrumb.type == "navigation" && breadcrumb.category == "device.orientation" -> {
43111
breadcrumbCategory = breadcrumb.category!!
44112
val position = breadcrumb.data["position"]
113+
45114
if (position == "landscape" || position == "portrait") {
46115
breadcrumbData["position"] = position
47116
} else {
@@ -53,8 +122,9 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
53122
breadcrumbCategory = "navigation"
54123
breadcrumbData["to"] =
55124
when {
56-
breadcrumb.data["state"] == "resumed" ->
125+
breadcrumb.data["state"] == "resumed" -> {
57126
(breadcrumb.data["screen"] as? String)?.substringAfterLast('.')
127+
}
58128
"to" in breadcrumb.data -> breadcrumb.data["to"] as? String
59129
else -> null
60130
} ?: return null
@@ -67,6 +137,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
67137
?: breadcrumb.data["view.tag"]
68138
?: breadcrumb.data["view.class"])
69139
as? String ?: return null
140+
70141
breadcrumbData.putAll(breadcrumb.data)
71142
}
72143

@@ -75,18 +146,18 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
75146
breadcrumbData["state"] =
76147
when {
77148
breadcrumb.data["action"] == "NETWORK_LOST" -> "offline"
78-
"network_type" in breadcrumb.data ->
149+
"network_type" in breadcrumb.data -> {
79150
if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) {
80151
breadcrumb.data["network_type"]
81152
} else {
82153
return null
83154
}
84-
155+
}
85156
else -> return null
86157
}
87158

88159
if (lastConnectivityState == breadcrumbData["state"]) {
89-
// debounce same state
160+
// Debounce same state
90161
return null
91162
}
92163

@@ -105,6 +176,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
105176
breadcrumbData.putAll(breadcrumb.data)
106177
}
107178
}
179+
108180
return if (!breadcrumbCategory.isNullOrEmpty()) {
109181
RRWebBreadcrumbEvent().apply {
110182
timestamp = breadcrumb.timestamp.time
@@ -120,29 +192,34 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
120192
}
121193
}
122194

123-
private fun Breadcrumb.isValidForRRWebSpan(): Boolean =
124-
!(data["url"] as? String).isNullOrEmpty() &&
195+
private fun Breadcrumb.isValidForRRWebSpan(): Boolean {
196+
return !(data["url"] as? String).isNullOrEmpty() &&
125197
SpanDataConvention.HTTP_START_TIMESTAMP in data &&
126198
SpanDataConvention.HTTP_END_TIMESTAMP in data
199+
}
127200

128-
private fun String.snakeToCamelCase(): String =
129-
replace(snakecasePattern) { it.value.last().toString().uppercase() }
201+
private fun String.snakeToCamelCase(): String {
202+
return replace(snakecasePattern) { it.value.last().toString().uppercase() }
203+
}
130204

131205
private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent {
132206
val breadcrumb = this
133207
val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP]
134208
val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP]
209+
135210
return RRWebSpanEvent().apply {
136211
timestamp = breadcrumb.timestamp.time
137212
op = "resource.http"
138213
description = breadcrumb.data["url"] as String
139-
// can be double if it was serialized to disk
214+
215+
// Can be double if it was serialized to disk
140216
startTimestamp =
141217
if (httpStartTimestamp is Double) {
142218
httpStartTimestamp / 1000.0
143219
} else {
144220
(httpStartTimestamp as Long) / 1000.0
145221
}
222+
146223
endTimestamp =
147224
if (httpEndTimestamp is Double) {
148225
httpEndTimestamp / 1000.0
@@ -151,13 +228,54 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
151228
}
152229

153230
val breadcrumbData = mutableMapOf<String, Any?>()
231+
232+
val networkDetailData = httpNetworkDetails.remove(breadcrumb)
233+
234+
// Add Network Details data when available
235+
networkDetailData?.let { networkData ->
236+
networkData.method?.let { breadcrumbData["method"] = it }
237+
networkData.statusCode?.let { breadcrumbData["statusCode"] = it }
238+
networkData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it }
239+
networkData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it }
240+
241+
networkData.request?.let { request ->
242+
val requestData = mutableMapOf<String, Any?>()
243+
request.size?.let { requestData["size"] = it }
244+
request.body?.let { requestData["body"] = it.value }
245+
246+
if (request.headers.isNotEmpty()) {
247+
requestData["headers"] = request.headers
248+
}
249+
250+
if (requestData.isNotEmpty()) {
251+
breadcrumbData["request"] = requestData
252+
}
253+
}
254+
255+
networkData.response?.let { response ->
256+
val responseData = mutableMapOf<String, Any?>()
257+
response.size?.let { responseData["size"] = it }
258+
response.body?.let { responseData["body"] = it.value }
259+
260+
if (response.headers.isNotEmpty()) {
261+
responseData["headers"] = response.headers
262+
}
263+
264+
if (responseData.isNotEmpty()) {
265+
breadcrumbData["response"] = responseData
266+
}
267+
}
268+
}
269+
270+
// Original breadcrumb http data
154271
for ((key, value) in breadcrumb.data) {
155272
if (key in supportedNetworkData) {
156-
breadcrumbData[
157-
key.replace("content_length", "body_size").substringAfter(".").snakeToCamelCase(),
158-
] = value
273+
val formattedKey =
274+
key.replace("content_length", "body_size").substringAfter(".").snakeToCamelCase()
275+
breadcrumbData[formattedKey] = value
159276
}
160277
}
278+
161279
data = breadcrumbData
162280
}
163281
}

0 commit comments

Comments
 (0)