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 2b7a32820c3..17b250f558a 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 @@ -16,6 +16,7 @@ import io.sentry.NoOpCompositePerformanceCollector; import io.sentry.NoOpConnectionStatusProvider; import io.sentry.NoOpContinuousProfiler; +import io.sentry.NoOpReplayBreadcrumbConverter; import io.sentry.NoOpSocketTagger; import io.sentry.NoOpTransactionProfiler; import io.sentry.NoopVersionDetector; @@ -253,6 +254,13 @@ static void initializeIntegrationsAndProcessors( options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options)); } + if (options.getReplayController().getBreadcrumbConverter() + instanceof NoOpReplayBreadcrumbConverter) { + options + .getReplayController() + .setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter(options)); + } + // 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. @@ -406,7 +414,6 @@ static void installDefaultIntegrations( if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); - replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter()); options.addIntegration(replay); options.setReplayController(replay); } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 5d6df28f7b3..aeabe9c05c1 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -9,6 +9,7 @@ public final class io/sentry/android/replay/BuildConfig { public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { public static final field $stable I public fun ()V + public fun (Lio/sentry/SentryOptions;)V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } 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..7bdcbf9c59f 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,17 +1,33 @@ 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 +import io.sentry.SentryOptions.BeforeBreadcrumbCallback import io.sentry.SpanDataConvention +import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS 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 { +public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter { + private var options: SentryOptions? = null + + public constructor(options: SentryOptions) : this() { + // We modify options, so keep it around to make that explicit. + this.options = options + this.options?.beforeBreadcrumb = ReplayBeforeBreadcrumbCallback(options.beforeBreadcrumb) + } + 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") @@ -23,16 +39,68 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } } + /** + * Intercept the breadcrumb to process any Network Details data on the hint. Delegate to any + * user-provided callback to provide the actual breadcrumb to process. + */ + private inner class ReplayBeforeBreadcrumbCallback( + private val delegate: BeforeBreadcrumbCallback? + ) : BeforeBreadcrumbCallback { + override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? { + val resultBreadcrumb = + if (delegate != null) { + delegate.execute(breadcrumb, hint) + } else { + breadcrumb + } + + resultBreadcrumb?.let { finalBreadcrumb -> + extractNetworkRequestDataFromHint(finalBreadcrumb, hint)?.let { networkData -> + httpNetworkDetails[finalBreadcrumb] = networkData + } + } + + return resultBreadcrumb + } + + private fun extractNetworkRequestDataFromHint( + breadcrumb: Breadcrumb, + breadcrumbHint: Hint, + ): NetworkRequestData? { + if (breadcrumb.type != "http" && breadcrumb.category != "http") { + return null + } + + return breadcrumbHint.get(SENTRY_REPLAY_NETWORK_DETAILS) as? NetworkRequestData + } + } + 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 + } + } + ) + 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 +110,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 { @@ -53,8 +122,9 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbCategory = "navigation" breadcrumbData["to"] = when { - breadcrumb.data["state"] == "resumed" -> + breadcrumb.data["state"] == "resumed" -> { (breadcrumb.data["screen"] as? String)?.substringAfterLast('.') + } "to" in breadcrumb.data -> breadcrumb.data["to"] as? String else -> null } ?: return null @@ -67,6 +137,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { ?: breadcrumb.data["view.tag"] ?: breadcrumb.data["view.class"]) as? String ?: return null + breadcrumbData.putAll(breadcrumb.data) } @@ -75,18 +146,18 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbData["state"] = when { breadcrumb.data["action"] == "NETWORK_LOST" -> "offline" - "network_type" in breadcrumb.data -> + "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 } @@ -105,6 +176,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { breadcrumbData.putAll(breadcrumb.data) } } + return if (!breadcrumbCategory.isNullOrEmpty()) { RRWebBreadcrumbEvent().apply { timestamp = breadcrumb.timestamp.time @@ -120,29 +192,34 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } } - private fun Breadcrumb.isValidForRRWebSpan(): Boolean = - !(data["url"] as? String).isNullOrEmpty() && + 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 + + // 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 @@ -151,13 +228,54 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } 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.value } + + 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.value } + + 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..4517655eca7 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,18 +1,32 @@ 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.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS 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.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame class DefaultReplayBreadcrumbConverterTest { class Fixture { - fun getSut(): DefaultReplayBreadcrumbConverter = DefaultReplayBreadcrumbConverter() + fun getSut(options: SentryOptions? = null): DefaultReplayBreadcrumbConverter = + if (options != null) { + DefaultReplayBreadcrumbConverter(options) + } else { + DefaultReplayBreadcrumbConverter() + } } private val fixture = Fixture() @@ -318,4 +332,246 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(SentryLevel.ERROR, rrwebEvent.level) assertEquals("shiet", rrwebEvent.data!!["stuff"]) } + + // BeforeBreadcrumbCallback delegation tests + + @Test + fun `ReplayBeforeBreadcrumb does not modify breadcrumb__no user-provided BeforeBreadcrumbCallback`() { + // Create options with no beforeBreadcrumb callback + val options = SentryOptions.empty() + options.beforeBreadcrumb = null + DefaultReplayBreadcrumbConverter(options) + + val breadcrumb = + Breadcrumb(Date()).apply { + message = "test message" + category = "test.category" + } + val hint = Hint() + + val result = options.beforeBreadcrumb?.execute(breadcrumb, hint) + + assertSame(breadcrumb, result) + } + + @Test + fun `ReplayBeforeBreadcrumb delegates to user-provided BeforeBreadcrumbCallback`() { + val originalBreadcrumb = + Breadcrumb(Date()).apply { + message = "original message" + category = "original.category" + } + val userModifiedBreadcrumb = + Breadcrumb(Date()).apply { + message = "modified message" + category = "modified.category" + } + + // Set up options with a user callback that returns modified breadcrumb + val userBeforeBreadcrumbCallback = + SentryOptions.BeforeBreadcrumbCallback { _, _ -> userModifiedBreadcrumb } + val options = SentryOptions.empty() + options.beforeBreadcrumb = userBeforeBreadcrumbCallback + + DefaultReplayBreadcrumbConverter(options) + + // user-provided SentryOptions beforeBreadcrumb is replaced. + assertNotSame(userBeforeBreadcrumbCallback, options.beforeBreadcrumb) + + // SentryOptions#beforeBreadcrumb still respects user-provided beforeBreadcrumb + val result = options.beforeBreadcrumb?.execute(originalBreadcrumb, Hint()) + assertSame(userModifiedBreadcrumb, result) + } + + @Test + fun `ReplayBeforeBreadcrumb handles user-provided BeforeBreadcrumbCallback returning null`() { + val breadcrumb = + Breadcrumb(Date()).apply { + message = "test message" + category = "test.category" + } + + val options = SentryOptions.empty() + val userCallback = SentryOptions.BeforeBreadcrumbCallback { _, _ -> null } + options.beforeBreadcrumb = userCallback + fixture.getSut(options) + + // user-provided SentryOptions beforeBreadcrumb is replaced. + assertNotSame(userCallback, options.beforeBreadcrumb) + + val result = options.beforeBreadcrumb?.execute(breadcrumb, Hint()) + assertNull(result) + } + + @Test + fun `converts network details data__with user-provided BeforeBreadcrumbCallback`() { + val options = SentryOptions.empty() + val userCallback = SentryOptions.BeforeBreadcrumbCallback { b, _ -> b } + options.beforeBreadcrumb = userCallback + val converter = fixture.getSut(options) + + val httpBreadcrumb = + Breadcrumb(Date(123L)).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1000L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2000L + } + + val fakeOkHttpNetworkDetails = NetworkRequestData("POST") + fakeOkHttpNetworkDetails.setRequestDetails( + ReplayNetworkRequestOrResponse( + 100L, + NetworkBody.fromString("request body content"), + mapOf("Content-Type" to "application/json"), + ) + ) + fakeOkHttpNetworkDetails.setResponseDetails( + 200, + ReplayNetworkRequestOrResponse( + 500L, + NetworkBody.fromJsonObject(mapOf("status" to "success", "message" to "OK")), + mapOf("Content-Type" to "text/plain"), + ), + ) + val hintWithFakeOKHttpNetworkDetails = Hint() + hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails) + + options.beforeBreadcrumb?.execute(httpBreadcrumb, hintWithFakeOKHttpNetworkDetails) + + // Verify NetworkDetails is properly extracted + val rrwebEvent = converter.convert(httpBreadcrumb) + check(rrwebEvent is RRWebSpanEvent) + + // Meta data + assertEquals("POST", rrwebEvent.data!!["method"]) + assertEquals(200, rrwebEvent.data!!["statusCode"]) + assertEquals(100L, rrwebEvent.data!!["requestBodySize"]) + assertEquals(500L, rrwebEvent.data!!["responseBodySize"]) + + // Request data + val requestData = rrwebEvent.data!!["request"] as? Map<*, *> + assertNotNull(requestData) + assertEquals(100L, requestData["size"]) + assertEquals("request body content", requestData["body"]) + assertEquals(mapOf("Content-Type" to "application/json"), requestData["headers"]) + + // Response data + val responseData = rrwebEvent.data!!["response"] as? Map<*, *> + assertNotNull(responseData) + assertEquals(500L, responseData["size"]) + assertEquals(mapOf("status" to "success", "message" to "OK"), responseData["body"]) + assertEquals(mapOf("Content-Type" to "text/plain"), responseData["headers"]) + } + + @Test + fun `converts network details data__no user-provided BeforeBreadcrumbCallback`() { + val options = SentryOptions.empty() + val userCallback = null + options.beforeBreadcrumb = userCallback + val converter = fixture.getSut(options) + + val httpBreadcrumb = + Breadcrumb(Date(123L)).apply { + type = "http" + category = "http" + data["url"] = "https://example.com" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1000L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2000L + } + + val fakeOkHttpNetworkDetails = NetworkRequestData("POST") + fakeOkHttpNetworkDetails.setRequestDetails( + ReplayNetworkRequestOrResponse( + 150L, + NetworkBody.fromJsonArray(listOf("item1", "item2", "item3")), + mapOf("Content-Type" to "application/json"), + ) + ) + fakeOkHttpNetworkDetails.setResponseDetails( + 404, + ReplayNetworkRequestOrResponse( + 550L, + NetworkBody.fromJsonObject(mapOf("status" to "success", "message" to "OK")), + mapOf("Content-Type" to "text/plain"), + ), + ) + val hintWithFakeOKHttpNetworkDetails = Hint() + hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails) + + options.beforeBreadcrumb?.execute(httpBreadcrumb, hintWithFakeOKHttpNetworkDetails) + + // Verify NetworkDetails is properly extracted + val rrwebEvent = converter.convert(httpBreadcrumb) + check(rrwebEvent is RRWebSpanEvent) + + // Meta data + assertEquals("POST", rrwebEvent.data!!["method"]) + assertEquals(404, rrwebEvent.data!!["statusCode"]) + assertEquals(150L, rrwebEvent.data!!["requestBodySize"]) + assertEquals(550L, rrwebEvent.data!!["responseBodySize"]) + + // Request data + val requestData = rrwebEvent.data!!["request"] as? Map<*, *> + assertNotNull(requestData) + assertEquals(150L, requestData["size"]) + assertEquals(listOf("item1", "item2", "item3"), requestData["body"]) + assertEquals(mapOf("Content-Type" to "application/json"), requestData["headers"]) + + // Response data + val responseData = rrwebEvent.data!!["response"] as? Map<*, *> + assertNotNull(responseData) + assertEquals(550L, responseData["size"]) + assertEquals(mapOf("status" to "success", "message" to "OK"), responseData["body"]) + assertEquals(mapOf("Content-Type" to "text/plain"), responseData["headers"]) + } + + @Test + fun `does not convert network details data for non-http breadcrumbs`() { + val navigationBreadcrumb = + Breadcrumb(Date()).apply { + type = "navigation" + category = "navigation" + data["to"] = "/home" + } + val hint = Hint() + val networkRequestData = NetworkRequestData("GET") + networkRequestData.setRequestDetails( + ReplayNetworkRequestOrResponse( + 100L, + NetworkBody.fromString("request body content"), + mapOf("Content-Type" to "application/json"), + ) + ) + networkRequestData.setResponseDetails( + 200, + ReplayNetworkRequestOrResponse( + 100L, + NetworkBody.fromString("respnse body content"), + mapOf("Content-Type" to "application/json"), + ), + ) + hint.set(SENTRY_REPLAY_NETWORK_DETAILS, networkRequestData) + + val options = SentryOptions.empty() + options.beforeBreadcrumb = null + val converter = fixture.getSut(options) + + assertSame(navigationBreadcrumb, options.beforeBreadcrumb?.execute(navigationBreadcrumb, hint)) + + // Verify converter also doesn't include network details for non-http breadcrumbs + val rrwebEvent = converter.convert(navigationBreadcrumb) + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("navigation", rrwebEvent.category) + assertEquals("/home", rrwebEvent.data!!["to"]) + + // Verify no network-related data is present + assertNull(rrwebEvent.data!!["method"]) + assertNull(rrwebEvent.data!!["statusCode"]) + assertNull(rrwebEvent.data!!["requestBodySize"]) + assertNull(rrwebEvent.data!!["responseBodySize"]) + assertNull(rrwebEvent.data!!["request"]) + assertNull(rrwebEvent.data!!["response"]) + } } 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..116d61891a2 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -4,15 +4,18 @@ import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange +import io.sentry.ILogger import io.sentry.IScopes 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 import io.sentry.TypeCheckHint.OKHTTP_RESPONSE +import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -21,9 +24,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 +61,16 @@ 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 +115,14 @@ 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 +146,32 @@ public open class SentryOkHttpInterceptor( } } + val requestContentLength = request.body?.contentLength() + + networkDetailData?.setRequestDetails( + NetworkDetailCaptureUtils.createRequest( + request, + requestContentLength, + FAKE_OPTIONS.networkCaptureBodies, + { req -> + req.body?.let { originalBody -> + 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(), scopes.options.logger) + } + }, + FAKE_OPTIONS.networkRequestHeaders, + { req: Request -> req.headers.toMap() }, + ) + ) + request = requestBuilder.build() response = chain.proceed(request) code = response.code @@ -153,11 +205,25 @@ 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(scopes.options.logger) }, + 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 +236,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(SENTRY_REPLAY_NETWORK_DETAILS, networkDetailData) + } } - hint[OKHTTP_RESPONSE] = it - } // needs this as unix timestamp for rrweb breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) breadcrumb.setData( @@ -194,6 +269,78 @@ 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 = linkedMapOf() + for (i in 0 until size) { + headers[name(i)] = value(i) + } + return headers + } + + /** Extracts NetworkBody from already buffered request body data. */ + private fun safeExtractRequestBody( + bufferedBody: ByteArray?, + contentType: okhttp3.MediaType?, + logger: ILogger, + ): 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, + logger, + ) + } catch (e: Exception) { + logger.log(io.sentry.SentryLevel.ERROR, "Failed to parse buffered request body: ${e.message}") + return null + } + } + + /** Extracts the body content from an OkHttp Response safely */ + private fun Response.extractResponseBody(logger: ILogger): 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, + logger, + ) + } catch (e: Exception) { + logger.log( + io.sentry.SentryLevel.ERROR, + "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 afc6db9029f..e21bead746d 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -83,6 +83,9 @@ android:name=".CameraXActivity" android:exported="false" /> + + 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 68372c7ab11..1d47a3f27f0 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 @@ -368,6 +368,9 @@ public void run() { startActivity(new Intent(this, CameraXActivity.class)); }); + 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..4d2869d1f14 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java @@ -0,0 +1,669 @@ +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.Sentry; +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 674b65199ae..c86c904f6a0 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 @@ -190,6 +190,12 @@ android:text="@string/open_camera_activity" /> +