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..a928d8ab7bc 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,14 @@ static void initializeIntegrationsAndProcessors( options.setCompositePerformanceCollector(new DefaultCompositePerformanceCollector(options)); } + if (options.getReplayController() != null) { // TODO: Triple-check this should be a null check + 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..d2f746f828d 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,16 +1,22 @@ package io.sentry.android.replay +import android.util.Log import io.sentry.Breadcrumb +import io.sentry.Hint import io.sentry.ReplayBreadcrumbConverter import io.sentry.SentryLevel +import io.sentry.SentryOptions.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 { @@ -24,10 +30,21 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } 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 { @@ -120,10 +137,76 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } } - private fun Breadcrumb.isValidForRRWebSpan(): Boolean = - !(data["url"] as? String).isNullOrEmpty() && - SpanDataConvention.HTTP_START_TIMESTAMP in data && - SpanDataConvention.HTTP_END_TIMESTAMP in data + 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 + } + } + + Log.d( + "SentryNetwork", + "SentryNetwork: BeforeBreadcrumbCallback - Hint: $hint, Breadcrumb: $result", + ) + return result + } + + private fun extractNetworkRequestDataFromHint( + breadcrumb: Breadcrumb, + breadcrumbHint: Hint, + ): NetworkRequestData? { + if (breadcrumb.type != "http" && breadcrumb.category != "http") { + return null + } + + val networkDetails = breadcrumbHint.get("replay:networkDetails") as? NetworkRequestData + if (networkDetails != null) { + Log.d( + "SentryNetwork", + "SentryNetwork: Found structured NetworkRequestData in hint: $networkDetails", + ) + return networkDetails + } + + Log.d("SentryNetwork", "SentryNetwork: No structured NetworkRequestData found on hint") + return null + } + + private fun Breadcrumb.isValidForRRWebSpan(): Boolean { + val url = data["url"] as? String + val hasStartTimestamp = SpanDataConvention.HTTP_START_TIMESTAMP in data + val hasEndTimestamp = SpanDataConvention.HTTP_END_TIMESTAMP in data + + val urlValid = !url.isNullOrEmpty() + val isValid = urlValid && hasStartTimestamp && hasEndTimestamp + + val reasons = mutableListOf() + if (!urlValid) reasons.add("missing or empty URL") + if (!hasStartTimestamp) reasons.add("missing start timestamp") + if (!hasEndTimestamp) reasons.add("missing end timestamp") + + Log.d( + "SentryReplay", + "Breadcrumb RRWeb span validation: ${if (isValid) "VALID" else "INVALID"}" + + if (!isValid) " (${reasons.joinToString(", ")})" + else "" + " - URL: ${url ?: "null"}, Category: ${category}", + ) + + return isValid + } private fun String.snakeToCamelCase(): String = replace(snakecasePattern) { it.value.last().toString().uppercase() } @@ -132,6 +215,16 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { val breadcrumb = this val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] + + // Get the NetworkRequestData if available and remove it from the map + val networkDetailData = httpNetworkDetails.remove(breadcrumb) + + Log.d( + "SentryNetwork", + "SentryNetwork: convert(breadcrumb=${breadcrumb.type}) httpNetworkDetails map size: ${httpNetworkDetails.size}, " + + "found network data for current breadcrumb: ${networkDetailData != null}", + ) + return RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time op = "resource.http" @@ -151,6 +244,39 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } val breadcrumbData = mutableMapOf() + + // Add data from NetworkRequestData if 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[ @@ -158,6 +284,7 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { ] = 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..5336740724f 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,234 @@ 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 do not extract network data`() { + 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 + } + + @Test + fun `chained callbacks work correctly`() { + val converter = fixture.getSut() + val breadcrumb = Breadcrumb(Date()).apply { message = "original" } + val hint = Hint() + + val firstCallback = + SentryOptions.BeforeBreadcrumbCallback { b, h -> + b.message = "modified by first" + b + } + + val secondCallback = + SentryOptions.BeforeBreadcrumbCallback { b, h -> + // This simulates what happens when ReplayBreadcrumbConverter + // wraps a user's callback + val result = firstCallback.execute(b, h) + result?.let { + it.message = "${it.message} and second" + it + } + } + + converter.setUserBeforeBreadcrumbCallback(secondCallback) + + val result = converter.execute(breadcrumb, hint) + + assertNotNull(result) + assertEquals("modified by first and second", result.message) + } } 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..e1c24e4a13d 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,18 @@ public open class SentryOkHttpInterceptor( SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-okhttp", BuildConfig.VERSION_NAME) } + + /** + * Fake options for testing network detail capture + * TODO: Remove before landing. + */ + private val FAKE_OPTIONS = object { + val networkDetailAllowUrls: Array = arrayOf(".*") + val networkDetailDenyUrls: Array? = null + val networkCaptureBodies: Boolean = true + val networkRequestHeaders: Array = arrayOf("User-Agent", "Accept", "sentry-trace", "Content-Type") + val networkResponseHeaders: Array = arrayOf("User-Agent", "access-control-allow-origin", "x-ratelimit-resource") + } } public constructor() : this(ScopesAdapter.getInstance()) @@ -97,6 +115,13 @@ public open class SentryOkHttpInterceptor( var response: Response? = null var code: Int? = null + var networkDetailData = NetworkDetailCaptureUtils.initializeForUrl( + request.url.toString(), + request.method, + FAKE_OPTIONS.networkDetailAllowUrls, + FAKE_OPTIONS.networkDetailDenyUrls, + ) + try { val requestBuilder = request.newBuilder() @@ -120,6 +145,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 +212,31 @@ public open class SentryOkHttpInterceptor( // this only works correctly if SentryOkHttpInterceptor is the last one in the chain okHttpEvent?.setRequest(request) + response?.let { response -> + networkDetailData?.setResponseDetails( + response.code, + NetworkDetailCaptureUtils.createResponse( + response, + response.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 +249,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 +282,87 @@ 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) { + scopes.options.logger.log( + io.sentry.SentryLevel.DEBUG, + "Response body too large: $contentLength bytes (max: $maxBodySize)", + ) + 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) { + // If body reading fails, log and return null + scopes.options.logger.log( + io.sentry.SentryLevel.DEBUG, + "Failed to read response body safely: ${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..ad681855840 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 @@ + + @@ -82,7 +85,7 @@ - + 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..14ed1d9d1dc --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java @@ -0,0 +1,270 @@ +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.IOException; +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 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); + 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 -> performPostRequest()); + 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 performPostRequest() { + 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("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") + .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); + } + + 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"/> +