diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 70c76c72824..bb5cc67bd72 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -19,6 +19,7 @@ import io.sentry.NoOpSocketTagger; import io.sentry.NoOpTransactionProfiler; import io.sentry.NoopVersionDetector; +import io.sentry.ReplayBreadcrumbConverter; import io.sentry.ScopeType; import io.sentry.SendFireAndForgetEnvelopeSender; import io.sentry.SendFireAndForgetOutboxSender; @@ -247,6 +248,13 @@ static void initializeIntegrationsAndProcessors( options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options)); } + ReplayBreadcrumbConverter replayBreadcrumbConverter = options.getReplayController().getBreadcrumbConverter(); + replayBreadcrumbConverter.setUserBeforeBreadcrumbCallback(options.getBeforeBreadcrumb()); + options.setBeforeBreadcrumb( + replayBreadcrumbConverter + ); + + // 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. diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 5d6df28f7b3..1cbff1e2a88 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -10,6 +10,8 @@ public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sent public static final field $stable I public fun ()V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; + public fun execute (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)Lio/sentry/Breadcrumb; + public fun setUserBeforeBreadcrumbCallback (Lio/sentry/SentryOptions$BeforeBreadcrumbCallback;)V } public final class io/sentry/android/replay/GeneratedVideo { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 058417ed2a1..450d55e9f79 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -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().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().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( + object : LinkedHashMap() { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry? + ): 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() + 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() + + 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() + 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() + 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) { - 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 } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index a12ae043154..b2ae3a7eb33 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -1,14 +1,21 @@ package io.sentry.android.replay import io.sentry.Breadcrumb +import io.sentry.Hint import io.sentry.SentryLevel +import io.sentry.SentryOptions import io.sentry.SpanDataConvention import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebSpanEvent +import io.sentry.util.network.NetworkBody +import io.sentry.util.network.NetworkRequestData +import io.sentry.util.network.ReplayNetworkRequestOrResponse import java.util.Date import junit.framework.TestCase.assertEquals import kotlin.test.Test +import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame class DefaultReplayBreadcrumbConverterTest { class Fixture { @@ -318,4 +325,203 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(SentryLevel.ERROR, rrwebEvent.level) assertEquals("shiet", rrwebEvent.data!!["stuff"]) } + + // BeforeBreadcrumbCallback delegation tests + + @Test + fun `returned breadcrumb is not modified when no user BeforeBreadcrumbCallback is provided`() { + val converter = fixture.getSut() + val breadcrumb = + Breadcrumb(Date()).apply { + message = "test message" + category = "test.category" + } + val hint = Hint() + + converter.setUserBeforeBreadcrumbCallback(null) + + val result = converter.execute(breadcrumb, hint) + + assertSame(breadcrumb, result) + } + + @Test + fun `returned breadcrumb is modified according to user provided BeforeBreadcrumbCallback`() { + val converter = fixture.getSut() + val originalBreadcrumb = + Breadcrumb(Date()).apply { + message = "original message" + category = "original.category" + } + val userModifiedBreadcrumb = + Breadcrumb(Date()).apply { + message = "modified message" + category = "modified.category" + } + val hint = Hint() + + val userBeforeBreadcrumbCallback = + SentryOptions.BeforeBreadcrumbCallback { _, _ -> userModifiedBreadcrumb } + converter.setUserBeforeBreadcrumbCallback(userBeforeBreadcrumbCallback) + + val result = converter.execute(originalBreadcrumb, hint) + + assertSame(userModifiedBreadcrumb, result) + } + + @Test + fun `returns null when user BeforeBreadcrumbCallback returns null`() { + val converter = fixture.getSut() + val breadcrumb = + Breadcrumb(Date()).apply { + message = "test message" + category = "test.category" + } + val hint = Hint() + + val userCallback = SentryOptions.BeforeBreadcrumbCallback { _, _ -> null } + converter.setUserBeforeBreadcrumbCallback(userCallback) + + val result = converter.execute(breadcrumb, hint) + + assertNull(result) + } + + @Test + fun `network data is extracted from hint for http breadcrumbs with user callback`() { + val converter = fixture.getSut() + val httpBreadcrumb = + Breadcrumb(Date()).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + } + + val networkData = + NetworkRequestData( + "GET", + 200, + 100L, + 500L, + ReplayNetworkRequestOrResponse(100L, null, mapOf("Content-Type" to "application/json")), + ReplayNetworkRequestOrResponse(500L, null, mapOf("Content-Type" to "application/json")), + ) + val hint = Hint() + hint.set("replay:networkDetails", networkData) + + val userCallback = SentryOptions.BeforeBreadcrumbCallback { b, _ -> b } + converter.setUserBeforeBreadcrumbCallback(userCallback) + + val result = converter.execute(httpBreadcrumb, hint) + + assertSame(httpBreadcrumb, result) + } + + @Test + fun `network data is extracted from hint for http breadcrumbs without user callback`() { + val converter = fixture.getSut() + val httpBreadcrumb = + Breadcrumb(Date()).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + } + + val networkData = + NetworkRequestData( + "POST", + 201, + 200L, + 400L, + ReplayNetworkRequestOrResponse( + 200L, + NetworkBody.fromJsonObject(mapOf("body" to "request")), + mapOf(), + ), + ReplayNetworkRequestOrResponse( + 400L, + NetworkBody.fromJsonObject(mapOf("body" to "response")), + mapOf(), + ), + ) + val hint = Hint() + hint.set("replay:networkDetails", networkData) + + converter.setUserBeforeBreadcrumbCallback(null) + + val result = converter.execute(httpBreadcrumb, hint) + + assertSame(httpBreadcrumb, result) + } + + @Test + fun `setUserBeforeBreadcrumbCallback updates the callback`() { + val converter = fixture.getSut() + val breadcrumb = Breadcrumb(Date()).apply { message = "test" } + val hint = Hint() + + // First callback modifies the message + val firstCallback = + SentryOptions.BeforeBreadcrumbCallback { b, _ -> + b.message = "modified by first" + b + } + converter.setUserBeforeBreadcrumbCallback(firstCallback) + var result = converter.execute(breadcrumb, hint) + assertEquals("modified by first", result?.message) + + // Second callback modifies differently + val secondCallback = + SentryOptions.BeforeBreadcrumbCallback { b, _ -> + b.message = "modified by second" + b + } + converter.setUserBeforeBreadcrumbCallback(secondCallback) + + breadcrumb.message = "test" // Reset + result = converter.execute(breadcrumb, hint) + assertEquals("modified by second", result?.message) + } + + @Test + fun `user callback receives same breadcrumb and hint objects`() { + val converter = fixture.getSut() + val breadcrumb = Breadcrumb(Date()).apply { message = "test" } + val hint = Hint() + + var capturedBreadcrumb: Breadcrumb? = null + var capturedHint: Hint? = null + + val capturingCallback = + SentryOptions.BeforeBreadcrumbCallback { b, h -> + capturedBreadcrumb = b + capturedHint = h + b + } + converter.setUserBeforeBreadcrumbCallback(capturingCallback) + + converter.execute(breadcrumb, hint) + + assertSame(breadcrumb, capturedBreadcrumb) + assertSame(hint, capturedHint) + } + + @Test + fun `non-http breadcrumbs are unaltered by network detail data extraction`() { + val converter = fixture.getSut() + val navigationBreadcrumb = + Breadcrumb(Date()).apply { + type = "navigation" + category = "navigation" + } + val hint = Hint() + hint.set("replay:networkDetails", NetworkRequestData("GET", 200, null, null, null, null)) + + converter.setUserBeforeBreadcrumbCallback(null) + + val result = converter.execute(navigationBreadcrumb, hint) + + assertSame(navigationBreadcrumb, result) + // Network data extraction only happens for http breadcrumbs + } } diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index be1ee1caf37..e12e819f893 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -9,6 +9,7 @@ import io.sentry.ISpan import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.SentryReplayOptions import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TypeCheckHint.OKHTTP_REQUEST @@ -21,9 +22,14 @@ 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.NetworkBody +import io.sentry.util.network.NetworkBodyParser +import io.sentry.util.network.NetworkDetailCaptureUtils +import io.sentry.util.network.NetworkRequestData import java.io.IOException import okhttp3.Interceptor import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response /** @@ -53,6 +59,17 @@ public open class SentryOkHttpInterceptor( SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-okhttp", BuildConfig.VERSION_NAME) } + + /** + * Fake options for testing network detail capture + */ + private val FAKE_OPTIONS = object { + val networkDetailAllowUrls: Array = emptyArray() + val networkDetailDenyUrls: Array = emptyArray() + val networkCaptureBodies: Boolean = false + val networkRequestHeaders: Array = emptyArray() + val networkResponseHeaders: Array = emptyArray() + } } public constructor() : this(ScopesAdapter.getInstance()) @@ -97,6 +114,13 @@ public open class SentryOkHttpInterceptor( var response: Response? = null var code: Int? = null + val networkDetailData = NetworkDetailCaptureUtils.initializeForUrl( + request.url.toString(), + request.method, + FAKE_OPTIONS.networkDetailAllowUrls, + FAKE_OPTIONS.networkDetailDenyUrls, + ) + try { val requestBuilder = request.newBuilder() @@ -120,6 +144,40 @@ public open class SentryOkHttpInterceptor( } } + val requestContentLength = request.body?.contentLength() + + networkDetailData?.setRequestDetails( + NetworkDetailCaptureUtils.createRequest( + request, + requestContentLength, + FAKE_OPTIONS.networkCaptureBodies, + { req -> + req.body?.let { originalBody -> + try { + val buffer = okio.Buffer() + originalBody.writeTo(buffer) + val bodyBytes = buffer.readByteArray() + + // Create fresh RequestBody and update the request being built + val newRequestBody = bodyBytes.toRequestBody(originalBody.contentType()) + requestBuilder.method(request.method, newRequestBody) + + // Parse the buffered bytes into NetworkBody for capture + safeExtractRequestBody(bodyBytes, originalBody.contentType()) + } catch (e: Exception) { + scopes.options.logger.log( + io.sentry.SentryLevel.DEBUG, + "Failed to buffer request body for network detail capture: ${e.message}", + ) + null + } + } + }, + FAKE_OPTIONS.networkRequestHeaders, + { req: Request -> req.headers.toMap() }, + ) + ) + request = requestBuilder.build() response = chain.proceed(request) code = response.code @@ -153,11 +211,31 @@ public open class SentryOkHttpInterceptor( // this only works correctly if SentryOkHttpInterceptor is the last one in the chain okHttpEvent?.setRequest(request) + response?.let { + networkDetailData?.setResponseDetails( + it.code, + NetworkDetailCaptureUtils.createResponse( + it, + it.body?.contentLength(), + FAKE_OPTIONS.networkCaptureBodies, + { resp: Response -> resp.extractResponseBody() }, + FAKE_OPTIONS.networkResponseHeaders, + { resp: Response -> resp.headers.toMap() }, + ), + ) + } + finishSpan(span, request, response, isFromEventListener, okHttpEvent) // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call if (!isFromEventListener) { - sendBreadcrumb(request, code, response, startTimestamp) + sendBreadcrumb( + request, + code, + response, + startTimestamp, + networkDetailData, + ) } } } @@ -170,20 +248,29 @@ public open class SentryOkHttpInterceptor( code: Int?, response: Response?, startTimestamp: Long, + networkDetailData: NetworkRequestData? ) { val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) + + // Track request and response body sizes for the breadcrumb request.body?.contentLength().ifHasValidLength { breadcrumb.setData("http.request_content_length", 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) + } + + val hint = + Hint().also { + it.set(OKHTTP_REQUEST, request) + response?.let { resp -> it[OKHTTP_RESPONSE] = resp } + + if (networkDetailData != null) { + it.set("replay:networkDetails", networkDetailData) + } } - hint[OKHTTP_RESPONSE] = it - } // needs this as unix timestamp for rrweb breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) breadcrumb.setData( @@ -194,6 +281,82 @@ public open class SentryOkHttpInterceptor( scopes.addBreadcrumb(breadcrumb, hint) } + /** Extracts headers from OkHttp Headers object into a map */ + private fun okhttp3.Headers.toMap(): Map { + val headers = mutableMapOf() + for (name in names()) { + headers[name] = get(name) ?: "" + } + return headers + } + + /** + * Extracts NetworkBody from already buffered request body data. + */ + private fun safeExtractRequestBody( + bufferedBody: ByteArray?, + contentType: okhttp3.MediaType?, + ): NetworkBody? { + if (bufferedBody == null) { + return null + } + + try { + val contentTypeString = contentType?.toString() + val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE + val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" + + return NetworkBodyParser.fromBytes( + bufferedBody, + contentTypeString, + charset, + maxBodySize, + scopes.options, + ) + } catch (e: Exception) { + scopes.options.logger.log( + io.sentry.SentryLevel.DEBUG, + "Failed to parse buffered request body: ${e.message}", + ) + return null + } + } + + /** Extracts the body content from an OkHttp Response safely */ + private fun Response.extractResponseBody(): NetworkBody? { + return body?.let { responseBody -> + try { + val contentType = responseBody.contentType() + val contentTypeString = contentType?.toString() + val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE + + val contentLength = responseBody.contentLength() + if (contentLength > maxBodySize * 2) { + return NetworkBody.fromString("[Response body too large: $contentLength bytes]") + } + + // Peek at the body (doesn't consume it) + val peekBody = peekBody(maxBodySize.toLong()) + val bodyBytes = peekBody.bytes() + + val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" + return NetworkBodyParser.fromBytes( + bodyBytes, + contentTypeString, + charset, + maxBodySize, + scopes.options, + ) + } catch (e: Exception) { + scopes.options.logger.log( + io.sentry.SentryLevel.DEBUG, + "Failed to read http response body for Network Details: ${e.message}", + ) + null + } + } + } + private fun finishSpan( span: ISpan?, request: Request, diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 65445d85706..4653617c68d 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -75,6 +75,9 @@ + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 25907655f7f..5a62ed607fc 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -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()); diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java new file mode 100644 index 00000000000..c23a23deb76 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java @@ -0,0 +1,622 @@ +package io.sentry.samples.android; + +import android.os.Bundle; +import android.text.method.ScrollingMovementMethod; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import io.sentry.HttpStatusCodeRange; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.okhttp.SentryOkHttpEventListener; +import io.sentry.okhttp.SentryOkHttpInterceptor; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.json.JSONObject; + +public class TriggerHttpRequestActivity extends AppCompatActivity { + + private EditText urlInput; + private TextView requestDisplay; + private TextView responseDisplay; + private ProgressBar loadingIndicator; + private Button getButton; + private Button postButton; + private Button formButton; + private Button binaryButton; + private Button stringButton; + private Button oneShotButton; + private Button largeTextButton; + private Button largeBinaryButton; + private Button clearButton; + + private OkHttpClient okHttpClient; + private SimpleDateFormat dateFormat; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_trigger_http_request); + + dateFormat = new SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()); + + initializeViews(); + setupOkHttpClient(); + setupClickListeners(); + } + + private void initializeViews() { + urlInput = findViewById(R.id.url_input); + requestDisplay = findViewById(R.id.request_display); + responseDisplay = findViewById(R.id.response_display); + loadingIndicator = findViewById(R.id.loading_indicator); + getButton = findViewById(R.id.trigger_get_request); + postButton = findViewById(R.id.trigger_post_request); + formButton = findViewById(R.id.trigger_form_request); + binaryButton = findViewById(R.id.trigger_binary_request); + stringButton = findViewById(R.id.trigger_string_request); + oneShotButton = findViewById(R.id.trigger_oneshot_request); + largeTextButton = findViewById(R.id.trigger_large_text_request); + largeBinaryButton = findViewById(R.id.trigger_large_binary_request); + clearButton = findViewById(R.id.clear_display); + + requestDisplay.setMovementMethod(new ScrollingMovementMethod()); + responseDisplay.setMovementMethod(new ScrollingMovementMethod()); + } + + private void setupOkHttpClient() { + // OkHttpClient with Sentry integration for monitoring HTTP requests + okHttpClient = new OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + // performance monitoring +// .eventListener(new SentryOkHttpEventListener()) + // breadcrumbs and failed request capture + .addInterceptor(new SentryOkHttpInterceptor()) + .build(); + } + + private void setupClickListeners() { + getButton.setOnClickListener(v -> performGetRequest()); + postButton.setOnClickListener(v -> performJsonRequest()); + formButton.setOnClickListener(v -> performFormUrlencodedRequest()); + binaryButton.setOnClickListener(v -> performOctetStreamRequest()); + stringButton.setOnClickListener(v -> performTextPlainRequest()); + oneShotButton.setOnClickListener(v -> performOneShotJsonRequest()); + largeTextButton.setOnClickListener(v -> performLargeTextPlainRequest()); + largeBinaryButton.setOnClickListener(v -> performLargeOctetStreamRequest()); + clearButton.setOnClickListener(v -> clearDisplays()); + } + + private void performGetRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + Request request = new Request.Builder() + .url(url) + .get() + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Accept", "application/json") + .build(); + + displayRequest("GET", request); + executeRequest(request); + } + + private void performJsonRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + JSONObject json = new JSONObject(); + json.put("request_type", "POST_JSON"); + json.put("button_clicked", "POST JSON"); + json.put("message", "Hello from Sentry Android Sample"); + json.put("timestamp", System.currentTimeMillis()); + json.put("device", android.os.Build.MODEL); + + RequestBody body = RequestBody.create( + json.toString(), + MediaType.get("application/json; charset=utf-8") + ); + + Request request = new Request.Builder() + .url(url) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .addHeader("X-Request-Type", "POST_JSON") + .build(); + + displayRequest("POST", request, json.toString(2)); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void executeRequest(Request request) { + showLoading(true); + + okHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Sentry.captureException(e); + runOnUiThread(() -> { + showLoading(false); + displayResponse( + "ERROR", + null, + "Request failed: " + e.getMessage(), + 0 + ); + }); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + final long startTime = System.currentTimeMillis(); + final int statusCode = response.code(); + final String statusMessage = response.message(); + ResponseBody responseBody = response.body(); + String body = ""; + + try { + if (responseBody != null) { + body = responseBody.string(); + } + } catch (IOException e) { + body = "Error reading response body: " + e.getMessage(); + Sentry.captureException(e); + } + + final long responseTime = System.currentTimeMillis() - startTime; + final String finalBody = body; + + runOnUiThread(() -> { + showLoading(false); + displayResponse(statusMessage, statusCode, finalBody, responseTime); + }); + + response.close(); + } + }); + } + + private void displayRequest(String method, Request request) { + displayRequest(method, request, null); + } + + private void displayRequest(String method, Request request, String body) { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(getCurrentTime()).append("]\n"); + sb.append("━━━━━━━━━━━━━━━━━━━━━━━━\n"); + sb.append("METHOD: ").append(method).append("\n"); + sb.append("URL: ").append(request.url()).append("\n\n"); + sb.append("HEADERS:\n"); + + for (int i = 0; i < request.headers().size(); i++) { + sb.append(" ").append(request.headers().name(i)).append(": ") + .append(request.headers().value(i)).append("\n"); + } + + if (body != null && !body.isEmpty()) { + sb.append("\nBODY:\n").append(body).append("\n"); + } + + sb.append("━━━━━━━━━━━━━━━━━━━━━━━━"); + + requestDisplay.setText(sb.toString()); + } + + private void displayResponse(String status, Integer code, String body, long responseTime) { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(getCurrentTime()).append("]\n"); + sb.append("━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + if (code != null) { + sb.append("STATUS: ").append(code).append(" ").append(status).append("\n"); + sb.append("RESPONSE TIME: ").append(responseTime).append("ms\n\n"); + } else { + sb.append("STATUS: ").append(status).append("\n\n"); + } + + if (body != null && !body.isEmpty()) { + try { + if (body.trim().startsWith("{") || body.trim().startsWith("[")) { + JSONObject json = new JSONObject(body); + sb.append("BODY (JSON):\n").append(json.toString(2)); + } else { + sb.append("BODY:\n").append(body); + } + } catch (Exception e) { + sb.append("BODY:\n").append(body); + } + } + + sb.append("\n━━━━━━━━━━━━━━━━━━━━━━━━"); + + responseDisplay.setText(sb.toString()); + } + + private void clearDisplays() { + requestDisplay.setText("No request yet..."); + responseDisplay.setText("No response yet..."); + } + + private String getUrl() { + String url = urlInput.getText().toString().trim(); + if (url.isEmpty()) { + return "https://api.github.com/users/getsentry"; + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + url; + } + return url; + } + + private void showLoading(boolean show) { + loadingIndicator.setVisibility(show ? View.VISIBLE : View.GONE); + getButton.setEnabled(!show); + postButton.setEnabled(!show); + formButton.setEnabled(!show); + binaryButton.setEnabled(!show); + stringButton.setEnabled(!show); + oneShotButton.setEnabled(!show); + largeTextButton.setEnabled(!show); + largeBinaryButton.setEnabled(!show); + } + + private void performFormUrlencodedRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Create URL-encoded form data + String formData = "request_type=POST_FORM_URLENCODED&" + + "button_clicked=POST%20Form&" + + "username=sentry_android_user&" + + "email=test@example.com&" + + "message=Hello%20from%20Android%20Sample%20Form%20Request&" + + "timestamp=" + System.currentTimeMillis() + "&" + + "device=" + android.os.Build.MODEL.replace(" ", "%20"); + + RequestBody body = RequestBody.create( + formData, + MediaType.get("application/x-www-form-urlencoded") + ); + + Request request = new Request.Builder() + .url(url) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .addHeader("X-Request-Type", "POST_FORM_URLENCODED") + .build(); + + displayRequest("POST", request, formData); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating form request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void performOctetStreamRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL as query parameter for binary requests + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_BINARY&button=POST_Binary"; + + // Create binary data (simulate a small file upload) + byte[] binaryData = new byte[1024]; // 1KB of binary data + for (int i = 0; i < binaryData.length; i++) { + binaryData[i] = (byte) (i % 256); + } + + RequestBody body = RequestBody.create( + binaryData, + MediaType.get("application/octet-stream") + ); + + Request request = new Request.Builder() + .url(urlWithType) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/octet-stream") + .addHeader("Content-Length", String.valueOf(binaryData.length)) + .addHeader("X-Request-Type", "POST_BINARY") + .build(); + + String displayBody = "[Binary data: " + binaryData.length + " bytes]\n" + + "Request type in URL: POST_BINARY\n" + + "Sample bytes: " + Arrays.toString(Arrays.copyOf(binaryData, Math.min(10, binaryData.length))); + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating binary request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void performTextPlainRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Create plain text string data with request type identifier + String textData = "REQUEST_TYPE: POST_STRING\n" + + "BUTTON_CLICKED: POST String\n" + + "Hello from Sentry Android Sample!\n" + + "This is a plain text request body.\n" + + "Timestamp: " + new Date().toString() + "\n" + + "Device: " + android.os.Build.MODEL + "\n" + + "SDK Version: " + android.os.Build.VERSION.SDK_INT + "\n" + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit."; + + RequestBody body = RequestBody.create( + textData, + MediaType.get("text/plain; charset=utf-8") + ); + + Request request = new Request.Builder() + .url(url) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "text/plain; charset=utf-8") + .addHeader("X-Request-Type", "POST_STRING") + .build(); + + displayRequest("POST", request, textData); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating string request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void performOneShotJsonRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL as query parameter for one-shot requests + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_ONE_SHOT&button=POST_OneShotBody"; + + // Create JSON data for one-shot request body + JSONObject json = new JSONObject(); + json.put("request_type", "POST_ONE_SHOT"); + json.put("button_clicked", "POST One-Shot"); + json.put("message", "This is a ONE-SHOT REQUEST BODY - can only be read once!"); + json.put("timestamp", System.currentTimeMillis()); + json.put("device", android.os.Build.MODEL); + json.put("warning", "Reading this body multiple times will cause IOException"); + + String jsonString = json.toString(); + byte[] bodyBytes = jsonString.getBytes("UTF-8"); + + // Create a TRUE one-shot request body that will fail if read multiple times + RequestBody oneShotBody = new RequestBody() { + private InputStream inputStream = new ByteArrayInputStream(bodyBytes); + private boolean hasBeenRead = false; + + @Override + public MediaType contentType() { + return MediaType.get("application/json; charset=utf-8"); + } + + @Override + public long contentLength() { + return bodyBytes.length; + } + + @Override + public void writeTo(okio.BufferedSink sink) throws IOException { + if (hasBeenRead) { + throw new IOException("One-shot body has already been read! This would happen in real scenarios with FileInputStream or other non-repeatable streams."); + } + + hasBeenRead = true; + + try { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + sink.write(buffer, 0, bytesRead); + } + } finally { + inputStream.close(); + } + } + }; + + Request request = new Request.Builder() + .url(urlWithType) + .post(oneShotBody) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/json; charset=utf-8") + .addHeader("X-Request-Type", "POST_ONE_SHOT") + .addHeader("X-Body-Type", "ONE_SHOT_STREAM") + .build(); + + String displayBody = "[ONE-SHOT REQUEST BODY]\n" + + "Type: InputStream-based RequestBody\n" + + "Size: " + bodyBytes.length + " bytes\n" + + "Content: " + json.toString(2) + "\n" + + "\nWARNING: This body can only be read once!\n" + + "If interceptors try to read it multiple times, it will fail."; + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating one-shot request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void performLargeTextPlainRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL for identification + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_LARGE_TEXT&button=POST_LargeText"; + + // Create large text data that exceeds MAX_NETWORK_BODY_SIZE (150KB) + // Target size: 200KB (204,800 bytes) + int targetSize = 200 * 1024; // 200KB + StringBuilder largeText = new StringBuilder(); + + largeText.append("REQUEST_TYPE: POST_LARGE_TEXT\n"); + largeText.append("BUTTON_CLICKED: POST Large Text\n"); + largeText.append("SIZE_TARGET: ").append(targetSize).append(" bytes (exceeds 150KB limit)\n"); + largeText.append("TIMESTAMP: ").append(new Date()).append("\n"); + largeText.append("DEVICE: ").append(android.os.Build.MODEL).append("\n"); + largeText.append("WARNING: This body size exceeds MAX_NETWORK_BODY_SIZE!\n\n"); + + // Fill with repeated content to reach target size + String filler = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "; + + int currentSize = largeText.length(); + while (currentSize < targetSize) { + largeText.append("FILLER_LINE_").append(currentSize / filler.length()).append(": ").append(filler); + currentSize = largeText.length(); + } + + String textData = largeText.toString(); + + RequestBody body = RequestBody.create( + textData, + MediaType.get("text/plain; charset=utf-8") + ); + + Request request = new Request.Builder() + .url(urlWithType) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "text/plain; charset=utf-8") + .addHeader("X-Request-Type", "POST_LARGE_TEXT") + .addHeader("X-Body-Size", String.valueOf(textData.length())) + .build(); + + String displayBody = "[LARGE TEXT REQUEST BODY]\n" + + "Type: text/plain\n" + + "Size: " + textData.length() + " bytes (" + (textData.length() / 1024) + "KB)\n" + + "Limit: 153,600 bytes (150KB)\n" + + "Status: " + (textData.length() > 153600 ? "EXCEEDS LIMIT" : "Within limit") + "\n" + + "Preview: " + textData.substring(0, Math.min(200, textData.length())) + "..."; + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating large text request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void performLargeOctetStreamRequest() { + String url = getUrl(); + if (url.isEmpty()) { + Toast.makeText(this, "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + + try { + // Add request type to URL for identification (binary bodies are ignored) + String separator = url.contains("?") ? "&" : "?"; + String urlWithType = url + separator + "request_type=POST_LARGE_BINARY&button=POST_LargeBinary"; + + // Create large binary data that exceeds MAX_NETWORK_BODY_SIZE (150KB) + // Target size: 256KB (262,144 bytes) + int targetSize = 256 * 1024; // 256KB + byte[] binaryData = new byte[targetSize]; + + // Fill with a pattern for easier identification + for (int i = 0; i < binaryData.length; i++) { + // Create a pattern: alternating bytes with position info + binaryData[i] = (byte) ((i % 256) ^ ((i / 256) % 256)); + } + + RequestBody body = RequestBody.create( + binaryData, + MediaType.get("application/octet-stream") + ); + + Request request = new Request.Builder() + .url(urlWithType) + .post(body) + .addHeader("User-Agent", "Sentry-Sample-Android") + .addHeader("Content-Type", "application/octet-stream") + .addHeader("Content-Length", String.valueOf(binaryData.length)) + .addHeader("X-Request-Type", "POST_LARGE_BINARY") + .addHeader("X-Body-Size", String.valueOf(binaryData.length)) + .build(); + + String displayBody = "[LARGE BINARY REQUEST BODY]\n" + + "Type: application/octet-stream\n" + + "Size: " + binaryData.length + " bytes (" + (binaryData.length / 1024) + "KB)\n" + + "Limit: 153,600 bytes (150KB)\n" + + "Status: " + (binaryData.length > 153600 ? "EXCEEDS LIMIT" : "Within limit") + "\n" + + "Pattern: Alternating bytes with position info\n" + + "Sample bytes: " + Arrays.toString(Arrays.copyOf(binaryData, Math.min(16, binaryData.length))) + "\n" + + "Request type in URL: POST_LARGE_BINARY"; + + displayRequest("POST", request, displayBody); + executeRequest(request); + } catch (Exception e) { + Sentry.captureException(e); + Toast.makeText(this, "Error creating large binary request: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private String getCurrentTime() { + return dateFormat.format(new Date()); + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 0083fae8f93..64e35b12748 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -176,6 +176,12 @@ android:layout_height="wrap_content" android:text="@string/check_for_update"/> +