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..8ff3c3917d3 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 @@ -400,7 +400,11 @@ static void installDefaultIntegrations( if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); - replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter()); + DefaultReplayBreadcrumbConverter replayBreadcrumbConverter = new DefaultReplayBreadcrumbConverter(options.getBeforeBreadcrumb()); + options.setBeforeBreadcrumb( + replayBreadcrumbConverter + ); + replay.setBreadcrumbConverter(replayBreadcrumbConverter); options.addIntegration(replay); options.setReplayController(replay); } 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..97da4342a33 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,15 +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 +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 kotlin.LazyThreadSafetyMode.NONE -public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { +public open class DefaultReplayBreadcrumbConverter( + private val userBeforeBreadcrumbCallback: BeforeBreadcrumbCallback? = null +) : ReplayBreadcrumbConverter, SentryOptions.BeforeBreadcrumbCallback { internal companion object { private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } private val supportedNetworkData = @@ -24,10 +31,11 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } private var lastConnectivityState: String? = null + private val httpBreadcrumbData = mutableMapOf() override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { var breadcrumbMessage: String? = null - var breadcrumbCategory: String? = null + var breadcrumbCategory: String? var breadcrumbLevel: SentryLevel? = null val breadcrumbData = mutableMapOf() when { @@ -120,10 +128,62 @@ 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 + /** + * By default, ReplayIntegration provides its own BeforeBreadcrumbCallback, + * delegating to user-provided callback (if exists). + */ + override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? { + Log.d("SentryNetwork", "SentryNetwork: BeforeBreadcrumbCallback - Hint: $hint, Breadcrumb: $breadcrumb") + return userBeforeBreadcrumbCallback?.let { + it.execute(breadcrumb, hint)?.also { processedBreadcrumb -> + extractNetworkRequestDataFromHint(processedBreadcrumb, hint)?.let { networkData -> + httpBreadcrumbData[processedBreadcrumb] = networkData + } + } + } ?: run { + // No user callback - store hint and return original breadcrumb + extractNetworkRequestDataFromHint(breadcrumb, hint)?.let { networkData -> + httpBreadcrumbData[breadcrumb] = networkData + } + breadcrumb + } + } + + private fun extractNetworkRequestDataFromHint(breadcrumb: Breadcrumb, breadcrumbHint: Hint): NetworkRequestData? { + if (breadcrumb.type != "http" && breadcrumb.category != "http") { + return null + } + + // First try to get the structured network data from the hint + 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 +192,14 @@ 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 + val networkRequestData = httpBreadcrumbData[breadcrumb] + + Log.d("SentryNetwork", "SentryNetwork: convert(breadcrumb=${breadcrumb.type}) httpBreadcrumbData map size: ${httpBreadcrumbData.size}, " + + "contains current breadcrumb: ${httpBreadcrumbData.containsKey(breadcrumb)}, " + + "network data for current: ${httpBreadcrumbData[breadcrumb]}") + return RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time op = "resource.http" @@ -151,6 +219,41 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { } val breadcrumbData = mutableMapOf() + + // Add data from NetworkRequestData if available + if (networkRequestData != null) { + networkRequestData.method?.let { breadcrumbData["method"] = it } + networkRequestData.statusCode?.let { breadcrumbData["statusCode"] = it } + networkRequestData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it } + networkRequestData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it } + + // Add request and response data if available + networkRequestData.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 + } + } + + networkRequestData.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 data processing + // TODO: Remove if superceded by more detailed data (above). for ((key, value) in breadcrumb.data) { if (key in supportedNetworkData) { breadcrumbData[ @@ -158,6 +261,8 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { ] = value } } + + data = breadcrumbData } } 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..f51224e6d77 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -21,6 +21,8 @@ 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.NetworkRequestData +import io.sentry.util.network.ReplayNetworkRequestOrResponse import java.io.IOException import okhttp3.Interceptor import okhttp3.Request @@ -172,18 +174,30 @@ public open class SentryOkHttpInterceptor( startTimestamp: Long, ) { val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) + + // Track request and response body sizes for the breadcrumb + var requestBodySize: Long? = null + var responseBodySize: Long? = null + request.body?.contentLength().ifHasValidLength { breadcrumb.setData("http.request_content_length", it) + requestBodySize = 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) + responseBodySize = it + } - hint[OKHTTP_RESPONSE] = it + val hint = Hint().also { + // Set the structured network data for replay + val networkData = createNetworkRequestData(request, response, requestBodySize, responseBodySize) + it.set("replay:networkDetails", networkData) + +// it.set(OKHTTP_REQUEST, request) +// response?.let { resp -> it[OKHTTP_RESPONSE] = resp } } + // needs this as unix timestamp for rrweb breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) breadcrumb.setData( @@ -194,6 +208,116 @@ 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 body metadata from OkHttp RequestBody or ResponseBody + * Note: We don't consume the actual body stream to avoid interfering with the request/response + */ + private fun extractBodyMetadata( + contentLength: Long?, + contentType: okhttp3.MediaType? + ): Pair { + val bodySize = contentLength?.takeIf { it >= 0 } + val bodyInfo = if (contentLength != null && contentLength != 0L) { + mapOf( + "contentType" to contentType?.toString(), + "hasBody" to true + ) + } else null + + return bodySize to bodyInfo + } + + /** + * Creates a NetworkRequestData object from the request and response + */ + private fun createNetworkRequestData( + request: Request, + response: Response?, + requestBodySize: Long?, + responseBodySize: Long? + ): NetworkRequestData { + // Log the incoming request details + println("SentryNetwork: Creating NetworkRequestData for: ${request.method} ${request.url}") + scopes.options.logger.log( + io.sentry.SentryLevel.INFO, + "SentryNetwork: Creating NetworkRequestData for: ${request.method} ${request.url}" + ) + + // Extract request data + val requestHeaders = request.headers.toMap() + val (reqBodySize, reqBodyInfo) = extractBodyMetadata( + request.body?.contentLength(), + request.body?.contentType() + ) + + scopes.options.logger.log( + io.sentry.SentryLevel.INFO, + "SentryNetwork: Request - Headers count: ${requestHeaders.size}, Body size: $reqBodySize, Body info: $reqBodyInfo" + ) + + val requestData = ReplayNetworkRequestOrResponse( + reqBodySize, + reqBodyInfo, + requestHeaders + ) + + // Extract response data if available + val responseData = response?.let { + val responseHeaders = it.headers.toMap() + val (respBodySize, respBodyInfo) = extractBodyMetadata( + it.body?.contentLength(), + it.body?.contentType() + ) + + scopes.options.logger.log( + io.sentry.SentryLevel.INFO, + "SentryNetwork: Response - Status: ${it.code}, Headers count: ${responseHeaders.size}, Body size: $respBodySize, Body info: $respBodyInfo" + ) + + ReplayNetworkRequestOrResponse( + respBodySize, + respBodyInfo, + responseHeaders + ) + } + + // Determine final body sizes (prefer the explicit sizes passed in) + val finalResponseBodySize = response?.let { + val (respBodySize, _) = extractBodyMetadata( + it.body?.contentLength(), + it.body?.contentType() + ) + responseBodySize ?: respBodySize + } + + val networkData = NetworkRequestData( + request.method, + response?.code, + requestBodySize ?: reqBodySize, + finalResponseBodySize, + requestData, + responseData + ) + + scopes.options.logger.log( + io.sentry.SentryLevel.INFO, + "SentryNetwork: Created NetworkRequestData: $networkData" + ) + + return networkData + } + 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"/> +