From 32bcb4a96ab2d3c4f06799fb2520149bebc508ae Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 7 Nov 2025 14:58:15 +0000 Subject: [PATCH 01/19] feat(http): introduced new bugsnag-android-http-api module --- CODEOWNERS | 1 + bugsnag-android-http-api/build.gradle.kts | 69 +++++++++++++++ bugsnag-android-http-api/gradle.properties | 2 + bugsnag-android-http-api/lint-baseline.xml | 4 + .../src/main/AndroidManifest.xml | 2 + .../http/HttpInstrumentationBuilder.java | 85 +++++++++++++++++++ .../android/http/HttpInstrumentedRequest.java | 19 +++++ .../http/HttpInstrumentedResponse.java | 17 ++++ .../android/http/HttpRequestCallback.java | 7 ++ .../android/http/HttpResponseCallback.java | 7 ++ dockerfiles/Dockerfile.android-publisher | 1 + settings.gradle.kts | 1 + 12 files changed, 215 insertions(+) create mode 100644 bugsnag-android-http-api/build.gradle.kts create mode 100644 bugsnag-android-http-api/gradle.properties create mode 100644 bugsnag-android-http-api/lint-baseline.xml create mode 100644 bugsnag-android-http-api/src/main/AndroidManifest.xml create mode 100644 bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java create mode 100644 bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java create mode 100644 bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java create mode 100644 bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java create mode 100644 bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java diff --git a/CODEOWNERS b/CODEOWNERS index 2d92311226..702819eda4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,6 @@ bugsnag-android/ @lemnik @YYChen01988 bugsnag-android-core/ @lemnik @YYChen01988 +bugsnag-android-http-api/ @lemnik @YYChen01988 bugsnag-plugin-android-anr/ @lemnik @YYChen01988 bugsnag-plugin-android-exitinfo/ @lemnik @YYChen01988 bugsnag-plugin-android-ndk/ @lemnik @YYChen01988 diff --git a/bugsnag-android-http-api/build.gradle.kts b/bugsnag-android-http-api/build.gradle.kts new file mode 100644 index 0000000000..9c1e15aa00 --- /dev/null +++ b/bugsnag-android-http-api/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compatibility) + alias(libs.plugins.detekt) + alias(libs.plugins.dokka) + alias(libs.plugins.ktlint) + alias(libs.plugins.licenseCheck) + checkstyle +} + +android { + compileSdk = libs.versions.android.compileSdk.get().toInt() + namespace = "com.bugsnag.android.http" + + configureRelease() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + ndkVersion = libs.versions.android.ndk.get() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + lint { + abortOnError = true + warningsAsErrors = true + checkAllWarnings = true + baseline = File(project.projectDir, "lint-baseline.xml") + disable += setOf("GradleDependency", "NewerVersionAvailable") + } + + buildFeatures { + aidl = false + renderScript = false + shaders = false + resValues = false + buildConfig = false + } + + compileOptions { + sourceCompatibility = Versions.java + targetCompatibility = Versions.java + } + + kotlinOptions { + jvmTarget = Versions.java.toString() + } + + testOptions { + unitTests { + isReturnDefaultValues = true + } + } +} + +dependencies { + api(libs.bundles.common.api) + add("api", project(":bugsnag-android-core")) + + testImplementation(libs.bundles.test.jvm) + androidTestImplementation(libs.bundles.test.android) +} + +apply(from = rootProject.file("gradle/detekt.gradle")) +apply(from = rootProject.file("gradle/license-check.gradle")) +apply(from = rootProject.file("gradle/release.gradle")) + +configureCheckstyle() diff --git a/bugsnag-android-http-api/gradle.properties b/bugsnag-android-http-api/gradle.properties new file mode 100644 index 0000000000..cb9d257f99 --- /dev/null +++ b/bugsnag-android-http-api/gradle.properties @@ -0,0 +1,2 @@ +pomName=Bugsnag Android HTTP API +artefactId=bugsnag-android-http-api diff --git a/bugsnag-android-http-api/lint-baseline.xml b/bugsnag-android-http-api/lint-baseline.xml new file mode 100644 index 0000000000..97743c0431 --- /dev/null +++ b/bugsnag-android-http-api/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bugsnag-android-http-api/src/main/AndroidManifest.xml b/bugsnag-android-http-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..de749acb75 --- /dev/null +++ b/bugsnag-android-http-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java new file mode 100644 index 0000000000..d85fb2cae4 --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java @@ -0,0 +1,85 @@ +package com.bugsnag.android.http; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; + +/** + * An abstraction of basic HTTP instrumentation configuration. This interface defines the basic + * capabilities that can be configured for HTTP instrumentation without defining how the + * instrumentation gets configured for its target HTTP implementation. + * + * @param the request type of the HTTP API being instrumented + * @param the response type of the HTTP API being instrumented + */ +public interface HttpInstrumentationBuilder { + /** + * Mark the given HTTP response status code as an error. Error responses are automatically + * reported as "HTTP Errors" to BugSnag. + * + * @param statusCode the HTTP status code to add + * @return this + */ + @NonNull + HttpInstrumentationBuilder addHttpErrorCode(@IntRange(from = 0) int statusCode); + + /** + * Mark the given HTTP response status code range as an error. Error responses are automatically + * reported as "HTTP Errors" to BugSnag. All status codes within this (inclusive) range will + * be considered errors. + * + * @param minStatusCode the low status code to mark as an error + * @param maxStatusCode the high status code (inclusive) to mark as an error + * @return this + */ + @NonNull + HttpInstrumentationBuilder addHttpErrorCodes(@IntRange(from = 0) int minStatusCode, + @IntRange(from = 0) int maxStatusCode); + + @NonNull + HttpInstrumentationBuilder removeHttpErrorCode(@IntRange(from = 0) int statusCode); + + @NonNull + HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int minStatusCode, + @IntRange(from = 0) int maxStatusCode); + + /** + * Define the maximum number of bytes that can be captured from the request body (when one + * exists). Setting the number to 0 will turn off request body capture. + * + * @param maxBytes the maximum number of bytes to capture + * @return this + */ + @NonNull + HttpInstrumentationBuilder maxRequestBodyCapture(@IntRange(from = 0) long maxBytes); + + @NonNull + HttpInstrumentationBuilder maxResponseBodyCapture(@IntRange(from = 0) long maxBytes); + + /** + * Shorthand for {@link #logBreadcrumbs(boolean) logBreadcrumbs(true)}. + * + * @return this + */ + @NonNull + HttpInstrumentationBuilder logBreadcrumbs(); + + /** + * Set whether breadcrumbs should logged for all HTTP requests observed by the instrumentation. + * The {@link com.bugsnag.android.Configuration#setEnabledBreadcrumbTypes(java.util.Set) + * enabledBreadcrumbTypes} configuration option will override this setting. If {@code REQUEST} + * breadcrumbs have been disabled in the configuration, this option will have no effect. + * + * @param logBreadcrumbs true if breadcrumbs should be logged + * @return this + */ + @NonNull + HttpInstrumentationBuilder logBreadcrumbs(boolean logBreadcrumbs); + + @NonNull + HttpInstrumentationBuilder addRequestCallback(@NonNull HttpRequestCallback callback); + + + @NonNull + HttpInstrumentationBuilder addResponseCallback(@NonNull HttpResponseCallback callback); + +} diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java new file mode 100644 index 0000000000..4f1bebe79f --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java @@ -0,0 +1,19 @@ +package com.bugsnag.android.http; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public interface HttpInstrumentedRequest { + @NonNull + R getRequest(); + + @Nullable + String getReportedUrl(); + + void setReportedUrl(@Nullable String reportedUrl); + + @Nullable + String getReportedRequestBody(); + + void setReportedRequestBody(@Nullable String requestBody); +} diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java new file mode 100644 index 0000000000..91a2c83c2b --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java @@ -0,0 +1,17 @@ +package com.bugsnag.android.http; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public interface HttpInstrumentedResponse { + @NonNull + R getRequest(); + + @Nullable + S getResponse(); + + @Nullable + String getReportedResponseBody(); + + void setReportedResponseBody(@Nullable String responseBody); +} diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java new file mode 100644 index 0000000000..30e7116fb6 --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java @@ -0,0 +1,7 @@ +package com.bugsnag.android.http; + +import androidx.annotation.NonNull; + +public interface HttpRequestCallback { + void onHttpRequest(@NonNull HttpInstrumentedRequest req); +} diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java new file mode 100644 index 0000000000..fa5c9ad3b5 --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java @@ -0,0 +1,7 @@ +package com.bugsnag.android.http; + +import androidx.annotation.NonNull; + +public interface HttpResponseCallback { + void onHttpResponse(@NonNull HttpInstrumentedResponse response); +} diff --git a/dockerfiles/Dockerfile.android-publisher b/dockerfiles/Dockerfile.android-publisher index 161b41da65..83985e72a8 100644 --- a/dockerfiles/Dockerfile.android-publisher +++ b/dockerfiles/Dockerfile.android-publisher @@ -11,6 +11,7 @@ COPY buildSrc/ buildSrc/ # Copy sdk source files COPY bugsnag-android/ bugsnag-android/ COPY bugsnag-android-core/ bugsnag-android-core/ +COPY bugsnag-android-http-api/ bugsnag-android-http-api/ COPY bugsnag-plugin-android-anr/ bugsnag-plugin-android-anr/ COPY bugsnag-plugin-android-exitinfo/ bugsnag-plugin-android-exitinfo/ COPY bugsnag-plugin-android-ndk/ bugsnag-plugin-android-ndk/ diff --git a/settings.gradle.kts b/settings.gradle.kts index cc2590528a..497bf406f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ gradleEnterprise { include( ":bugsnag-android", ":bugsnag-android-core", + ":bugsnag-android-http-api", ":bugsnag-plugin-android-anr", ":bugsnag-plugin-android-exitinfo", ":bugsnag-plugin-android-ndk", From 6ab017fca282575a9779c24366d806222961d5a8 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 7 Nov 2025 15:20:47 +0000 Subject: [PATCH 02/19] doc(http): documentation comments for the new HTTP instrumentation API --- .../http/HttpInstrumentationBuilder.java | 43 ++++++++++++- .../android/http/HttpInstrumentedRequest.java | 36 +++++++++++ .../http/HttpInstrumentedResponse.java | 64 +++++++++++++++++++ .../android/http/HttpRequestCallback.java | 13 ++++ .../android/http/HttpResponseCallback.java | 14 ++++ 5 files changed, 168 insertions(+), 2 deletions(-) diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java index d85fb2cae4..705d5a2c9a 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java @@ -35,9 +35,22 @@ public interface HttpInstrumentationBuilder { HttpInstrumentationBuilder addHttpErrorCodes(@IntRange(from = 0) int minStatusCode, @IntRange(from = 0) int maxStatusCode); + /** + * Un-mark the given HTTP response status code as an error. + * + * @param statusCode the HTTP status code to remove + * @return this + */ @NonNull HttpInstrumentationBuilder removeHttpErrorCode(@IntRange(from = 0) int statusCode); + /** + * Un-mark the given HTTP response status code range as an error. + * + * @param minStatusCode the low status code to un-mark as an error + * @param maxStatusCode the high status code (inclusive) to un-mark as an error + * @return this + */ @NonNull HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int minStatusCode, @IntRange(from = 0) int maxStatusCode); @@ -52,6 +65,13 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi @NonNull HttpInstrumentationBuilder maxRequestBodyCapture(@IntRange(from = 0) long maxBytes); + /** + * Define the maximum number of bytes that can be captured from the response body (when one + * exists). Setting the number to 0 will turn off response body capture. + * + * @param maxBytes the maximum number of bytes to capture + * @return this + */ @NonNull HttpInstrumentationBuilder maxResponseBodyCapture(@IntRange(from = 0) long maxBytes); @@ -64,7 +84,10 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi HttpInstrumentationBuilder logBreadcrumbs(); /** - * Set whether breadcrumbs should logged for all HTTP requests observed by the instrumentation. + * Set the default for whether breadcrumbs should be logged for all HTTP requests observed by + * the instrumentation. This can be overridden on a per-request basis in an + * {@link #addResponseCallback(HttpResponseCallback) HttpResponseCallback}. + *

* The {@link com.bugsnag.android.Configuration#setEnabledBreadcrumbTypes(java.util.Set) * enabledBreadcrumbTypes} configuration option will override this setting. If {@code REQUEST} * breadcrumbs have been disabled in the configuration, this option will have no effect. @@ -75,10 +98,26 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi @NonNull HttpInstrumentationBuilder logBreadcrumbs(boolean logBreadcrumbs); + /** + * Add a callback to be invoked before an HTTP request is sent. This can be used to modify + * the request information that is reported to BugSnag, or to prevent the request from being + * reported at all. + * + * @param callback the callback to add + * @return this + */ @NonNull HttpInstrumentationBuilder addRequestCallback(@NonNull HttpRequestCallback callback); - + /** + * Add a callback to be invoked after an HTTP response is received. This can be used to modify + * the response information that is reported to BugSnag, or to prevent the response from being + * reported at all. The callback can also be used to override the default breadcrumb and + * error reporting behaviour on a per-request basis. + * + * @param callback the callback to add + * @return this + */ @NonNull HttpInstrumentationBuilder addResponseCallback(@NonNull HttpResponseCallback callback); diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java index 4f1bebe79f..912f156fb6 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java @@ -3,17 +3,53 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +/** + * Represents an HTTP request that has been instrumented by BugSnag. This interface provides + * access to the original request object, as well as methods to modify the information that is + * reported to BugSnag. + * + * @param the request type of the HTTP API being instrumented + */ public interface HttpInstrumentedRequest { + /** + * The original HTTP request object. + * + * @return the original request + */ @NonNull R getRequest(); + /** + * The URL that will be reported to BugSnag for this request. This may be different from the + * original request URL if it has been modified by a callback. + * + * @return the reported URL + */ @Nullable String getReportedUrl(); + /** + * Set the URL that will be reported to BugSnag for this request. Setting this to {@code null} + * will prevent the request from being reported at all. + * + * @param reportedUrl the URL to report + */ void setReportedUrl(@Nullable String reportedUrl); + /** + * The request body that will be reported to BugSnag for this request. This may be different + * from the original request body if it has been modified by a callback. + * + * @return the reported request body + */ @Nullable String getReportedRequestBody(); + /** + * Set the request body that will be reported to BugSnag for this request. Setting this to + * {@code null} will prevent the request body from being reported. + * + * @param requestBody the request body to report + */ void setReportedRequestBody(@Nullable String requestBody); } diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java index 91a2c83c2b..88a36baa53 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java @@ -3,15 +3,79 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +/** + * Represents an HTTP response that has been instrumented by BugSnag. This interface provides + * access to the original request and response objects, as well as methods to modify the + * information that is reported to BugSnag. + * + * @param the request type of the HTTP API being instrumented + * @param the response type of the HTTP API being instrumented + */ public interface HttpInstrumentedResponse { + /** + * The original HTTP request object. + * + * @return the original request + */ @NonNull R getRequest(); + /** + * The original HTTP response object, if one was received. This may be {@code null} if the + * request failed to produce a response (e.g. due to a network error). + * + * @return the original response, or {@code null} + */ @Nullable S getResponse(); + /** + * Override whether this request/response should be reported as a breadcrumb. This defaults + * to the value passed to {@link HttpInstrumentationBuilder#logBreadcrumbs(boolean)}, but + * cannot override whether {@link com.bugsnag.android.BreadcrumbType#REQUEST REQUEST} + * breadcrumbs are enabled. + * + * @param isBreadcrumbReported false if a breadcrumb should not be reported + */ + void setBreadcrumbReported(boolean isBreadcrumbReported); + + /** + * Check whether a breadcrumb will be reported for this response. + * + * @return true if a breadcrumb will be reported + */ + boolean isBreadcrumbReported(); + + /** + * Mark that an error should be reported for this response. This will default to true if the + * HTTP status code in the {@link #getResponse() response} was added as an "error code" using + * {@link HttpInstrumentationBuilder#addHttpErrorCode(int)}. + * + * @param isErrorReported true if an error has been reported + */ + void setErrorReported(boolean isErrorReported); + + /** + * Check whether an error will be reported for this response. + * + * @return true if an error should be reported + */ + boolean isErrorReported(); + + /** + * The response body that will be reported to BugSnag for this response. This may be different + * from the original response body if it has been modified by a callback. + * + * @return the reported response body + */ @Nullable String getReportedResponseBody(); + /** + * Set the response body that will be reported to BugSnag for this response. Setting this to + * {@code null} will prevent the response body from being reported. + * + * @param responseBody the response body to report + */ void setReportedResponseBody(@Nullable String responseBody); } diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java index 30e7116fb6..deeff88852 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java @@ -2,6 +2,19 @@ import androidx.annotation.NonNull; +/** + * A callback that is invoked before an HTTP request is sent. This can be used to modify the + * request information that is reported to BugSnag, or to prevent the request from being + * reported at all. + * + * @param the request type of the HTTP API being instrumented + * @see HttpInstrumentationBuilder#addRequestCallback(HttpRequestCallback) + */ public interface HttpRequestCallback { + /** + * Called before an HTTP request is sent. + * + * @param req the instrumented request + */ void onHttpRequest(@NonNull HttpInstrumentedRequest req); } diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java index fa5c9ad3b5..e68dd7728a 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java @@ -2,6 +2,20 @@ import androidx.annotation.NonNull; +/** + * A callback that is invoked after an HTTP response is received. This can be used to modify the + * response information that is reported to BugSnag, or to prevent the response from being + * reported at all. + * + * @param the request type of the HTTP API being instrumented + * @param the response type of the HTTP API being instrumented + * @see HttpInstrumentationBuilder#addResponseCallback(HttpResponseCallback) + */ public interface HttpResponseCallback { + /** + * Called after an HTTP response is received. + * + * @param response the instrumented response + */ void onHttpResponse(@NonNull HttpInstrumentedResponse response); } From 1ac5067a2fa0eb13d7c443a20e486e54a45231eb Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 10 Nov 2025 09:55:08 +0000 Subject: [PATCH 03/19] chore(http): formatting & api-dump for the http api module --- .../api/bugsnag-android-http-api.api | 40 +++++++++++++++++++ .../http/HttpInstrumentationBuilder.java | 31 +++++++++----- 2 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 bugsnag-android-http-api/api/bugsnag-android-http-api.api diff --git a/bugsnag-android-http-api/api/bugsnag-android-http-api.api b/bugsnag-android-http-api/api/bugsnag-android-http-api.api new file mode 100644 index 0000000000..4fac624972 --- /dev/null +++ b/bugsnag-android-http-api/api/bugsnag-android-http-api.api @@ -0,0 +1,40 @@ +public abstract interface class com/bugsnag/android/http/HttpInstrumentationBuilder { + public abstract fun addHttpErrorCode (I)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun addHttpErrorCodes (II)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun addRequestCallback (Lcom/bugsnag/android/http/HttpRequestCallback;)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun addResponseCallback (Lcom/bugsnag/android/http/HttpResponseCallback;)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun logBreadcrumbs ()Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun logBreadcrumbs (Z)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun maxRequestBodyCapture (J)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun maxResponseBodyCapture (J)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun removeHttpErrorCode (I)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public abstract fun removeHttpErrorCodes (II)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; +} + +public abstract interface class com/bugsnag/android/http/HttpInstrumentedRequest { + public abstract fun getReportedRequestBody ()Ljava/lang/String; + public abstract fun getReportedUrl ()Ljava/lang/String; + public abstract fun getRequest ()Ljava/lang/Object; + public abstract fun setReportedRequestBody (Ljava/lang/String;)V + public abstract fun setReportedUrl (Ljava/lang/String;)V +} + +public abstract interface class com/bugsnag/android/http/HttpInstrumentedResponse { + public abstract fun getReportedResponseBody ()Ljava/lang/String; + public abstract fun getRequest ()Ljava/lang/Object; + public abstract fun getResponse ()Ljava/lang/Object; + public abstract fun isBreadcrumbReported ()Z + public abstract fun isErrorReported ()Z + public abstract fun setBreadcrumbReported (Z)V + public abstract fun setErrorReported (Z)V + public abstract fun setReportedResponseBody (Ljava/lang/String;)V +} + +public abstract interface class com/bugsnag/android/http/HttpRequestCallback { + public abstract fun onHttpRequest (Lcom/bugsnag/android/http/HttpInstrumentedRequest;)V +} + +public abstract interface class com/bugsnag/android/http/HttpResponseCallback { + public abstract fun onHttpResponse (Lcom/bugsnag/android/http/HttpInstrumentedResponse;)V +} + diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java index 705d5a2c9a..f1c0116b3d 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java @@ -20,7 +20,8 @@ public interface HttpInstrumentationBuilder { * @return this */ @NonNull - HttpInstrumentationBuilder addHttpErrorCode(@IntRange(from = 0) int statusCode); + HttpInstrumentationBuilder addHttpErrorCode( + @IntRange(from = 0) int statusCode); /** * Mark the given HTTP response status code range as an error. Error responses are automatically @@ -32,8 +33,9 @@ public interface HttpInstrumentationBuilder { * @return this */ @NonNull - HttpInstrumentationBuilder addHttpErrorCodes(@IntRange(from = 0) int minStatusCode, - @IntRange(from = 0) int maxStatusCode); + HttpInstrumentationBuilder addHttpErrorCodes( + @IntRange(from = 0) int minStatusCode, + @IntRange(from = 0) int maxStatusCode); /** * Un-mark the given HTTP response status code as an error. @@ -42,7 +44,8 @@ HttpInstrumentationBuilder addHttpErrorCodes(@IntRange(from = 0) int minSt * @return this */ @NonNull - HttpInstrumentationBuilder removeHttpErrorCode(@IntRange(from = 0) int statusCode); + HttpInstrumentationBuilder removeHttpErrorCode( + @IntRange(from = 0) int statusCode); /** * Un-mark the given HTTP response status code range as an error. @@ -52,8 +55,9 @@ HttpInstrumentationBuilder addHttpErrorCodes(@IntRange(from = 0) int minSt * @return this */ @NonNull - HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int minStatusCode, - @IntRange(from = 0) int maxStatusCode); + HttpInstrumentationBuilder removeHttpErrorCodes( + @IntRange(from = 0) int minStatusCode, + @IntRange(from = 0) int maxStatusCode); /** * Define the maximum number of bytes that can be captured from the request body (when one @@ -63,7 +67,8 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi * @return this */ @NonNull - HttpInstrumentationBuilder maxRequestBodyCapture(@IntRange(from = 0) long maxBytes); + HttpInstrumentationBuilder maxRequestBodyCapture( + @IntRange(from = 0) long maxBytes); /** * Define the maximum number of bytes that can be captured from the response body (when one @@ -73,7 +78,8 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi * @return this */ @NonNull - HttpInstrumentationBuilder maxResponseBodyCapture(@IntRange(from = 0) long maxBytes); + HttpInstrumentationBuilder maxResponseBodyCapture( + @IntRange(from = 0) long maxBytes); /** * Shorthand for {@link #logBreadcrumbs(boolean) logBreadcrumbs(true)}. @@ -96,7 +102,8 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi * @return this */ @NonNull - HttpInstrumentationBuilder logBreadcrumbs(boolean logBreadcrumbs); + HttpInstrumentationBuilder logBreadcrumbs( + boolean logBreadcrumbs); /** * Add a callback to be invoked before an HTTP request is sent. This can be used to modify @@ -107,7 +114,8 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi * @return this */ @NonNull - HttpInstrumentationBuilder addRequestCallback(@NonNull HttpRequestCallback callback); + HttpInstrumentationBuilder addRequestCallback( + @NonNull HttpRequestCallback callback); /** * Add a callback to be invoked after an HTTP response is received. This can be used to modify @@ -119,6 +127,7 @@ HttpInstrumentationBuilder removeHttpErrorCodes(@IntRange(from = 0) int mi * @return this */ @NonNull - HttpInstrumentationBuilder addResponseCallback(@NonNull HttpResponseCallback callback); + HttpInstrumentationBuilder addResponseCallback( + @NonNull HttpResponseCallback callback); } From 57279d8ca2014d61808aebd18bf07d46194cb4ca Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 11 Nov 2025 12:01:53 +0000 Subject: [PATCH 04/19] feat(okhttp): deprecated BugsnagOkHttpPlugin and replaced it with BugsnagOkHttp --- bugsnag-android-core/detekt-baseline.xml | 1 - .../api/bugsnag-plugin-android-okhttp.api | 26 ++ .../build.gradle.kts | 1 + .../detekt-baseline.xml | 6 + .../com/bugsnag/android/BreadcrumbHooks.kt | 6 +- .../bugsnag/android/okhttp/BugsnagOkHttp.kt | 153 ++++++++ .../okhttp/BugsnagOkHttpInterceptor.kt | 299 ++++++++++++++ .../android/okhttp/BugsnagOkHttpPlugin.kt | 1 + ...BugsnagOkHttpInterceptorBreadcrumbsTest.kt | 370 ++++++++++++++++++ .../android/BugsnagOkHttpPluginTest.kt | 2 + .../android/CachedRequestIntegrationTest.kt | 2 + ...CachedRequestInterceptorIntegrationTest.kt | 100 +++++ .../android/ClonedRequestInterceptorTest.kt | 356 +++++++++++++++++ .../com/bugsnag/android/ClonedRequestTest.kt | 2 + .../android/ComplexRequestIntegrationTest.kt | 2 + ...omplexRequestInterceptorIntegrationTest.kt | 81 ++++ .../android/DelegateEventListenerTest.kt | 2 + .../GetRequestInterceptorIntegrationTest.kt | 109 ++++++ .../android/NetworkBreadcrumbRequest.kt | 2 + .../PostRequestInterceptorIntegrationTest.kt | 74 ++++ .../RedirectedRequestIntegrationTest.kt | 2 + ...rectedRequestInterceptorIntegrationTest.kt | 78 ++++ 22 files changed, 1673 insertions(+), 2 deletions(-) create mode 100644 bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt create mode 100644 bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpInterceptorBreadcrumbsTest.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestInterceptorIntegrationTest.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestInterceptorTest.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestInterceptorIntegrationTest.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/GetRequestInterceptorIntegrationTest.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/PostRequestInterceptorIntegrationTest.kt create mode 100644 bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestInterceptorIntegrationTest.kt diff --git a/bugsnag-android-core/detekt-baseline.xml b/bugsnag-android-core/detekt-baseline.xml index 7a176d6815..c5e9b3eb3c 100644 --- a/bugsnag-android-core/detekt-baseline.xml +++ b/bugsnag-android-core/detekt-baseline.xml @@ -64,7 +64,6 @@ MagicNumber:JsonHelper.kt$JsonHelper$8 MagicNumber:JsonStream.kt$JsonStream$128 MagicNumber:JsonStream.kt$JsonStream$32 - MagicNumber:JsonStream.kt$JsonStream.Companion$128 MagicNumber:LastRunInfoStore.kt$LastRunInfoStore$3 MagicNumber:SessionStore.kt$SessionStore$60 MaxLineLength:LastRunInfo.kt$LastRunInfo$return "LastRunInfo(consecutiveLaunchCrashes=$consecutiveLaunchCrashes, crashed=$crashed, crashedDuringLaunch=$crashedDuringLaunch)" diff --git a/bugsnag-plugin-android-okhttp/api/bugsnag-plugin-android-okhttp.api b/bugsnag-plugin-android-okhttp/api/bugsnag-plugin-android-okhttp.api index 95e3883256..4411cf56fd 100644 --- a/bugsnag-plugin-android-okhttp/api/bugsnag-plugin-android-okhttp.api +++ b/bugsnag-plugin-android-okhttp/api/bugsnag-plugin-android-okhttp.api @@ -1,3 +1,29 @@ +public final class com/bugsnag/android/okhttp/BugsnagOkHttp : com/bugsnag/android/http/HttpInstrumentationBuilder { + public fun ()V + public synthetic fun addHttpErrorCode (I)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun addHttpErrorCode (I)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun addHttpErrorCodes (II)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun addHttpErrorCodes (II)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun addRequestCallback (Lcom/bugsnag/android/http/HttpRequestCallback;)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun addRequestCallback (Lcom/bugsnag/android/http/HttpRequestCallback;)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun addResponseCallback (Lcom/bugsnag/android/http/HttpResponseCallback;)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun addResponseCallback (Lcom/bugsnag/android/http/HttpResponseCallback;)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public final fun createInterceptor ()Lokhttp3/Interceptor; + public final fun createInterceptor (Lcom/bugsnag/android/Client;)Lokhttp3/Interceptor; + public synthetic fun logBreadcrumbs ()Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun logBreadcrumbs ()Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun logBreadcrumbs (Z)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun logBreadcrumbs (Z)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun maxRequestBodyCapture (J)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun maxRequestBodyCapture (J)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun maxResponseBodyCapture (J)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun maxResponseBodyCapture (J)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun removeHttpErrorCode (I)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun removeHttpErrorCode (I)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; + public synthetic fun removeHttpErrorCodes (II)Lcom/bugsnag/android/http/HttpInstrumentationBuilder; + public fun removeHttpErrorCodes (II)Lcom/bugsnag/android/okhttp/BugsnagOkHttp; +} + public final class com/bugsnag/android/okhttp/BugsnagOkHttpPlugin : com/bugsnag/android/okhttp/util/DelegateEventListener, com/bugsnag/android/Plugin { public fun ()V public fun (Lokhttp3/EventListener;)V diff --git a/bugsnag-plugin-android-okhttp/build.gradle.kts b/bugsnag-plugin-android-okhttp/build.gradle.kts index 0cbabbdcc5..6eedc26170 100644 --- a/bugsnag-plugin-android-okhttp/build.gradle.kts +++ b/bugsnag-plugin-android-okhttp/build.gradle.kts @@ -64,6 +64,7 @@ android { dependencies { api(libs.bundles.common.api) add("api", project(":bugsnag-android-core")) + add("api", project(":bugsnag-android-http-api")) add("compileOnly", "com.squareup.okhttp3:okhttp:4.9.1") { exclude(group = "org.jetbrains.kotlin") diff --git a/bugsnag-plugin-android-okhttp/detekt-baseline.xml b/bugsnag-plugin-android-okhttp/detekt-baseline.xml index 98fe83ae93..9a42dc5b2f 100644 --- a/bugsnag-plugin-android-okhttp/detekt-baseline.xml +++ b/bugsnag-plugin-android-okhttp/detekt-baseline.xml @@ -2,6 +2,12 @@ + LongParameterList:BugsnagOkHttpInterceptor.kt$BugsnagOkHttpInterceptor$( private val errorCodes: BitSet, private var maxRequestBodyCapture: Long, private var maxResponseBodyCapture: Long, private var logRequestBreadcrumbs: Boolean, private val requestCallbacks: List<HttpRequestCallback<Request>>, private val responseCallbacks: List<HttpResponseCallback<Request, Response>>, private val clientSource: () -> Client?, private val timeProvider: () -> Long = { SystemClock.elapsedRealtime() } ) + LongParameterList:BugsnagOkHttpInterceptorBreadcrumbsTest.kt$BugsnagOkHttpInterceptorBreadcrumbsTest$( client: Client, request: Request.Builder, response: MockResponse? = null, path: String = "/test", breadcrumbsEnabled: Boolean = true, configureErrorCodes: ((BugsnagOkHttp) -> BugsnagOkHttp)? = null ) + MagicNumber:BugsnagOkHttpInterceptor.kt$BugsnagOkHttpInterceptor$100 + MagicNumber:BugsnagOkHttpInterceptor.kt$BugsnagOkHttpInterceptor$399 + MagicNumber:BugsnagOkHttpInterceptor.kt$BugsnagOkHttpInterceptor$400 + MagicNumber:BugsnagOkHttpInterceptor.kt$BugsnagOkHttpInterceptor$499 MagicNumber:BugsnagOkHttpPlugin.kt$100 MagicNumber:BugsnagOkHttpPlugin.kt$399 MagicNumber:BugsnagOkHttpPlugin.kt$400 diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/BreadcrumbHooks.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/BreadcrumbHooks.kt index 6081d6288a..8f9db7b0b0 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/BreadcrumbHooks.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/BreadcrumbHooks.kt @@ -1,3 +1,7 @@ package com.bugsnag.android -internal fun Client.shouldDiscardNetworkBreadcrumb() = config.shouldDiscardBreadcrumb(BreadcrumbType.REQUEST) +internal fun Client.shouldDiscardNetworkBreadcrumb() = + config.shouldDiscardBreadcrumb(BreadcrumbType.REQUEST) + +internal val Client.log + get() = this.logger diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt new file mode 100644 index 0000000000..a81f2e371f --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt @@ -0,0 +1,153 @@ +package com.bugsnag.android.okhttp + +import com.bugsnag.android.Bugsnag +import com.bugsnag.android.Client +import com.bugsnag.android.http.HttpInstrumentationBuilder +import com.bugsnag.android.http.HttpRequestCallback +import com.bugsnag.android.http.HttpResponseCallback +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.util.BitSet +import kotlin.math.max + +/** + * This class builds [Interceptor] integrations for OkHttp that can be configured to capture requests + * as breadcrumbs and/or errors. + * + * To enable this functionality use [createInterceptor] to create an interceptor and use + * [addInterceptor](okhttp3.OkHttpClient.Builder.addInterceptor) to configure it with your + * `OkHttpClient`. + */ +class BugsnagOkHttp : HttpInstrumentationBuilder { + private val errorCodes = BitSet() + private var maxRequestBodyCapture = DEFAULT_BODY_CAPTURE_SIZE + private var maxResponseBodyCapture = DEFAULT_BODY_CAPTURE_SIZE + private var logRequestBreadcrumbs = false + private val requestCallbacks = ArrayList>() + private val responseCallbacks = ArrayList>() + + override fun addHttpErrorCode(statusCode: Int): BugsnagOkHttp { + errorCodes.set(statusCode) + return this + } + + override fun addHttpErrorCodes( + minStatusCode: Int, + maxStatusCode: Int + ): BugsnagOkHttp { + val start = max(0, minStatusCode) + val end = max(start, maxStatusCode + 1) + + if (start < end) { + errorCodes.set(start, end) + } + + return this + } + + override fun removeHttpErrorCode(statusCode: Int): BugsnagOkHttp { + errorCodes.clear(statusCode) + return this + } + + override fun removeHttpErrorCodes( + minStatusCode: Int, + maxStatusCode: Int + ): BugsnagOkHttp { + val start = max(0, minStatusCode) + val end = max(start, maxStatusCode + 1) + + if (start < end) { + errorCodes.clear(start, end) + } + + return this + } + + override fun maxRequestBodyCapture(maxBytes: Long): BugsnagOkHttp { + maxRequestBodyCapture = max(maxBytes, 0L) + return this + } + + override fun maxResponseBodyCapture(maxBytes: Long): BugsnagOkHttp { + maxResponseBodyCapture = max(maxBytes, 0L) + return this + } + + override fun logBreadcrumbs(): BugsnagOkHttp { + return logBreadcrumbs(true) + } + + override fun logBreadcrumbs(logBreadcrumbs: Boolean): BugsnagOkHttp { + logRequestBreadcrumbs = logBreadcrumbs + return this + } + + override fun addRequestCallback(callback: HttpRequestCallback): BugsnagOkHttp { + @Suppress("SENSELESS_COMPARISON") + if (callback != null) { + requestCallbacks.add(callback) + } + + return this + } + + override fun addResponseCallback(callback: HttpResponseCallback): BugsnagOkHttp { + @Suppress("SENSELESS_COMPARISON") + if (callback != null) { + responseCallbacks.add(callback) + } + + return this + } + + /** + * Create an OkHttp `Interceptor` based on the current config of this `BugsnagOkHttp` instance. + * The new `Interceptor` should be added using [okhttp3.OkHttpClient.Builder.addInterceptor] + * to instrument the configured requests. + * + * The `Interceptor` returned here will sent all breadcrumbs and errors to the global + * [Bugsnag.getClient]. + */ + fun createInterceptor(): Interceptor { + // shortcut if Bugsnag is already started, we can skip the check on every call + if (Bugsnag.isStarted()) { + return createInterceptor(Bugsnag.getClient()) + } + + return createInterceptor { + if (Bugsnag.isStarted()) { + Bugsnag.getClient() + } else { + null + } + } + } + + /** + * Create an OkHttp `Interceptor` based on the current config of this `BugsnagOkHttp` instance, + * and send all its breadcrumbs and errors to the given [client]. The new `Interceptor` should + * be added using [okhttp3.OkHttpClient.Builder.addInterceptor] to instrument the configured + * requests. + */ + fun createInterceptor(client: Client): Interceptor { + return createInterceptor { client } + } + + private fun createInterceptor(clientSupplier: () -> Client?): Interceptor { + return BugsnagOkHttpInterceptor( + errorCodes, + maxRequestBodyCapture, + maxResponseBodyCapture, + logRequestBreadcrumbs, + requestCallbacks.toList(), + responseCallbacks.toList(), + clientSupplier + ) + } + + internal companion object { + private const val DEFAULT_BODY_CAPTURE_SIZE = 4096L + } +} diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt new file mode 100644 index 0000000000..bf90527442 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -0,0 +1,299 @@ +package com.bugsnag.android.okhttp + +import android.os.SystemClock +import com.bugsnag.android.BreadcrumbType +import com.bugsnag.android.Client +import com.bugsnag.android.Logger +import com.bugsnag.android.http.HttpInstrumentedRequest +import com.bugsnag.android.http.HttpInstrumentedResponse +import com.bugsnag.android.http.HttpRequestCallback +import com.bugsnag.android.http.HttpResponseCallback +import com.bugsnag.android.log +import com.bugsnag.android.shouldDiscardNetworkBreadcrumb +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okio.Buffer +import java.util.BitSet + +internal class BugsnagOkHttpInterceptor( + private val errorCodes: BitSet, + private var maxRequestBodyCapture: Long, + private var maxResponseBodyCapture: Long, + private var logRequestBreadcrumbs: Boolean, + private val requestCallbacks: List>, + private val responseCallbacks: List>, + private val clientSource: () -> Client?, + private val timeProvider: () -> Long = { SystemClock.elapsedRealtime() } +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val client = clientSource() ?: return chain.proceed(request) + val logger = client.log + + val instrumentedRequest = OkHttpInstrumentedRequest(request, maxRequestBodyCapture) + runRequestCallbacks(logger, instrumentedRequest) + + val startTimeMs = timeProvider() + val response = runCatching { chain.proceed(request) } + val endTimeMs = timeProvider() + try { + val instrumentedResponse = OkHttpInstrumentedResponse( + request = request, + response = response.getOrNull(), + errorCodes = errorCodes, + maxResponseBodyCapture = maxResponseBodyCapture, + reportBreadcrumb = logRequestBreadcrumbs + ) + + runResponseCallbacks(logger, instrumentedResponse) + + actionConfiguredInstrumentation( + client, + instrumentedRequest, + instrumentedResponse, + endTimeMs - startTimeMs + ) + } catch (_: Exception) { + } + + return response.getOrThrow() + } + + private fun runRequestCallbacks(logger: Logger, req: OkHttpInstrumentedRequest) { + if (requestCallbacks.isEmpty()) { + return + } + + requestCallbacks.forEach { callback -> + try { + callback.onHttpRequest(req) + } catch (ex: Exception) { + logger.w("HttpRequestCallback threw an exception", ex) + } + } + } + + private fun runResponseCallbacks(logger: Logger, resp: OkHttpInstrumentedResponse) { + if (responseCallbacks.isEmpty()) { + return + } + + responseCallbacks.forEach { callback -> + try { + callback.onHttpResponse(resp) + } catch (ex: Exception) { + logger.w("HttpResponseCallback threw an exception", ex) + } + } + } + + private fun actionConfiguredInstrumentation( + client: Client, + req: OkHttpInstrumentedRequest, + resp: OkHttpInstrumentedResponse, + durationMs: Long + ) { + if (resp.isBreadcrumbReported && !client.shouldDiscardNetworkBreadcrumb()) { + val okHttpResponse = resp.response + val statusCode = okHttpResponse?.code ?: 0 + val isResponseError = statusCode in 400..499 || errorCodes[statusCode] + val message = when { + isResponseError -> "OkHttp call error" + statusCode in 100..399 -> "OkHttp call succeeded" + else -> "OkHttp call failed" + } + + client.leaveBreadcrumb( + message, + collateMetadata(req, resp, durationMs), + BreadcrumbType.REQUEST + ) + } + } + + private fun collateMetadata( + req: OkHttpInstrumentedRequest, + resp: OkHttpInstrumentedResponse, + duration: Long + ): Map { + val data = LinkedHashMap() + data["method"] = req.request.method + req.reportedUrl?.let { data["url"] = it } + + val queryParams = buildQueryParams(req.request) + if (queryParams.isNotEmpty()) { + data["urlParams"] = queryParams + } + + data["requestContentLength"] = req.request.body?.contentLength() ?: 0L + + data["duration"] = duration + + resp.response?.code?.let { data["status"] = it } + data["responseContentLength"] = resp.response?.body?.contentLength() ?: 0L + + return data + } + + /** + * Constructs a map of query parameters, redacting any sensitive values. + */ + private fun buildQueryParams(request: Request): Map { + val url = request.url + val params = mutableMapOf() + + url.queryParameterNames.forEach { name -> + val values = url.queryParameterValues(name) + when (values.size) { + 1 -> params[name] = values.first() + else -> params[name] = url.queryParameterValues(name) + } + } + return params + } +} + +private class OkHttpInstrumentedRequest( + private val request: Request, + private val maxRequestBodyCapture: Long, +) : HttpInstrumentedRequest { + + private var reportedUrl: String? = sanitizeUrl(request.url) + + private var reportedRequestBody: String? = null + private var isRequestBodySet: Boolean = false + + override fun getRequest(): Request = request + + override fun getReportedUrl(): String? = reportedUrl + + override fun setReportedUrl(reportedUrl: String?) { + this.reportedUrl = reportedUrl + } + + override fun getReportedRequestBody(): String? { + if (isRequestBodySet) { + return reportedRequestBody + } else { + reportedRequestBody = extractRequestBody() + isRequestBodySet = true + return reportedRequestBody + } + } + + private fun extractRequestBody(): String? { + val body = request.body ?: return null + + if (maxRequestBodyCapture <= 0) { + return null + } + + // Don't read one-shot or duplex bodies as they can only be consumed once + // and reading them here would break the actual HTTP request + if (body.isOneShot() || body.isDuplex()) { + return null + } + + return try { + val buffer = Buffer() + body.writeTo(buffer) + + // Limit the capture to maxRequestBodyCapture bytes + val bytesToRead = minOf(buffer.size, maxRequestBodyCapture) + buffer.readUtf8(bytesToRead) + } catch (_: Exception) { + // If we can't read the body (e.g., it's not text or already consumed), return null + null + } + } + + /** + * Sanitizes the URL by removing query params. + */ + private fun sanitizeUrl(url: HttpUrl): String { + val builder = url.newBuilder() + + url.queryParameterNames.forEach { name -> + builder.removeAllQueryParameters(name) + } + return builder.build().toString() + } + + override fun setReportedRequestBody(requestBody: String?) { + reportedRequestBody = requestBody + isRequestBodySet = true + } +} + +private class OkHttpInstrumentedResponse( + private val request: Request, + private val response: Response?, + errorCodes: BitSet, + private val maxResponseBodyCapture: Long, + private var reportBreadcrumb: Boolean, +) : HttpInstrumentedResponse { + private var reportedResponseBody: String? = null + private var isResponseBodySet = false + + private var isErrorReported = response != null && errorCodes[response.code] + + override fun getRequest(): Request = request + override fun getResponse(): Response? = response + + override fun setBreadcrumbReported(isBreadcrumbReported: Boolean) { + reportBreadcrumb = isBreadcrumbReported + } + + override fun isBreadcrumbReported(): Boolean { + return reportBreadcrumb + } + + override fun setErrorReported(isErrorReported: Boolean) { + this.isErrorReported = isErrorReported + } + + override fun isErrorReported(): Boolean { + return isErrorReported + } + + override fun getReportedResponseBody(): String? { + if (isResponseBodySet) { + return reportedResponseBody + } else { + reportedResponseBody = extractResponseBody() + isResponseBodySet = true + return reportedResponseBody + } + } + + override fun setReportedResponseBody(responseBody: String?) { + reportedResponseBody = responseBody + isResponseBodySet = true + } + + private fun extractResponseBody(): String? { + val body = response?.body ?: return null + + if (maxResponseBodyCapture <= 0) { + return null + } + + return try { + // Use peekBody to read without consuming the actual response body + // This creates a copy of the body bytes that can be read safely + val peekedBody = body.source().peek() + + // Request the data we want to read + peekedBody.request(maxResponseBodyCapture) + + // Read up to maxResponseBodyCapture bytes + val bytesToRead = minOf(peekedBody.buffer.size, maxResponseBodyCapture) + peekedBody.buffer.clone().readUtf8(bytesToRead) + } catch (_: Exception) { + // If we can't read the body (e.g., it's not text or an error occurred), return null + null + } + } +} diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpPlugin.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpPlugin.kt index b0f27d1443..1da6380e37 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpPlugin.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpPlugin.kt @@ -25,6 +25,7 @@ import java.util.concurrent.ConcurrentHashMap * OkHttp connection and prevent breadcrumbs from being collected. For further information, see: * https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/#the-response-body-must-be-closed */ +@Deprecated("Replaced by BugsnagOkHttp") class BugsnagOkHttpPlugin @JvmOverloads constructor( delegateEventListener: EventListener? = null, internal val timeProvider: () -> Long = { System.currentTimeMillis() } diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpInterceptorBreadcrumbsTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpInterceptorBreadcrumbsTest.kt new file mode 100644 index 0000000000..a2e030ce02 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpInterceptorBreadcrumbsTest.kt @@ -0,0 +1,370 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner +import java.io.IOException + +/** + * Tests for breadcrumb functionality using the BugsnagOkHttp interceptor instead of EventListener. + */ +@RunWith(MockitoJUnitRunner::class) +class BugsnagOkHttpInterceptorBreadcrumbsTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + `when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Performs a simple GET request with a 200 response using interceptor. + */ + @Test + fun getRequest200WithInterceptor() { + val request = Request.Builder() + val expectedResponseBody = "hello, world!" + val mockResponse = MockResponse().setBody(expectedResponseBody) + val url = makeInterceptorBreadcrumbRequest(client, request, mockResponse) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(expectedResponseBody.length.toLong(), get("responseContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(url, get("url")) + } + } + + /** + * Performs a GET request to a non-existent resource (404) using interceptor. + */ + @Test + fun getRequest404WithInterceptor() { + val request = Request.Builder() + val expectedResponseBody = "Resource not found" + val mockResponse = MockResponse().setResponseCode(404).setBody(expectedResponseBody) + val path = "/a/9?lang=en&darkMode=true&count=5" + val url = makeInterceptorBreadcrumbRequest(client, request, mockResponse, path) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call error"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(404, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(expectedResponseBody.length.toLong(), get("responseContentLength")) + assertTrue(get("duration") is Long) + assertEquals( + mapOf( + "lang" to "en", + "darkMode" to "true", + "count" to "5" + ), + get("urlParams") + ) + assertEquals(url.substringBefore("?"), get("url")) + } + } + + /** + * Performs a GET request that triggers an internal server error using interceptor. + */ + @Test + fun getRequest500WithInterceptor() { + val request = Request.Builder() + val expectedResponseBody = "Internal server error." + val mockResponse = MockResponse().setBody(expectedResponseBody).setResponseCode(500) + val url = makeInterceptorBreadcrumbRequest(client, request, mockResponse) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call failed"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(500, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(expectedResponseBody.length.toLong(), get("responseContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(url, get("url")) + } + } + + /** + * Verifies a long endpoint + URL params are not truncated when using interceptor. + */ + @Test + fun longEndpointNotTruncatedWithInterceptor() { + val request = Request.Builder() + val mockResponse = MockResponse() + val path = "/test/endpoints/fifty-nine/spaghetti/custom_resource/foo/123409/amp/shoot/" + + "the-biggest-bar.aspx?lang=en&highlighted=Hello%20World" + + "&something_very_very_very_very_very_long=something_very_very_very_very_very_big" + val url = makeInterceptorBreadcrumbRequest(client, request, mockResponse, path) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals(url.substringBefore("?"), get("url")) + assertEquals( + mapOf( + "lang" to "en", + "highlighted" to "Hello World", + "something_very_very_very_very_very_long" to "something_very_very_very_very_very_big" + ), + get("urlParams") + ) + } + } + + /** + * Performs a simple HEAD request with a 429 response using interceptor. + */ + @Test + fun headRequest429WithInterceptor() { + val request = Request.Builder().head() + val mockResponse = MockResponse().setResponseCode(429).setBody("Rate limited") + val url = makeInterceptorBreadcrumbRequest(client, request, mockResponse) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call error"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("HEAD", get("method")) + assertEquals(429, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(0L, get("responseContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(url, get("url")) + } + } + + /** + * Tests that network errors (IOException) generate error breadcrumbs using interceptor. + */ + @Test + fun networkErrorWithInterceptor() { + val server = MockWebServer() + server.start() + val baseUrl = server.url("/test") + + // Close server immediately to simulate network error + server.shutdown() + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val request = Request.Builder().url(baseUrl).build() + val call = okHttpClient.newCall(request) + try { + call.execute().close() + } catch (ignored: IOException) { + // Expected for this test + } + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call failed"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(0L, get("requestContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(baseUrl.toString(), get("url")) + // No status code for network errors + assertNull(get("status")) + } + } + + /** + * Tests that breadcrumbs are not logged when disabled using interceptor. + */ + @Test + fun breadcrumbsDisabledWithInterceptor() { + val request = Request.Builder() + val mockResponse = MockResponse().setBody("hello, world!") + + // Create interceptor with breadcrumbs disabled + makeInterceptorBreadcrumbRequest( + client, + request, + mockResponse, + breadcrumbsEnabled = false + ) + + // Verify no breadcrumb was logged + verify(client, times(0)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + } + + /** + * Tests that breadcrumbs are not logged when REQUEST breadcrumb type is disabled. + */ + @Test + fun requestBreadcrumbTypeDisabledWithInterceptor() { + val cfg = Configuration("api-key").apply { enabledBreadcrumbTypes = emptySet() } + `when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig(cfg)) + + val request = Request.Builder() + val mockResponse = MockResponse().setBody("hello, world!") + makeInterceptorBreadcrumbRequest(client, request, mockResponse) + + // Verify no breadcrumb was logged when REQUEST type is disabled + verify(client, times(0)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + } + + /** + * Tests custom error codes configured on the interceptor. + */ + @Test + fun customErrorCodesWithInterceptor() { + val request = Request.Builder() + val mockResponse = MockResponse().setResponseCode(418).setBody("I'm a teapot") + + // Configure 418 as an error code + val url = makeInterceptorBreadcrumbRequest( + client, + request, + mockResponse, + configureErrorCodes = { it.addHttpErrorCode(418) } + ) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call error"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(418, get("status")) + assertEquals(0L, get("requestContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(url, get("url")) + } + } + + /** + * Tests that default error codes (4xx, 5xx) work with interceptor. + */ + @Test + fun defaultErrorCodesWithInterceptor() { + // Test with 404 (should be treated as error by default based on errorCodes BitSet) + val request = Request.Builder() + val mockResponse = MockResponse().setResponseCode(404).setBody("Not found") + + // Configure default error codes + val url = makeInterceptorBreadcrumbRequest( + client, + request, + mockResponse, + configureErrorCodes = { it.addHttpErrorCodes(400, 599) } + ) + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call error"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(404, get("status")) + assertEquals(url, get("url")) + } + } + + /** + * Helper function to make HTTP requests with the BugsnagOkHttp interceptor. + */ + private fun makeInterceptorBreadcrumbRequest( + client: Client, + request: Request.Builder, + response: MockResponse? = null, + path: String = "/test", + breadcrumbsEnabled: Boolean = true, + configureErrorCodes: ((BugsnagOkHttp) -> BugsnagOkHttp)? = null + ): String { + val server = MockWebServer().apply { + response?.let(this::enqueue) + start() + } + val baseUrl = server.url(path) + + var bugsnagOkHttp = BugsnagOkHttp() + if (breadcrumbsEnabled) { + bugsnagOkHttp = bugsnagOkHttp.logBreadcrumbs() + } + + configureErrorCodes?.let { + bugsnagOkHttp = it(bugsnagOkHttp) + } + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val req = request.url(baseUrl).build() + val call = okHttpClient.newCall(req) + try { + call.execute().close() + } catch (ignored: IOException) { + // Expected for some tests + } + server.shutdown() + return baseUrl.toString() + } +} diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpPluginTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpPluginTest.kt index 809e993f40..8b2ff9b754 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpPluginTest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/BugsnagOkHttpPluginTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestIntegrationTest.kt index d9212303ea..cab20d1744 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestIntegrationTest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestIntegrationTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestInterceptorIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestInterceptorIntegrationTest.kt new file mode 100644 index 0000000000..26f795a6d1 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/CachedRequestInterceptorIntegrationTest.kt @@ -0,0 +1,100 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner +import kotlin.io.path.createTempDirectory + +@RunWith(MockitoJUnitRunner::class) +class CachedRequestInterceptorIntegrationTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + Mockito.`when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Performs a GET request with caching enabled using BugsnagOkHttp interceptor. + * A breadcrumb is collected for successful cached requests. + */ + @Test + fun getRequestCachedSuccess() { + val server = MockWebServer().apply { + enqueue( + MockResponse() + .setResponseCode(200) + .addHeader("Cache-Control", "max-age=300") + .setBody("hello, world!") + ) + } + val baseUrl = server.url("/test") + val cacheDir = createTempDirectory().toFile() + val cache = Cache(cacheDir, 1024 * 1024) + + val bugsnagOkHttp = BugsnagOkHttp().logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .cache(cache) + .build() + + val req = Request.Builder().url(baseUrl).build() + + // First request - should hit server and cache response + val execute1 = okHttpClient.newCall(req).execute() + execute1.close() + + // Second request - should be served from cache + val execute2 = okHttpClient.newCall(req).execute() + execute2.close() + + server.shutdown() + cacheDir.deleteRecursively() + + verify(client, times(2)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + val capturedValues = mapCaptor.allValues + with(capturedValues[0]) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + assertTrue(get("responseContentLength") is Long) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(server.url("/test").toString(), get("url")) + } + + with(capturedValues[1]) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(server.url("/test").toString(), get("url")) + } + } +} diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestInterceptorTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestInterceptorTest.kt new file mode 100644 index 0000000000..0044578d7b --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestInterceptorTest.kt @@ -0,0 +1,356 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.BufferedSink +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +/** + * Tests for cloned requests and special request body types using the BugsnagOkHttp interceptor. + */ +@RunWith(MockitoJUnitRunner::class) +class ClonedRequestInterceptorTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + Mockito.`when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Tests that cloned requests are handled correctly using interceptor. + */ + @Test + fun clonedRequestWithInterceptor() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("Original response")) + enqueue(MockResponse().setBody("Cloned response")) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val originalRequest = Request.Builder() + .url(server.url("/original")) + .addHeader("X-Original", "true") + .build() + + val clonedRequest = originalRequest.newBuilder() + .url(server.url("/cloned")) + .addHeader("X-Cloned", "true") + .build() + + // Execute both requests + okHttpClient.newCall(originalRequest).execute().close() + okHttpClient.newCall(clonedRequest).execute().close() + + server.shutdown() + + // Should get 2 breadcrumbs + verify(client, times(2)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + val capturedValues = mapCaptor.allValues + + // Both should be GET requests with 200 status + capturedValues.forEach { breadcrumb -> + assertEquals("GET", breadcrumb["method"]) + assertEquals(200, breadcrumb["status"]) + } + + // URLs should be different + assertEquals(server.url("/original").toString(), capturedValues[0]["url"]) + assertEquals(server.url("/cloned").toString(), capturedValues[1]["url"]) + } + + /** + * Tests one-shot request body handling using interceptor. + */ + @Test + fun oneShotRequestBodyWithInterceptor() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("OK")) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + .maxRequestBodyCapture(1024L) + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + // Create a one-shot request body + val oneShotBody = object : RequestBody() { + override fun contentType() = "text/plain".toMediaType() + override fun isOneShot() = true + override fun contentLength() = 5L + override fun writeTo(sink: BufferedSink) { + sink.writeUtf8("hello") + } + } + + val request = Request.Builder() + .url(server.url("/test")) + .post(oneShotBody) + .build() + + okHttpClient.newCall(request).execute().close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + with(mapCaptor.value) { + assertEquals("POST", get("method")) + assertEquals(200, get("status")) + assertEquals(5L, get("requestContentLength")) + // One-shot body content should not be captured in request body + } + } + + /** + * Tests request body with unknown content length using interceptor. + */ + @Test + fun unknownContentLengthRequestBodyWithInterceptor() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("OK")) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + // Create request body with unknown content length + val unknownLengthBody = object : RequestBody() { + override fun contentType() = "text/plain".toMediaType() + override fun contentLength() = -1L // Unknown length + override fun writeTo(sink: BufferedSink) { + sink.writeUtf8("content with unknown length") + } + } + + val request = Request.Builder() + .url(server.url("/test")) + .post(unknownLengthBody) + .build() + + okHttpClient.newCall(request).execute().close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + with(mapCaptor.value) { + assertEquals("POST", get("method")) + assertEquals(200, get("status")) + assertEquals(-1L, get("requestContentLength")) + assertEquals(2L, get("responseContentLength")) + } + } + + /** + * Tests empty request body handling using interceptor. + */ + @Test + fun emptyRequestBodyWithInterceptor() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("OK")) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val request = Request.Builder() + .url(server.url("/test")) + .post("".toRequestBody("text/plain".toMediaType())) + .build() + + okHttpClient.newCall(request).execute().close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + with(mapCaptor.value) { + assertEquals("POST", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + } + } + + /** + * Tests request with no body (null body) using interceptor. + */ + @Test + fun nullRequestBodyWithInterceptor() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("OK")) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val request = Request.Builder() + .url(server.url("/test")) + .get() // GET requests have no body + .build() + + okHttpClient.newCall(request).execute().close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + } + } + + /** + * Tests response body with unknown content length using interceptor. + */ + @Test + fun unknownResponseContentLengthWithInterceptor() { + val server = MockWebServer().apply { + enqueue( + MockResponse() + .setBody("Response content") + .removeHeader("Content-Length") // Remove content-length header + .setHeader("Transfer-Encoding", "chunked") + ) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val request = Request.Builder() + .url(server.url("/test")) + .build() + + okHttpClient.newCall(request).execute().close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + // Response content length might be -1 for chunked encoding + } + } + + /** + * Tests interceptor with network call that throws exception during processing. + */ + @Test + fun exceptionDuringResponseProcessingWithInterceptor() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("OK")) + start() + } + + val bugsnagOkHttp = BugsnagOkHttp() + .logBreadcrumbs() + + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val request = Request.Builder() + .url(server.url("/test")) + .build() + + try { + okHttpClient.newCall(request).execute().close() + } catch (ignored: Exception) { + // Ignore any exceptions for this test + } + + server.shutdown() + + // Should still get breadcrumb even if there are exceptions during processing + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + any(), + eq(BreadcrumbType.REQUEST) + ) + } +} diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestTest.kt index 1cf2e13db4..e223b53d6a 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestTest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ClonedRequestTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestIntegrationTest.kt index 7a44eed37e..5249c4d6bc 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestIntegrationTest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestIntegrationTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestInterceptorIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestInterceptorIntegrationTest.kt new file mode 100644 index 0000000000..38d1fd2dba --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/ComplexRequestInterceptorIntegrationTest.kt @@ -0,0 +1,81 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class ComplexRequestInterceptorIntegrationTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + Mockito.`when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Performs a complex GET request using BugsnagOkHttp interceptor. + * A breadcrumb is collected for successful requests. + */ + @Test + fun complexGetRequestSuccess() { + val server = MockWebServer().apply { + enqueue( + MockResponse() + .setBody("hello, world!") + .addHeader("X-Custom-Header", "custom-value") + ) + } + val baseUrl = server.url("/complex/test?param1=value1¶m2=value2") + val bugsnagOkHttp = BugsnagOkHttp().logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val req = Request.Builder() + .url(baseUrl) + .addHeader("User-Agent", "Test-Client/1.0") + .addHeader("Accept", "application/json") + .build() + val execute = okHttpClient.newCall(req).execute() + execute.close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(13L, get("responseContentLength")) + assertTrue(get("duration") is Long) + @Suppress("UNCHECKED_CAST") + val params = get("urlParams") as Map + assertEquals("value1", params["param1"]) + assertEquals("value2", params["param2"]) + assertEquals(server.url("/complex/test").toString(), get("url")) + } + } +} diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/DelegateEventListenerTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/DelegateEventListenerTest.kt index 1abc04282a..90ba81f50b 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/DelegateEventListenerTest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/DelegateEventListenerTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/GetRequestInterceptorIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/GetRequestInterceptorIntegrationTest.kt new file mode 100644 index 0000000000..93516ffe8f --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/GetRequestInterceptorIntegrationTest.kt @@ -0,0 +1,109 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class GetRequestInterceptorIntegrationTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + Mockito.`when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Performs a GET request using BugsnagOkHttp interceptor. + * A breadcrumb is collected for successful requests. + */ + @Test + fun getRequestSuccess() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("hello, world!")) + } + val baseUrl = server.url("/test") + val bugsnagOkHttp = BugsnagOkHttp().logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val req = Request.Builder().url(baseUrl).build() + val execute = okHttpClient.newCall(req).execute() + execute.close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(13L, get("responseContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(server.url("/test").toString(), get("url")) + } + } + + /** + * Performs a GET request with query parameters using BugsnagOkHttp interceptor. + * A breadcrumb is collected with url params populated. + */ + @Test + fun getRequestWithParamsSuccess() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("hello, world!")) + } + val baseUrl = server.url("/test?id=123456&user=test") + val bugsnagOkHttp = BugsnagOkHttp().logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val req = Request.Builder().url(baseUrl).build() + val execute = okHttpClient.newCall(req).execute() + execute.close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(13L, get("responseContentLength")) + assertTrue(get("duration") is Long) + @Suppress("UNCHECKED_CAST") + val params = get("urlParams") as Map + assertEquals("test", params["user"]) + assertEquals("123456", params["id"]) + assertEquals(server.url("/test").toString(), get("url")) + } + } +} diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/NetworkBreadcrumbRequest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/NetworkBreadcrumbRequest.kt index 7b6ae89c33..921551f103 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/NetworkBreadcrumbRequest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/NetworkBreadcrumbRequest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/PostRequestInterceptorIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/PostRequestInterceptorIntegrationTest.kt new file mode 100644 index 0000000000..2c580ca659 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/PostRequestInterceptorIntegrationTest.kt @@ -0,0 +1,74 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class PostRequestInterceptorIntegrationTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + Mockito.`when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Performs a POST request using BugsnagOkHttp interceptor. + * A breadcrumb is collected for successful requests. + */ + @Test + fun postRequestSuccess() { + val server = MockWebServer().apply { + enqueue(MockResponse().setBody("hello, world!")) + } + val baseUrl = server.url("/test") + val bugsnagOkHttp = BugsnagOkHttp().logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val requestBody = "test-data".toRequestBody("application/json".toMediaType()) + val req = Request.Builder().url(baseUrl).post(requestBody).build() + val execute = okHttpClient.newCall(req).execute() + execute.close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("POST", get("method")) + assertEquals(200, get("status")) + assertEquals(9L, get("requestContentLength")) + assertEquals(13L, get("responseContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(server.url("/test").toString(), get("url")) + } + } +} diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestIntegrationTest.kt index be405a6660..5477ff172d 100644 --- a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestIntegrationTest.kt +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestIntegrationTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.bugsnag.android import com.bugsnag.android.okhttp.BugsnagOkHttpPlugin diff --git a/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestInterceptorIntegrationTest.kt b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestInterceptorIntegrationTest.kt new file mode 100644 index 0000000000..bb3f64bd0c --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/test/java/com/bugsnag/android/RedirectedRequestInterceptorIntegrationTest.kt @@ -0,0 +1,78 @@ +package com.bugsnag.android + +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class RedirectedRequestInterceptorIntegrationTest { + + @Mock + lateinit var client: Client + + @Captor + lateinit var mapCaptor: ArgumentCaptor> + + @Before + fun setup() { + Mockito.`when`(client.config).thenReturn(BugsnagTestUtils.generateImmutableConfig()) + } + + /** + * Performs a GET request that follows a redirect using BugsnagOkHttp interceptor. + * A breadcrumb is collected for the last call in the OkHttp chain. + */ + @Test + fun getRequestRedirectSuccess() { + // create a redirect and then the actual response + val expectedResponseBodyContent = "hello, world!" + val server = MockWebServer().apply { + enqueue( + MockResponse() + .setResponseCode(301) + .addHeader("Location", url("/foo")) + ) + enqueue(MockResponse().setBody(expectedResponseBodyContent)) + } + val baseUrl = server.url("/test") + val bugsnagOkHttp = BugsnagOkHttp().logBreadcrumbs() + val interceptor = bugsnagOkHttp.createInterceptor(client) + val okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val req = Request.Builder().url(baseUrl).build() + val execute = okHttpClient.newCall(req).execute() + execute.close() + server.shutdown() + + verify(client, times(1)).leaveBreadcrumb( + eq("OkHttp call succeeded"), + mapCaptor.capture(), + eq(BreadcrumbType.REQUEST) + ) + with(mapCaptor.value) { + assertEquals("GET", get("method")) + assertEquals(200, get("status")) + assertEquals(0L, get("requestContentLength")) + assertEquals(expectedResponseBodyContent.length.toLong(), get("responseContentLength")) + assertTrue(get("duration") is Long) + assertNull(get("urlParams")) + assertEquals(server.url("/test").toString(), get("url")) + } + } +} From 4c6b4e867eb7a4099a75ff2c175c4796c12d362a Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 25 Nov 2025 16:22:07 +0000 Subject: [PATCH 05/19] feat(http): added Request/Response as optional properties on an Event --- .../api/bugsnag-android-core.api | 36 +++ .../java/com/bugsnag/android/RequestTest.kt | 130 +++++++++++ .../bugsnag/android/AbstractHttpEntity.java | 111 ++++++++++ .../main/java/com/bugsnag/android/Event.java | 154 +++++++++---- .../java/com/bugsnag/android/EventInternal.kt | 7 + .../java/com/bugsnag/android/Request.java | 205 ++++++++++++++++++ .../java/com/bugsnag/android/Response.java | 42 ++++ .../com/bugsnag/android/SeverityReason.java | 4 +- 8 files changed, 644 insertions(+), 45 deletions(-) create mode 100644 bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt create mode 100644 bugsnag-android-core/src/main/java/com/bugsnag/android/AbstractHttpEntity.java create mode 100644 bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java create mode 100644 bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index 692f6d1675..91856f93ea 100644 --- a/bugsnag-android-core/api/bugsnag-android-core.api +++ b/bugsnag-android-core/api/bugsnag-android-core.api @@ -1,3 +1,15 @@ +public abstract class com/bugsnag/android/AbstractHttpEntity { + protected final field headers Ljava/util/Map; + public fun addHeader (Ljava/lang/String;Ljava/lang/String;)V + public fun getBody ()Ljava/lang/String; + public fun getBodyLength ()J + public fun getHeader (Ljava/lang/String;)Ljava/lang/String; + public fun getHeaderNames ()Ljava/util/Set; + public fun removeHeader (Ljava/lang/String;)V + public fun setBody (Ljava/lang/String;)V + public fun setBodyLength (J)V +} + public class com/bugsnag/android/App : com/bugsnag/android/JsonStream$Streamable { public final fun getBinaryArch ()Ljava/lang/String; public final fun getBuildUuid ()Ljava/lang/String; @@ -411,6 +423,8 @@ public class com/bugsnag/android/Event : com/bugsnag/android/FeatureFlagAware, c public fun getMetadata (Ljava/lang/String;)Ljava/util/Map; public fun getMetadata (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; public fun getOriginalError ()Ljava/lang/Throwable; + public fun getRequest ()Lcom/bugsnag/android/Request; + public fun getResponse ()Lcom/bugsnag/android/Response; public fun getSeverity ()Lcom/bugsnag/android/Severity; public fun getThreads ()Ljava/util/List; public fun getUser ()Lcom/bugsnag/android/User; @@ -424,6 +438,8 @@ public class com/bugsnag/android/Event : com/bugsnag/android/FeatureFlagAware, c public fun setErrorReportingThread (Lcom/bugsnag/android/Thread;)V public fun setGroupingDiscriminator (Ljava/lang/String;)Ljava/lang/String; public fun setGroupingHash (Ljava/lang/String;)V + public fun setRequest (Lcom/bugsnag/android/Request;)V + public fun setResponse (Lcom/bugsnag/android/Response;)V public fun setSeverity (Lcom/bugsnag/android/Severity;)V public fun setTraceCorrelation (Ljava/util/UUID;J)V public fun setUnhandled (Z)V @@ -641,6 +657,26 @@ public abstract interface class com/bugsnag/android/Plugin { public abstract fun unload ()V } +public final class com/bugsnag/android/Request : com/bugsnag/android/AbstractHttpEntity, com/bugsnag/android/JsonStream$Streamable { + public fun addQueryParameter (Ljava/lang/String;Ljava/lang/String;)V + public fun getHttpMethod ()Ljava/lang/String; + public fun getHttpVersion ()Ljava/lang/String; + public fun getQueryParameter (Ljava/lang/String;)Ljava/lang/String; + public fun getQueryParameterNames ()Ljava/util/Set; + public fun getUrl ()Ljava/lang/String; + public fun removeQueryParameter (Ljava/lang/String;)V + public fun setHttpMethod (Ljava/lang/String;)V + public fun setHttpVersion (Ljava/lang/String;)V + public fun setUrl (Ljava/lang/String;)V + public fun toStream (Lcom/bugsnag/android/JsonStream;)V +} + +public final class com/bugsnag/android/Response : com/bugsnag/android/AbstractHttpEntity, com/bugsnag/android/JsonStream$Streamable { + public fun getStatusCode ()I + public fun setStatusCode (I)V + public fun toStream (Lcom/bugsnag/android/JsonStream;)V +} + public final class com/bugsnag/android/Session : com/bugsnag/android/Deliverable, com/bugsnag/android/JsonStream$Streamable, com/bugsnag/android/UserAware { public fun getApiKey ()Ljava/lang/String; public fun getApp ()Lcom/bugsnag/android/App; diff --git a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt new file mode 100644 index 0000000000..4657908ef6 --- /dev/null +++ b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt @@ -0,0 +1,130 @@ +package com.bugsnag.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RequestTest { + private val testBodyString = "this is a body string with some content" + private val customBodyLength = 5000L + + private val logger = NoopLogger + + @Test + fun urlQueryIsExtracted() { + val request = Request( + logger, + "GET", + "1.1", + "http://localhost/test?t1=arg1&test2=argument+2" + ) + + assertEquals("http://localhost/test", request.url) + assertEquals("arg1", request.getQueryParameter("t1")) + assertEquals("argument 2", request.getQueryParameter("test2")) + assertEquals(setOf("t1", "test2"), request.queryParameterNames) + } + + @Test + fun setBody() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.body = testBodyString + assertEquals(testBodyString, request.body) + } + + @Test + fun setBodyWithUserLength() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.bodyLength = customBodyLength + request.body = testBodyString + + assertEquals(testBodyString, request.body) + assertEquals(customBodyLength, request.bodyLength) + } + + @Test + fun setNullBody() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.body = testBodyString + request.body = null + assertNull(request.body) + } + + @Test + fun setHttpMethod() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + assertEquals("GET", request.httpMethod) + + request.httpMethod = "POST" + assertEquals("POST", request.httpMethod) + } + + @Test + fun setHttpVersion() { + val request = Request(logger, "1.1", "1.1", "http://localhost/") + assertEquals("1.1", request.httpVersion) + + request.httpVersion = "1.0" + assertEquals("1.0", request.httpVersion) + } + + @Test + fun setUrl() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + assertEquals("http://localhost/", request.url) + + request.url = "https://google.com" + assertEquals("https://google.com", request.url) + } + + @Test + fun setUrlWithQuery() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.url = "http://foo.com?a=1&b=2" + assertEquals("http://foo.com", request.url) + assertEquals("1", request.getQueryParameter("a")) + assertEquals("2", request.getQueryParameter("b")) + assertEquals(setOf("a", "b"), request.queryParameterNames) + } + + @Test + fun queryParameters() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.addQueryParameter("foo", "bar") + request.addQueryParameter("another", "param") + + assertEquals("bar", request.getQueryParameter("foo")) + assertEquals("param", request.getQueryParameter("another")) + assertEquals(setOf("foo", "another"), request.queryParameterNames) + + request.removeQueryParameter("foo") + assertNull(request.getQueryParameter("foo")) + assertEquals(setOf("another"), request.queryParameterNames) + } + + @Test + fun headers() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.addHeader("X-Test", "value") + request.addHeader("Another-Header", "another-value") + + assertEquals("X-Test", request.getHeader("X-Test")) + assertEquals("Another-Header", request.getHeader("Another-Header")) + assertEquals(setOf("X-Test", "Another-Header"), request.headerNames) + + request.removeHeader("X-Test") + assertEquals("", request.getHeader("X-Test")) + assertEquals(setOf("Another-Header"), request.headerNames) + } + + @Test + fun setBodyLength() { + val request = Request(logger, "GET", "1.1", "http://localhost/") + request.bodyLength = 1234 + assertEquals(1234, request.bodyLength) + + // test negative value is ignored + request.bodyLength = -5 + assertEquals(1234, request.bodyLength) + } +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/AbstractHttpEntity.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/AbstractHttpEntity.java new file mode 100644 index 0000000000..79bd16790e --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/AbstractHttpEntity.java @@ -0,0 +1,111 @@ +package com.bugsnag.android; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +@SuppressWarnings("ConstantValue") +public abstract class AbstractHttpEntity { + protected final Map headers = new LinkedHashMap<>(); + + @Nullable + private String body; + private long bodyLength = -1L; + + // package-protected constructor + AbstractHttpEntity() { + } + + /** + * Add a header to this reported HTTP entity. + * + * @param name the name of the header + * @param value the value of the header + */ + public void addHeader(@NonNull String name, @NonNull String value) { + if (name == null || value == null) { + return; + } + + headers.put(name, value); + } + + /** + * Remove the specified header by name (case-sensitive). + * + * @param name the header to remove + */ + public void removeHeader(@NonNull String name) { + headers.remove(name); + } + + /** + * Return the headers that are set for this HTTP entity. + * + * @return the header names + */ + @NonNull + public Set getHeaderNames() { + return Collections.unmodifiableSet(headers.keySet()); + } + + /** + * Return the HTTP header by name or an empty string if the header is not present. + * + * @param headerName the header name (case-sensitive) + * @return the value of the header or an empty string + */ + @NonNull + public String getHeader(@NonNull String headerName) { + if (headerName == null) { + return ""; + } + + String headerValue = headers.get(headerName); + return headerValue != null ? headerName : ""; + } + + /** + * Return the captured HTTP body if one has been set, or null. + * + * @return the captured HTTP body + */ + @Nullable + public String getBody() { + return body; + } + + /** + * Set or clear the captured body. + * + * @param body the body to report + */ + public void setBody(@Nullable String body) { + this.body = body; + } + + /** + * Return the body length (as set with {@link #setBodyLength(long)}) or -1 if none has been set. + * + * @return the number of bytes in the body + */ + public long getBodyLength() { + return bodyLength; + } + + /** + * Change the reported size of the request body size (in bytes). + * + * @param bodyLength the number of bytes in the request body + */ + public void setBodyLength(@IntRange(from = 0L) long bodyLength) { + if (bodyLength >= 0) { + this.bodyLength = bodyLength; + } + } +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java index 2c32fe84f7..58b5d047f5 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java @@ -49,7 +49,7 @@ public class Event implements JsonStream.Streamable, MetadataAware, UserAware, F } private void logNull(String property) { - logger.e("Invalid null value supplied to config." + property + ", ignoring"); + logger.e("Invalid null value supplied to event." + property + ", ignoring"); } /** @@ -533,6 +533,115 @@ public void setTraceCorrelation(@NonNull UUID traceId, long spanId) { } } + /** + * Returns the delivery strategy for this event, which determines how the event + * should be delivered to the Bugsnag API. + * + * @return the delivery strategy, or null if no specific strategy is set + */ + @NonNull + public DeliveryStrategy getDeliveryStrategy() { + if (impl.getDeliveryStrategy() != null) { + return impl.getDeliveryStrategy(); + } + + if (impl.getOriginalUnhandled()) { + String severityReasonType = impl.getSeverityReasonType(); + boolean promiseRejection = REASON_PROMISE_REJECTION.equals(severityReasonType); + boolean anr = impl.isAnr(this); + if (anr || promiseRejection) { + return DeliveryStrategy.STORE_AND_FLUSH; + } else if (impl.isAttemptDeliveryOnCrash()) { + return DeliveryStrategy.STORE_AND_SEND; + } else { + return DeliveryStrategy.STORE_ONLY; + } + } else { + return DeliveryStrategy.SEND_IMMEDIATELY; + } + } + + /** + * Sets the delivery strategy for this event, which determines how the event + * should be delivered to the Bugsnag API. This allows customization of delivery + * behavior on a per-event basis. + * + * @param deliveryStrategy the delivery strategy to use for this event + */ + public void setDeliveryStrategy(@NonNull DeliveryStrategy deliveryStrategy) { + if (deliveryStrategy != null) { + impl.setDeliveryStrategy(deliveryStrategy); + } else { + logNull("deliveryStrategy"); + } + } + + /** + * Returns the HTTP request associated with this event, if any. This represents + * the HTTP request that was being processed when the event occurred. + * + * The request object contains information such as the HTTP method, URL, headers, + * and query parameters. This can be useful for understanding the context of errors + * that occur during HTTP request handling. + * + * @return the HTTP request, or null if no request is associated with this event + * @see Request + * @see #setRequest(Request) + */ + @Nullable + public Request getRequest() { + return impl.getRequest(); + } + + /** + * Associates an HTTP request with this event. This should represent the HTTP request + * that was being processed when the event occurred. + * + * Setting request information can help with debugging by providing context about + * the HTTP request that led to the error. Set this to null to clear any previously + * associated request. + * + * @param request the HTTP request to associate with this event, or null to clear it + * @see Request + * @see #getRequest() + */ + public void setRequest(@Nullable Request request) { + impl.setRequest(request); + } + + /** + * Returns the HTTP response associated with this event, if any. This represents + * the HTTP response that was being generated when the event occurred. + * + * The response object contains information such as the HTTP status code, headers, + * and body length. This can be useful for understanding the context of errors + * that occur during HTTP response generation. + * + * @return the HTTP response, or null if no response is associated with this event + * @see Response + * @see #setResponse(Response) + */ + @Nullable + public Response getResponse() { + return impl.getResponse(); + } + + /** + * Associates an HTTP response with this event. This should represent the HTTP response + * that was being generated when the event occurred. + * + * Setting response information can help with debugging by providing context about + * the HTTP response generation that led to the error. Set this to null to clear any + * previously associated response. + * + * @param response the HTTP response to associate with this event, or null to clear it + * @see Response + * @see #setResponse(Response) + */ + public void setResponse(@Nullable Response response) { + impl.setResponse(response); + } + protected boolean shouldDiscardClass() { return impl.shouldDiscardClass(); } @@ -577,47 +686,4 @@ void setRedactedKeys(Collection redactedKeys) { void setInternalMetrics(InternalMetrics metrics) { impl.setInternalMetrics(metrics); } - - /** - * Returns the delivery strategy for this event, which determines how the event - * should be delivered to the Bugsnag API. - * - * @return the delivery strategy, or null if no specific strategy is set - */ - @NonNull - public DeliveryStrategy getDeliveryStrategy() { - if (impl.getDeliveryStrategy() != null) { - return impl.getDeliveryStrategy(); - } - - if (impl.getOriginalUnhandled()) { - String severityReasonType = impl.getSeverityReasonType(); - boolean promiseRejection = REASON_PROMISE_REJECTION.equals(severityReasonType); - boolean anr = impl.isAnr(this); - if (anr || promiseRejection) { - return DeliveryStrategy.STORE_AND_FLUSH; - } else if (impl.isAttemptDeliveryOnCrash()) { - return DeliveryStrategy.STORE_AND_SEND; - } else { - return DeliveryStrategy.STORE_ONLY; - } - } else { - return DeliveryStrategy.SEND_IMMEDIATELY; - } - } - - /** - * Sets the delivery strategy for this event, which determines how the event - * should be delivered to the Bugsnag API. This allows customization of delivery - * behavior on a per-event basis. - * - * @param deliveryStrategy the delivery strategy to use for this event - */ - public void setDeliveryStrategy(@NonNull DeliveryStrategy deliveryStrategy) { - if (deliveryStrategy != null) { - impl.setDeliveryStrategy(deliveryStrategy); - } else { - logNull("deliveryStrategy"); - } - } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/EventInternal.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/EventInternal.kt index 2a4d57f142..a8e2c8ff63 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/EventInternal.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/EventInternal.kt @@ -129,6 +129,9 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata var deliveryStrategy: DeliveryStrategy? = null + var request: Request? = null + var response: Response? = null + fun getUnhandledOverridden(): Boolean = severityReason.unhandledOverridden fun getOriginalUnhandled(): Boolean = severityReason.originalUnhandled @@ -173,6 +176,10 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata errors.forEach { childWriter.value(it) } childWriter.endArray() + // Write request/response info if it exists + childWriter.name("request").value(request) + childWriter.name("response").value(response) + // Write project packages childWriter.name("projectPackages") childWriter.beginArray() diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java new file mode 100644 index 0000000000..4ead4cc6d0 --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java @@ -0,0 +1,205 @@ +package com.bugsnag.android; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * A Request represents an HTTP request that forms part of an {@link Event}. + */ +@SuppressWarnings("ConstantValue") +public final class Request extends AbstractHttpEntity implements JsonStream.Streamable { + private final Logger logger; + private final Map params = new LinkedHashMap<>(); + + @NonNull + private String httpMethod; + + @NonNull + private String httpVersion; + + @NonNull + @SuppressWarnings("NotNullFieldNotInitialized") + private String url; + + Request(Logger logger, + @NonNull String httpMethod, + @NonNull String httpVersion, + @NonNull String url) { + + this.logger = logger; + this.httpMethod = httpMethod; + this.httpVersion = httpVersion; + setUrl(url); + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to request." + property + ", ignoring"); + } + + /** + * Return the HTTP method for this request (e.g. "GET"). + * + * @return the HTTP method + */ + @NonNull + public String getHttpMethod() { + return httpMethod; + } + + /** + * Set the HTTP method for this request (e.g. "GET", "POST"). + * + * @param httpMethod the HTTP method name + */ + public void setHttpMethod(@NonNull String httpMethod) { + if (httpMethod != null) { + this.httpMethod = httpMethod; + } else { + logNull("httpMethod"); + } + } + + /** + * Return the HTTP version for this request (e.g. "HTTP/1.1"). + * + * @return the HTTP version + */ + @NonNull + public String getHttpVersion() { + return httpVersion; + } + + /** + * Set the HTTP version for this request (e.g. "HTTP/1.1"). + * + * @param httpVersion the HTTP version + */ + public void setHttpVersion(@NonNull String httpVersion) { + if (httpVersion != null) { + this.httpVersion = httpVersion; + } else { + logNull("httpVersion"); + } + } + + /** + * Return the URL for this request, excluding query parameters. + * + * @return the request URL + */ + @NonNull + public String getUrl() { + return url; + } + + /** + * Set the URL for this request. If the URL contains a query string, the query parameters + * will be extracted and stored separately, and the URL will be set without the query string. + * + * @param url the request URL, optionally including query parameters + */ + public void setUrl(@NonNull String url) { + if (url != null) { + int querySeparatorIndex = url.indexOf('?'); + + if (querySeparatorIndex > 0) { + setUrlWithQueryString(url); + } else { + this.url = url; + } + } else { + this.url = ""; + this.params.clear(); + } + } + + private void setUrlWithQueryString(@NonNull String url) { + try { + Uri uri = Uri.parse(url); + + params.clear(); + for (String queryName : uri.getQueryParameterNames()) { + params.put(queryName, uri.getQueryParameter(queryName)); + } + + this.url = uri.buildUpon() + .clearQuery() + .build() + .toString(); + } catch (RuntimeException ignored) { + this.url = url; + } + } + + /** + * Add a query parameter to this reported request. + * + * @param name the name of the query parameter + * @param value the value of the query parameter + */ + public void addQueryParameter(@NonNull String name, @Nullable String value) { + if (name != null) { + params.put(name, value); + } + } + + /** + * Remove the specified query parameter by name (case-sensitive). + * + * @param name the query parameter to remove + */ + public void removeQueryParameter(@NonNull String name) { + params.remove(name); + } + + /** + * Return the query parameter names that are set for this request. + * + * @return the query parameter names + */ + @NonNull + public Set getQueryParameterNames() { + return Collections.unmodifiableSet(params.keySet()); + } + + /** + * Return the query parameter value by name or null if the parameter is not present. + * + * @param name the query parameter name (case-sensitive) + * @return the value of the query parameter or null + */ + @Nullable + public String getQueryParameter(@NonNull String name) { + return params.get(name); + } + + @Override + public void toStream(@NotNull JsonStream writer) throws IOException { + writer.beginObject(); + writer.name("httpMethod").value(httpMethod); + writer.name("httpVersion").value(httpVersion); + writer.name("url").value(url); + + writer.name("body").value(getBody()); + + long bodyLength = getBodyLength(); + if (bodyLength >= 0) { + writer.name("bodyLength").value(bodyLength); + } + + writer.name("headers").value(headers, true); + writer.name("params").value(params, true); + + writer.endObject(); + } +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java new file mode 100644 index 0000000000..425ab661f8 --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java @@ -0,0 +1,42 @@ +package com.bugsnag.android; + +import androidx.annotation.IntRange; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +/** + * A Response represents an HTTP response that forms part of an {@link Event}. + */ +public final class Response extends AbstractHttpEntity implements JsonStream.Streamable { + private int statusCode; + + Response(@IntRange(from = 0) int statusCode) { + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + + public void setStatusCode(@IntRange(from = 0) int statusCode) { + this.statusCode = statusCode; + } + + @Override + public void toStream(@NotNull JsonStream writer) throws IOException { + writer.beginObject(); + writer.name("statusCode").value(statusCode); + + writer.name("body").value(getBody()); + + long bodyLength = getBodyLength(); + if (bodyLength >= 0) { + writer.name("bodyLength").value(bodyLength); + } + + writer.name("headers").value(headers, true); + writer.endObject(); + } +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/SeverityReason.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/SeverityReason.java index e759bd6d59..5c29e67a9e 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/SeverityReason.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/SeverityReason.java @@ -15,7 +15,7 @@ final class SeverityReason implements JsonStream.Streamable { @StringDef({REASON_UNHANDLED_EXCEPTION, REASON_STRICT_MODE, REASON_HANDLED_EXCEPTION, REASON_HANDLED_ERROR, REASON_USER_SPECIFIED, REASON_CALLBACK_SPECIFIED, - REASON_PROMISE_REJECTION, REASON_LOG, REASON_SIGNAL, REASON_ANR }) + REASON_PROMISE_REJECTION, REASON_LOG, REASON_SIGNAL, REASON_ANR, REASON_HTTP_ERROR }) @Retention(RetentionPolicy.SOURCE) @interface SeverityReasonType { } @@ -30,6 +30,7 @@ final class SeverityReason implements JsonStream.Streamable { static final String REASON_SIGNAL = "signal"; static final String REASON_LOG = "log"; static final String REASON_ANR = "anrError"; + static final String REASON_HTTP_ERROR = "httpError"; @SeverityReasonType private final String severityReasonType; @@ -71,6 +72,7 @@ static SeverityReason newInstance(@SeverityReasonType String reason, return new SeverityReason(reason, WARNING, true, true, attrVal, "violationType"); case REASON_HANDLED_ERROR: case REASON_HANDLED_EXCEPTION: + case REASON_HTTP_ERROR: return new SeverityReason(reason, WARNING, false, false, null, null); case REASON_USER_SPECIFIED: case REASON_CALLBACK_SPECIFIED: From 42ff9cc1e598c5a82ef189b95d2eb13e5ff3219d Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 5 Dec 2025 10:15:50 +0000 Subject: [PATCH 06/19] feat(okhttp): add error reporting to the BugsnagOkHttp instrumentation --- .../main/java/com/bugsnag/android/Event.java | 30 +++++++++++ .../java/com/bugsnag/android/Request.java | 12 ++--- .../detekt-baseline.xml | 2 + .../com/bugsnag/android/EventRequestHelper.kt | 49 ++++++++++++++++++ .../bugsnag/android/okhttp/BugsnagOkHttp.kt | 5 +- .../okhttp/BugsnagOkHttpInterceptor.kt | 50 +++++++++++++++++-- 6 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java index 5fef78cabc..09a82e5e44 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java @@ -622,6 +622,36 @@ public void setRequest(@Nullable Request request) { impl.setRequest(request); } + /** + * Associates an HTTP request with this event. This should represent the HTTP request + * that was being processed when the event occurred. + * + * Setting request information can help with debugging by providing context about + * the HTTP request that led to the error. Set this to null to clear any previously + * associated request. + * + * @param httpMethod the HTTP method (GET, POST, etc.) to associate with this event + * @param httpVersion the HTTP version (1.1) to associate with this event + * @param url the URL to associate with this event + * @see #getRequest() + */ + public void setRequest( + @NonNull String httpMethod, + @Nullable String httpVersion, + @NonNull String url + ) { + if (httpMethod == null) { + logNull("httpMethod"); + return; + } + if (url == null) { + logNull("url"); + return; + } + + setRequest(new Request(logger, httpMethod, httpVersion, url)); + } + /** * Returns the HTTP response associated with this event, if any. This represents * the HTTP response that was being generated when the event occurred. diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java index 4ead4cc6d0..46a9e995f5 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java @@ -24,7 +24,7 @@ public final class Request extends AbstractHttpEntity implements JsonStream.Stre @NonNull private String httpMethod; - @NonNull + @Nullable private String httpVersion; @NonNull @@ -74,7 +74,7 @@ public void setHttpMethod(@NonNull String httpMethod) { * * @return the HTTP version */ - @NonNull + @Nullable public String getHttpVersion() { return httpVersion; } @@ -84,12 +84,8 @@ public String getHttpVersion() { * * @param httpVersion the HTTP version */ - public void setHttpVersion(@NonNull String httpVersion) { - if (httpVersion != null) { - this.httpVersion = httpVersion; - } else { - logNull("httpVersion"); - } + public void setHttpVersion(@Nullable String httpVersion) { + this.httpVersion = httpVersion; } /** diff --git a/bugsnag-plugin-android-okhttp/detekt-baseline.xml b/bugsnag-plugin-android-okhttp/detekt-baseline.xml index 9a42dc5b2f..667f8d2551 100644 --- a/bugsnag-plugin-android-okhttp/detekt-baseline.xml +++ b/bugsnag-plugin-android-okhttp/detekt-baseline.xml @@ -12,6 +12,8 @@ MagicNumber:BugsnagOkHttpPlugin.kt$399 MagicNumber:BugsnagOkHttpPlugin.kt$400 MagicNumber:BugsnagOkHttpPlugin.kt$599 + NestedBlockDepth:BugsnagOkHttpInterceptor.kt$OkHttpInstrumentedResponse$private fun extractResponseBody(): String? + ReturnCount:BugsnagOkHttpInterceptor.kt$OkHttpInstrumentedResponse$private fun extractResponseBody(): String? TooManyFunctions:DelegateEventListener.kt$DelegateEventListener : EventListener diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt new file mode 100644 index 0000000000..633f2087a8 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt @@ -0,0 +1,49 @@ +package com.bugsnag.android + +import com.bugsnag.android.http.HttpInstrumentedRequest +import com.bugsnag.android.http.HttpInstrumentedResponse +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import com.bugsnag.android.Response as BugsnagResponse + +internal fun Event.setHttpInfo( + instrumentedRequest: HttpInstrumentedRequest, + instrumentedResponse: HttpInstrumentedResponse? +) { + val url = instrumentedRequest.reportedUrl ?: return + setRequest( + instrumentedRequest.request.method, + instrumentedResponse?.response?.protocol.toVersionString(), + url + ) + + request?.apply { + bodyLength = instrumentedRequest.request.body?.contentLength() ?: 0L + body = instrumentedRequest.reportedRequestBody + instrumentedRequest.request.headers.forEach { (name, value) -> + addHeader(name, value) + } + } + + val okResp = instrumentedResponse?.response + if (okResp != null) { + response = BugsnagResponse(okResp.code).apply { + bodyLength = okResp.body?.contentLength() ?: 0 + body = instrumentedResponse.reportedResponseBody + okResp.headers.forEach { (name, value) -> + addHeader(name, value) + } + } + } +} + +@Suppress("DEPRECATION") +private fun Protocol?.toVersionString(): String? = when (this) { + Protocol.HTTP_1_1 -> "HTTP/1.1" + Protocol.HTTP_1_0 -> "HTTP/1.0" + Protocol.H2_PRIOR_KNOWLEDGE, Protocol.HTTP_2 -> "HTTP/2.0" + Protocol.SPDY_3 -> "SPDY/3.1" + Protocol.QUIC -> "QUIC" + null -> null +} diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt index a81f2e371f..758b127f5b 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt @@ -19,6 +19,7 @@ import kotlin.math.max * [addInterceptor](okhttp3.OkHttpClient.Builder.addInterceptor) to configure it with your * `OkHttpClient`. */ +@Suppress("SENSELESS_COMPARISON") class BugsnagOkHttp : HttpInstrumentationBuilder { private val errorCodes = BitSet() private var maxRequestBodyCapture = DEFAULT_BODY_CAPTURE_SIZE @@ -85,7 +86,6 @@ class BugsnagOkHttp : HttpInstrumentationBuilder { } override fun addRequestCallback(callback: HttpRequestCallback): BugsnagOkHttp { - @Suppress("SENSELESS_COMPARISON") if (callback != null) { requestCallbacks.add(callback) } @@ -94,7 +94,6 @@ class BugsnagOkHttp : HttpInstrumentationBuilder { } override fun addResponseCallback(callback: HttpResponseCallback): BugsnagOkHttp { - @Suppress("SENSELESS_COMPARISON") if (callback != null) { responseCallbacks.add(callback) } @@ -148,6 +147,6 @@ class BugsnagOkHttp : HttpInstrumentationBuilder { } internal companion object { - private const val DEFAULT_BODY_CAPTURE_SIZE = 4096L + private const val DEFAULT_BODY_CAPTURE_SIZE: Long = 0L } } diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt index bf90527442..305cd9d9d2 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -1,14 +1,18 @@ package com.bugsnag.android.okhttp import android.os.SystemClock +import android.util.Base64 import com.bugsnag.android.BreadcrumbType import com.bugsnag.android.Client +import com.bugsnag.android.ErrorCaptureOptions +import com.bugsnag.android.ErrorOptions import com.bugsnag.android.Logger import com.bugsnag.android.http.HttpInstrumentedRequest import com.bugsnag.android.http.HttpInstrumentedResponse import com.bugsnag.android.http.HttpRequestCallback import com.bugsnag.android.http.HttpResponseCallback import com.bugsnag.android.log +import com.bugsnag.android.setHttpInfo import com.bugsnag.android.shouldDiscardNetworkBreadcrumb import okhttp3.HttpUrl import okhttp3.Interceptor @@ -27,6 +31,14 @@ internal class BugsnagOkHttpInterceptor( private val clientSource: () -> Client?, private val timeProvider: () -> Long = { SystemClock.elapsedRealtime() } ) : Interceptor { + private val templateException by lazy { + RuntimeException("HTTP Error Placeholder") + } + + private val httpErrorOptions = ErrorOptions( + ErrorCaptureOptions(stacktrace = false) + ) + override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val client = clientSource() ?: return chain.proceed(request) @@ -111,6 +123,14 @@ internal class BugsnagOkHttpInterceptor( BreadcrumbType.REQUEST ) } + + if (resp.isErrorReported) { + client.notify(templateException, httpErrorOptions) { event -> + event.errors.clear() + event.setHttpInfo(req, resp) + true + } + } } private fun collateMetadata( @@ -274,13 +294,13 @@ private class OkHttpInstrumentedResponse( } private fun extractResponseBody(): String? { - val body = response?.body ?: return null - if (maxResponseBodyCapture <= 0) { return null } - return try { + val body = response?.peekBody(maxResponseBodyCapture) ?: return null + + try { // Use peekBody to read without consuming the actual response body // This creates a copy of the body bytes that can be read safely val peekedBody = body.source().peek() @@ -290,10 +310,30 @@ private class OkHttpInstrumentedResponse( // Read up to maxResponseBodyCapture bytes val bytesToRead = minOf(peekedBody.buffer.size, maxResponseBodyCapture) - peekedBody.buffer.clone().readUtf8(bytesToRead) + if (bytesToRead <= 0) { + return null + } + + val contentType = body.contentType() + if (contentType != null) { + if (contentType.subtype == "json" || + contentType.type == "text" || + contentType.charset(null) != null + ) { + val charset = contentType.charset(null) + return if (charset != null) { + peekedBody.readString(bytesToRead, charset) + } else { + peekedBody.readUtf8(bytesToRead) + } + } + } + + return Base64.encodeToString(peekedBody.readByteArray(bytesToRead), Base64.NO_WRAP) } catch (_: Exception) { // If we can't read the body (e.g., it's not text or an error occurred), return null - null } + + return null } } From 29821345c6bacb69c50ac5727387c52943a24f62 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 9 Dec 2025 11:26:05 +0000 Subject: [PATCH 07/19] refactor(request): gave the `Request` and `Response` classes public constructors --- .../api/bugsnag-android-core.api | 3 ++ .../main/java/com/bugsnag/android/Event.java | 30 ----------- .../java/com/bugsnag/android/Request.java | 52 ++++++++++++------- .../java/com/bugsnag/android/Response.java | 7 ++- .../com/bugsnag/android/EventRequestHelper.kt | 5 +- .../okhttp/BugsnagOkHttpInterceptor.kt | 7 +++ 6 files changed, 51 insertions(+), 53 deletions(-) diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index 72b9e28fb2..60b13cecb8 100644 --- a/bugsnag-android-core/api/bugsnag-android-core.api +++ b/bugsnag-android-core/api/bugsnag-android-core.api @@ -702,6 +702,8 @@ public abstract interface class com/bugsnag/android/Plugin { } public final class com/bugsnag/android/Request : com/bugsnag/android/AbstractHttpEntity, com/bugsnag/android/JsonStream$Streamable { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public fun addQueryParameter (Ljava/lang/String;Ljava/lang/String;)V public fun getHttpMethod ()Ljava/lang/String; public fun getHttpVersion ()Ljava/lang/String; @@ -716,6 +718,7 @@ public final class com/bugsnag/android/Request : com/bugsnag/android/AbstractHtt } public final class com/bugsnag/android/Response : com/bugsnag/android/AbstractHttpEntity, com/bugsnag/android/JsonStream$Streamable { + public fun (I)V public fun getStatusCode ()I public fun setStatusCode (I)V public fun toStream (Lcom/bugsnag/android/JsonStream;)V diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java index 09a82e5e44..5fef78cabc 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Event.java @@ -622,36 +622,6 @@ public void setRequest(@Nullable Request request) { impl.setRequest(request); } - /** - * Associates an HTTP request with this event. This should represent the HTTP request - * that was being processed when the event occurred. - * - * Setting request information can help with debugging by providing context about - * the HTTP request that led to the error. Set this to null to clear any previously - * associated request. - * - * @param httpMethod the HTTP method (GET, POST, etc.) to associate with this event - * @param httpVersion the HTTP version (1.1) to associate with this event - * @param url the URL to associate with this event - * @see #getRequest() - */ - public void setRequest( - @NonNull String httpMethod, - @Nullable String httpVersion, - @NonNull String url - ) { - if (httpMethod == null) { - logNull("httpMethod"); - return; - } - if (url == null) { - logNull("url"); - return; - } - - setRequest(new Request(logger, httpMethod, httpVersion, url)); - } - /** * Returns the HTTP response associated with this event, if any. This represents * the HTTP response that was being generated when the event occurred. diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java index 46a9e995f5..848644a14a 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java @@ -18,32 +18,48 @@ */ @SuppressWarnings("ConstantValue") public final class Request extends AbstractHttpEntity implements JsonStream.Streamable { - private final Logger logger; private final Map params = new LinkedHashMap<>(); - @NonNull + @Nullable private String httpMethod; @Nullable private String httpVersion; - @NonNull - @SuppressWarnings("NotNullFieldNotInitialized") + @Nullable private String url; - Request(Logger logger, - @NonNull String httpMethod, - @NonNull String httpVersion, - @NonNull String url) { + /** + * Constructs a new Request with the specified HTTP version, method, and URL. + * If the URL contains query parameters, they will be extracted and stored separately. + * + * @param httpVersion the HTTP version (e.g. "HTTP/1.1"), or null + * @param httpMethod the HTTP method (e.g. "GET", "POST"), or null + * @param url the request URL, optionally including query parameters, or null + */ + public Request( + @Nullable String httpVersion, + @Nullable String httpMethod, + @Nullable String url) { - this.logger = logger; this.httpMethod = httpMethod; this.httpVersion = httpVersion; setUrl(url); } - private void logNull(String property) { - logger.e("Invalid null value supplied to request." + property + ", ignoring"); + /** + * Constructs a new Request with the specified HTTP method and URL. + * The HTTP version will be set to null. + * If the URL contains query parameters, they will be extracted and stored separately. + * + * @param httpMethod the HTTP method (e.g. "GET", "POST"), or null + * @param url the request URL, optionally including query parameters, or null + */ + public Request( + @Nullable String httpMethod, + @Nullable String url) { + + this(null, httpMethod, url); } /** @@ -51,7 +67,7 @@ private void logNull(String property) { * * @return the HTTP method */ - @NonNull + @Nullable public String getHttpMethod() { return httpMethod; } @@ -61,12 +77,8 @@ public String getHttpMethod() { * * @param httpMethod the HTTP method name */ - public void setHttpMethod(@NonNull String httpMethod) { - if (httpMethod != null) { - this.httpMethod = httpMethod; - } else { - logNull("httpMethod"); - } + public void setHttpMethod(@Nullable String httpMethod) { + this.httpMethod = httpMethod; } /** @@ -93,7 +105,7 @@ public void setHttpVersion(@Nullable String httpVersion) { * * @return the request URL */ - @NonNull + @Nullable public String getUrl() { return url; } @@ -104,7 +116,7 @@ public String getUrl() { * * @param url the request URL, optionally including query parameters */ - public void setUrl(@NonNull String url) { + public void setUrl(@Nullable String url) { if (url != null) { int querySeparatorIndex = url.indexOf('?'); diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java index 425ab661f8..6f75880417 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java @@ -12,7 +12,12 @@ public final class Response extends AbstractHttpEntity implements JsonStream.Streamable { private int statusCode; - Response(@IntRange(from = 0) int statusCode) { + /** + * Constructs a new Response with the specified HTTP status code. + * + * @param statusCode the HTTP status code + */ + public Response(@IntRange(from = 0) int statusCode) { this.statusCode = statusCode; } diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt index 633f2087a8..bbaeb7f082 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt @@ -5,6 +5,7 @@ import com.bugsnag.android.http.HttpInstrumentedResponse import okhttp3.Protocol import okhttp3.Request import okhttp3.Response +import com.bugsnag.android.Request as BugsnagRequest import com.bugsnag.android.Response as BugsnagResponse internal fun Event.setHttpInfo( @@ -12,9 +13,9 @@ internal fun Event.setHttpInfo( instrumentedResponse: HttpInstrumentedResponse? ) { val url = instrumentedRequest.reportedUrl ?: return - setRequest( - instrumentedRequest.request.method, + request = BugsnagRequest( instrumentedResponse?.response?.protocol.toVersionString(), + instrumentedRequest.request.method, url ) diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt index 305cd9d9d2..4e36b58632 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -126,7 +126,14 @@ internal class BugsnagOkHttpInterceptor( if (resp.isErrorReported) { client.notify(templateException, httpErrorOptions) { event -> + val okHttpRequest = resp.request + val okHttpResponse = resp.response + + val domain = okHttpRequest.url.host + event.errors.clear() + event.addError("HTTPError", "${okHttpResponse?.code}: ${okHttpRequest.url}") + event.context = "${okHttpRequest.method} $domain" event.setHttpInfo(req, resp) true } From cb7a78f32e0bb0ba285d40164f299e10f0d85a45 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 10 Dec 2025 14:48:40 +0000 Subject: [PATCH 08/19] test(http): added e2e tests for HTTP instrumentation --- .../android/mazerunner/MainActivity.kt | 14 ++-- .../jvm-scenarios/detekt-baseline.xml | 3 + .../OkHttpInstrumentationScenario.kt | 76 ++++++++++++++++++ .../full_tests/network_breadcrumbs.feature | 25 ------ .../full_tests/okhttp_instrumentation.feature | 77 +++++++++++++++++++ 5 files changed, 165 insertions(+), 30 deletions(-) create mode 100644 features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt delete mode 100644 features/full_tests/network_breadcrumbs.feature create mode 100644 features/full_tests/okhttp_instrumentation.feature diff --git a/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt b/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt index af8b1759d2..b2d93896f2 100644 --- a/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt +++ b/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt @@ -173,7 +173,7 @@ class MainActivity : Activity() { val commandUUID = getStringSafely(command, "uuid") // Stop polling once we have a scenario action - if ("start_bugsnag".equals(action) || "run_scenario".equals(action)) { + if ("start_bugsnag" == action || "run_scenario" == action) { polling = false } @@ -189,18 +189,22 @@ class MainActivity : Activity() { "noop" -> { CiLog.info("No Maze Runner command queuing, continuing to poll") } + "start_bugsnag" -> { setStoredCommandUUID(commandUUID) startBugsnag(scenarioName, scenarioMode, sessionsUrl, notifyUrl) } + "run_scenario" -> { setStoredCommandUUID(commandUUID) runScenario(scenarioName, scenarioMode, sessionsUrl, notifyUrl) } + "clear_persistent_data" -> { setStoredCommandUUID(commandUUID) PersistentData(applicationContext).clear() } + "reset_uuid" -> clearStoredCommandUUID() else -> throw IllegalArgumentException("Unknown action: $action") } @@ -224,10 +228,10 @@ class MainActivity : Activity() { val errorMessage = urlConnection.errorStream.use { it.reader().readText() } CiLog.error( "Failed to GET $commandUrl (HTTP ${urlConnection.responseCode} " + - "${urlConnection.responseMessage}):\n" + - "${"-".repeat(errorMessage.width)}\n" + - "$errorMessage\n" + - "-".repeat(errorMessage.width) + "${urlConnection.responseMessage}):\n" + + "${"-".repeat(errorMessage.width)}\n" + + "$errorMessage\n" + + "-".repeat(errorMessage.width) ) } catch (e: Exception) { log("Failed to retrieve error message from connection", e) diff --git a/features/fixtures/mazerunner/jvm-scenarios/detekt-baseline.xml b/features/fixtures/mazerunner/jvm-scenarios/detekt-baseline.xml index 32bbce37d5..74b206bac5 100644 --- a/features/fixtures/mazerunner/jvm-scenarios/detekt-baseline.xml +++ b/features/fixtures/mazerunner/jvm-scenarios/detekt-baseline.xml @@ -17,6 +17,9 @@ MagicNumber:LoadConfigurationKotlinScenario.kt$LoadConfigurationKotlinScenario$10000 MagicNumber:LoadConfigurationKotlinScenario.kt$LoadConfigurationKotlinScenario$98 MagicNumber:ManualSessionSmokeScenario.kt$ManualSessionSmokeScenario$3 + MagicNumber:OkHttpInstrumentationScenario.kt$OkHttpInstrumentationScenario$200 + MagicNumber:OkHttpInstrumentationScenario.kt$OkHttpInstrumentationScenario$400 + MagicNumber:OkHttpInstrumentationScenario.kt$OkHttpInstrumentationScenario$599 MagicNumber:Scenario.kt$Scenario$100 MagicNumber:Scenario.kt$Scenario$1000 MagicNumber:StartupCrashFlushScenario.kt$StartupCrashFlushScenario$6000 diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt new file mode 100644 index 0000000000..520a55c3df --- /dev/null +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt @@ -0,0 +1,76 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import android.net.Uri +import com.bugsnag.android.Configuration +import com.bugsnag.android.mazerunner.log +import com.bugsnag.android.okhttp.BugsnagOkHttp +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import kotlin.concurrent.thread + +private const val MAX_CAPTURE_BYTES = 32L + +class OkHttpInstrumentationScenario( + config: Configuration, + context: Context, + eventMetadata: String? +) : Scenario(config, context, eventMetadata) { + private val instrumentation = BugsnagOkHttp() + .maxRequestBodyCapture(MAX_CAPTURE_BYTES) + .maxResponseBodyCapture(MAX_CAPTURE_BYTES) + .logBreadcrumbs() + .addHttpErrorCodes(400, 599) + + private val httpClient = OkHttpClient.Builder() + .addInterceptor(instrumentation.createInterceptor()) + .build() + + private fun reflectionUrl(status: Int): String { + return Uri.parse(config.endpoints.notify) + .buildUpon() + .path("/reflect") + .appendQueryParameter("status", status.toString()) + .build() + .toString() + } + + private fun requestType(): Pair { + val type = eventMetadata?.takeIf { it.isNotBlank() } ?: return ("GET" to 200) + val (method, status) = type.split(' ') + return method to status.toInt() + } + + override fun startScenario() { + super.startScenario() + + // background thread to avoid networking on main + thread { + val (method, status) = requestType() + val reflectionUrl = reflectionUrl(status) + + val payload = JSONObject() + payload.put("padding", "this is a string, and it goes on and on until it stops...here") + payload.put("url", reflectionUrl) + + val body = payload.toString().toRequestBody("application/json".toMediaType()) + + val requestBuilder = Request.Builder() + .url(reflectionUrl) + .method(method, body.takeIf { method != "GET" }) + + log("Sending request to $reflectionUrl") + + val call = httpClient.newCall(requestBuilder.build()) + call.execute().use { response -> + log("Received ${response.code} response code") + response.body?.use { body -> + log("Response Body: '${body.string()}'") + } + } + } + } +} diff --git a/features/full_tests/network_breadcrumbs.feature b/features/full_tests/network_breadcrumbs.feature deleted file mode 100644 index e3a389f913..0000000000 --- a/features/full_tests/network_breadcrumbs.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: Capturing network breadcrumbs - - Background: - Given I clear all persistent data - - Scenario: Breadcrumbs are captured for OkHttp network requests - When I run "NetworkBreadcrumbScenario" - And I wait to receive an error - Then the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier - And the error payload field "events" is an array with 1 elements - And the exception "errorClass" equals "java.lang.RuntimeException" - And the exception "message" equals "NetworkBreadcrumbScenario" - - And the event has 1 breadcrumbs - And the event "breadcrumbs.0.timestamp" is a timestamp - And the event "breadcrumbs.0.name" equals "OkHttp call succeeded" - And the event "breadcrumbs.0.type" equals "request" - And the event "breadcrumbs.0.metaData.method" equals "GET" - And the event "breadcrumbs.0.metaData.url" equals "https://google.com/" - And the error payload field "events.0.breadcrumbs.0.metaData.duration" is a number - And the error payload field "events.0.exceptions.0.stacktrace.0.type" is null - And the event "breadcrumbs.0.metaData.urlParams.test" equals "true" - And the error payload field "events.0.breadcrumbs.0.metaData.requestContentLength" is a number - And the error payload field "events.0.breadcrumbs.0.metaData.responseContentLength" is a number - And the error payload field "events.0.breadcrumbs.0.metaData.status" is a number diff --git a/features/full_tests/okhttp_instrumentation.feature b/features/full_tests/okhttp_instrumentation.feature new file mode 100644 index 0000000000..28e1691c95 --- /dev/null +++ b/features/full_tests/okhttp_instrumentation.feature @@ -0,0 +1,77 @@ +Feature: Capturing network breadcrumbs + + Background: + Given I clear all persistent data + + Scenario: Breadcrumbs are captured for OkHttp network requests (Legacy) + When I run "NetworkBreadcrumbScenario" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier + And the error payload field "events" is an array with 1 elements + And the exception "errorClass" equals "java.lang.RuntimeException" + And the exception "message" equals "NetworkBreadcrumbScenario" + + And the event has 1 breadcrumbs + And the event "breadcrumbs.0.timestamp" is a timestamp + And the event "breadcrumbs.0.name" equals "OkHttp call succeeded" + And the event "breadcrumbs.0.type" equals "request" + And the event "breadcrumbs.0.metaData.method" equals "GET" + And the event "breadcrumbs.0.metaData.url" equals "https://google.com/" + And the error payload field "events.0.breadcrumbs.0.metaData.duration" is a number + And the error payload field "events.0.exceptions.0.stacktrace.0.type" is null + And the event "breadcrumbs.0.metaData.urlParams.test" equals "true" + And the error payload field "events.0.breadcrumbs.0.metaData.requestContentLength" is a number + And the error payload field "events.0.breadcrumbs.0.metaData.responseContentLength" is a number + And the error payload field "events.0.breadcrumbs.0.metaData.status" is a number + + Scenario: Failed POST requests send error reports when configured + When I configure the app to run in the "POST 400" state + And I run "OkHttpInstrumentationScenario" + And I wait to receive a reflection + Then I wait to receive an error + And the error payload field "events" is an array with 1 elements + And the exception "errorClass" equals "HTTPError" + And the exception "message" matches "400: http://.+" + And the event "context" matches "POST .+" + + And the reflection payload field "url" is stored as the value "expectedUrl" + + # Validate request fields + And the event "request.httpMethod" equals "POST" + And the event "request.httpVersion" is not null + And the event "request.bodyLength" is greater than 64 + And the error payload field "events.0.request.body" equals "{\"padding\":\"this is a string, an" + And the error payload field "request.url" equals the stored value "expectedUrl" + + # Validate response fields + And the event "response.statusCode" equals 400 + And the event "response.bodyLength" is greater than 1 + And the event "response.body" is not null + + Scenario: Failed GET requests send error reports when configured + When I configure the app to run in the "GET 500" state + And I run "OkHttpInstrumentationScenario" + And I wait to receive a reflection + Then I wait to receive an error + And the error payload field "events" is an array with 1 elements + And the exception "errorClass" equals "HTTPError" + And the exception "message" matches "500: http://.+" + And the event "context" matches "GET .+" + + And the reflection payload field "url" is stored as the value "expectedUrl" + + # Validate request fields + And the event "request.httpMethod" equals "GET" + And the event "request.httpVersion" is not null + And the error payload field "request.url" equals the stored value "expectedUrl" + + # Validate response fields + And the event "response.statusCode" equals 500 + And the event "response.bodyLength" is greater than 1 + And the event "response.body" is not null + + Scenario: Successful requests do not emit errors + When I configure the app to run in the "POST 200" state + And I run "OkHttpInstrumentationScenario" + Then I wait to receive a reflection + And I should receive no errors \ No newline at end of file From 94ca8376ebf714a7d9d9c316259a8b39474534a7 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 10 Dec 2025 16:48:23 +0000 Subject: [PATCH 09/19] refactor(bodyLength): improved readability on bodyLength code --- .../okhttp/BugsnagOkHttpInterceptor.kt | 1 - .../{ => okhttp}/EventRequestHelper.kt | 19 ++++++++++++++++--- .../android/mazerunner/MainActivity.kt | 8 ++++---- .../OkHttpInstrumentationScenario.kt | 3 ++- 4 files changed, 22 insertions(+), 9 deletions(-) rename bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/{ => okhttp}/EventRequestHelper.kt (74%) diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt index 4e36b58632..98abcef91f 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -12,7 +12,6 @@ import com.bugsnag.android.http.HttpInstrumentedResponse import com.bugsnag.android.http.HttpRequestCallback import com.bugsnag.android.http.HttpResponseCallback import com.bugsnag.android.log -import com.bugsnag.android.setHttpInfo import com.bugsnag.android.shouldDiscardNetworkBreadcrumb import okhttp3.HttpUrl import okhttp3.Interceptor diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt similarity index 74% rename from bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt rename to bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt index bbaeb7f082..2538116140 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/EventRequestHelper.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt @@ -1,10 +1,13 @@ -package com.bugsnag.android +package com.bugsnag.android.okhttp +import com.bugsnag.android.Event import com.bugsnag.android.http.HttpInstrumentedRequest import com.bugsnag.android.http.HttpInstrumentedResponse import okhttp3.Protocol import okhttp3.Request import okhttp3.Response +import kotlin.math.max +import kotlin.math.min import com.bugsnag.android.Request as BugsnagRequest import com.bugsnag.android.Response as BugsnagResponse @@ -20,7 +23,7 @@ internal fun Event.setHttpInfo( ) request?.apply { - bodyLength = instrumentedRequest.request.body?.contentLength() ?: 0L + bodyLength = bodyLengthOf(instrumentedRequest.request) body = instrumentedRequest.reportedRequestBody instrumentedRequest.request.headers.forEach { (name, value) -> addHeader(name, value) @@ -30,7 +33,7 @@ internal fun Event.setHttpInfo( val okResp = instrumentedResponse?.response if (okResp != null) { response = BugsnagResponse(okResp.code).apply { - bodyLength = okResp.body?.contentLength() ?: 0 + bodyLength = bodyLengthOf(okResp) body = instrumentedResponse.reportedResponseBody okResp.headers.forEach { (name, value) -> addHeader(name, value) @@ -39,6 +42,16 @@ internal fun Event.setHttpInfo( } } +private fun bodyLengthOf(request: Request): Long { + val requestBody = request.body ?: return 0 + return max(requestBody.contentLength(), 0) +} + +private fun bodyLengthOf(response: Response): Long { + val requestBody = response.body ?: return 0 + return max(requestBody.contentLength(), 0) +} + @Suppress("DEPRECATION") private fun Protocol?.toVersionString(): String? = when (this) { Protocol.HTTP_1_1 -> "HTTP/1.1" diff --git a/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt b/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt index b2d93896f2..c24cd4039a 100644 --- a/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt +++ b/features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt @@ -228,10 +228,10 @@ class MainActivity : Activity() { val errorMessage = urlConnection.errorStream.use { it.reader().readText() } CiLog.error( "Failed to GET $commandUrl (HTTP ${urlConnection.responseCode} " + - "${urlConnection.responseMessage}):\n" + - "${"-".repeat(errorMessage.width)}\n" + - "$errorMessage\n" + - "-".repeat(errorMessage.width) + "${urlConnection.responseMessage}):\n" + + "${"-".repeat(errorMessage.width)}\n" + + "$errorMessage\n" + + "-".repeat(errorMessage.width) ) } catch (e: Exception) { log("Failed to retrieve error message from connection", e) diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt index 520a55c3df..5eeddc578a 100644 --- a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt @@ -13,6 +13,7 @@ import org.json.JSONObject import kotlin.concurrent.thread private const val MAX_CAPTURE_BYTES = 32L +private val JSON = "application/json".toMediaType() class OkHttpInstrumentationScenario( config: Configuration, @@ -56,7 +57,7 @@ class OkHttpInstrumentationScenario( payload.put("padding", "this is a string, and it goes on and on until it stops...here") payload.put("url", reflectionUrl) - val body = payload.toString().toRequestBody("application/json".toMediaType()) + val body = payload.toString().toRequestBody(JSON) val requestBuilder = Request.Builder() .url(reflectionUrl) From f1975e1b7b48dfddc7aed274d912cfcbcc68a419 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 10 Dec 2025 16:59:04 +0000 Subject: [PATCH 10/19] test(request): removed the unused Logger from RequestTest --- .../java/com/bugsnag/android/RequestTest.kt | 25 ++++++++----------- .../android/okhttp/EventRequestHelper.kt | 1 - 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt index 4657908ef6..a3dcf10bf7 100644 --- a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt +++ b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt @@ -8,14 +8,11 @@ class RequestTest { private val testBodyString = "this is a body string with some content" private val customBodyLength = 5000L - private val logger = NoopLogger - @Test fun urlQueryIsExtracted() { val request = Request( - logger, - "GET", "1.1", + "GET", "http://localhost/test?t1=arg1&test2=argument+2" ) @@ -27,14 +24,14 @@ class RequestTest { @Test fun setBody() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.body = testBodyString assertEquals(testBodyString, request.body) } @Test fun setBodyWithUserLength() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.bodyLength = customBodyLength request.body = testBodyString @@ -44,7 +41,7 @@ class RequestTest { @Test fun setNullBody() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.body = testBodyString request.body = null assertNull(request.body) @@ -52,7 +49,7 @@ class RequestTest { @Test fun setHttpMethod() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") assertEquals("GET", request.httpMethod) request.httpMethod = "POST" @@ -61,7 +58,7 @@ class RequestTest { @Test fun setHttpVersion() { - val request = Request(logger, "1.1", "1.1", "http://localhost/") + val request = Request("1.1", "1.1", "http://localhost/") assertEquals("1.1", request.httpVersion) request.httpVersion = "1.0" @@ -70,7 +67,7 @@ class RequestTest { @Test fun setUrl() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") assertEquals("http://localhost/", request.url) request.url = "https://google.com" @@ -79,7 +76,7 @@ class RequestTest { @Test fun setUrlWithQuery() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.url = "http://foo.com?a=1&b=2" assertEquals("http://foo.com", request.url) assertEquals("1", request.getQueryParameter("a")) @@ -89,7 +86,7 @@ class RequestTest { @Test fun queryParameters() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.addQueryParameter("foo", "bar") request.addQueryParameter("another", "param") @@ -104,7 +101,7 @@ class RequestTest { @Test fun headers() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.addHeader("X-Test", "value") request.addHeader("Another-Header", "another-value") @@ -119,7 +116,7 @@ class RequestTest { @Test fun setBodyLength() { - val request = Request(logger, "GET", "1.1", "http://localhost/") + val request = Request("1.1", "GET", "http://localhost/") request.bodyLength = 1234 assertEquals(1234, request.bodyLength) diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt index 2538116140..d0927a5a8a 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt @@ -7,7 +7,6 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import kotlin.math.max -import kotlin.math.min import com.bugsnag.android.Request as BugsnagRequest import com.bugsnag.android.Response as BugsnagResponse From 241a014188052fd2a9d9624c900486e108a1bf30 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 15 Dec 2025 11:48:16 +0000 Subject: [PATCH 11/19] feat(okhttp): add redacted headers & parameters to the HTTP instrumentation tests --- .../android/okhttp/EventRequestHelper.kt | 10 +++++++-- .../OkHttpInstrumentationScenario.kt | 21 +++++++++++++++---- .../full_tests/okhttp_instrumentation.feature | 10 +++++---- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt index d0927a5a8a..b0a5916a65 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt @@ -22,11 +22,17 @@ internal fun Event.setHttpInfo( ) request?.apply { - bodyLength = bodyLengthOf(instrumentedRequest.request) + val okReq = instrumentedRequest.request + bodyLength = bodyLengthOf(okReq) body = instrumentedRequest.reportedRequestBody - instrumentedRequest.request.headers.forEach { (name, value) -> + okReq.headers.forEach { (name, value) -> addHeader(name, value) } + + val queryParams = okReq.url.queryParameterNames + queryParams.forEach { queryKey -> + addQueryParameter(queryKey, okReq.url.queryParameter(queryKey)) + } } val okResp = instrumentedResponse?.response diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt index 5eeddc578a..81596034af 100644 --- a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt @@ -10,6 +10,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject +import java.util.regex.Pattern import kotlin.concurrent.thread private const val MAX_CAPTURE_BYTES = 32L @@ -30,13 +31,21 @@ class OkHttpInstrumentationScenario( .addInterceptor(instrumentation.createInterceptor()) .build() - private fun reflectionUrl(status: Int): String { + private fun reflectionUrl(status: Int): Uri { return Uri.parse(config.endpoints.notify) .buildUpon() .path("/reflect") .appendQueryParameter("status", status.toString()) + .appendQueryParameter("password", "secret") .build() - .toString() + } + + init { + config.redactedKeys = setOf( + "Cookie".toPattern(Pattern.LITERAL or Pattern.CASE_INSENSITIVE), + "Authorization".toPattern(Pattern.LITERAL or Pattern.CASE_INSENSITIVE), + ".*password.*".toPattern(Pattern.CASE_INSENSITIVE) + ) } private fun requestType(): Pair { @@ -55,12 +64,16 @@ class OkHttpInstrumentationScenario( val payload = JSONObject() payload.put("padding", "this is a string, and it goes on and on until it stops...here") - payload.put("url", reflectionUrl) + // we expect the output URL to not have a query string, so we help the scenario feature + // out by removing it in the reflection payload (allowing an "equals" match) + payload.put("url", reflectionUrl.buildUpon().clearQuery().build()) + payload.put("status", status) val body = payload.toString().toRequestBody(JSON) val requestBuilder = Request.Builder() - .url(reflectionUrl) + .url(reflectionUrl.toString()) + .header("Authorization", "Bearer OpenSesame") .method(method, body.takeIf { method != "GET" }) log("Sending request to $reflectionUrl") diff --git a/features/full_tests/okhttp_instrumentation.feature b/features/full_tests/okhttp_instrumentation.feature index 28e1691c95..2dd246a225 100644 --- a/features/full_tests/okhttp_instrumentation.feature +++ b/features/full_tests/okhttp_instrumentation.feature @@ -41,7 +41,9 @@ Feature: Capturing network breadcrumbs And the event "request.httpVersion" is not null And the event "request.bodyLength" is greater than 64 And the error payload field "events.0.request.body" equals "{\"padding\":\"this is a string, an" - And the error payload field "request.url" equals the stored value "expectedUrl" + And the error payload field "events.0.request.url" equals the stored value "expectedUrl" + And the error payload field "events.0.request.headers.Authorization" equals "[REDACTED]" + And the error payload field "events.0.request.params.password" equals "[REDACTED]" # Validate response fields And the event "response.statusCode" equals 400 @@ -58,12 +60,12 @@ Feature: Capturing network breadcrumbs And the exception "message" matches "500: http://.+" And the event "context" matches "GET .+" - And the reflection payload field "url" is stored as the value "expectedUrl" - # Validate request fields And the event "request.httpMethod" equals "GET" And the event "request.httpVersion" is not null - And the error payload field "request.url" equals the stored value "expectedUrl" + And the event "request.url" matches "^https?\:\/\/.+" + And the error payload field "events.0.request.headers.Authorization" equals "[REDACTED]" + And the error payload field "events.0.request.params.password" equals "[REDACTED]" # Validate response fields And the event "response.statusCode" equals 500 From b76703cce79990c022b89bf387b0393ea52d8167 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 9 Jan 2026 09:24:30 +0000 Subject: [PATCH 12/19] fix(http): added the `instrumentedResponse.errorCallback` so that generated HTTP Events can be customized --- .../http/HttpInstrumentedResponse.java | 19 +++++++++++++++++++ .../okhttp/BugsnagOkHttpInterceptor.kt | 14 +++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java index 88a36baa53..4a7cbb74d5 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java @@ -3,6 +3,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.bugsnag.android.OnErrorCallback; + /** * Represents an HTTP response that has been instrumented by BugSnag. This interface provides * access to the original request and response objects, as well as methods to modify the @@ -78,4 +80,21 @@ public interface HttpInstrumentedResponse { * @param responseBody the response body to report */ void setReportedResponseBody(@Nullable String responseBody); + + /** + * Set an {@code OnErrorCallback} that can customise {@link com.bugsnag.android.Event Events} + * created as a consequence to this response (when {@link #isErrorReported()} is true). Setting + * this to {@code null} will remove any existing error callback. + * + * @param onErrorCallback the error callback to customise HTTP events + */ + void setErrorCallback(@Nullable OnErrorCallback onErrorCallback); + + /** + * Return the error callback if one has been set. + * + * @return the error callback for this response + */ + @Nullable + OnErrorCallback getErrorCallback(); } diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt index 98abcef91f..5375a739d6 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -7,6 +7,7 @@ import com.bugsnag.android.Client import com.bugsnag.android.ErrorCaptureOptions import com.bugsnag.android.ErrorOptions import com.bugsnag.android.Logger +import com.bugsnag.android.OnErrorCallback import com.bugsnag.android.http.HttpInstrumentedRequest import com.bugsnag.android.http.HttpInstrumentedResponse import com.bugsnag.android.http.HttpRequestCallback @@ -134,7 +135,8 @@ internal class BugsnagOkHttpInterceptor( event.addError("HTTPError", "${okHttpResponse?.code}: ${okHttpRequest.url}") event.context = "${okHttpRequest.method} $domain" event.setHttpInfo(req, resp) - true + + return@notify resp.errorCallback?.onError(event) != false } } } @@ -265,6 +267,8 @@ private class OkHttpInstrumentedResponse( private var isErrorReported = response != null && errorCodes[response.code] + private var errorCallback: OnErrorCallback? = null + override fun getRequest(): Request = request override fun getResponse(): Response? = response @@ -299,6 +303,14 @@ private class OkHttpInstrumentedResponse( isResponseBodySet = true } + override fun setErrorCallback(onErrorCallback: OnErrorCallback?) { + this.errorCallback = onErrorCallback + } + + override fun getErrorCallback(): OnErrorCallback? { + return errorCallback + } + private fun extractResponseBody(): String? { if (maxResponseBodyCapture <= 0) { return null From d8d4bd8085e3fcb0659ed1e37d5bd5b08b9b1eaf Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 9 Jan 2026 10:20:09 +0000 Subject: [PATCH 13/19] fix(http): e2e test for HTTP OnErrorCallback --- bugsnag-android-http-api/api/bugsnag-android-http-api.api | 2 ++ .../com/bugsnag/android/http/HttpInstrumentedResponse.java | 1 - .../mazerunner/scenarios/OkHttpInstrumentationScenario.kt | 7 +++++++ features/full_tests/okhttp_instrumentation.feature | 6 ++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/bugsnag-android-http-api/api/bugsnag-android-http-api.api b/bugsnag-android-http-api/api/bugsnag-android-http-api.api index 4fac624972..bea4943ef4 100644 --- a/bugsnag-android-http-api/api/bugsnag-android-http-api.api +++ b/bugsnag-android-http-api/api/bugsnag-android-http-api.api @@ -20,12 +20,14 @@ public abstract interface class com/bugsnag/android/http/HttpInstrumentedRequest } public abstract interface class com/bugsnag/android/http/HttpInstrumentedResponse { + public abstract fun getErrorCallback ()Lcom/bugsnag/android/OnErrorCallback; public abstract fun getReportedResponseBody ()Ljava/lang/String; public abstract fun getRequest ()Ljava/lang/Object; public abstract fun getResponse ()Ljava/lang/Object; public abstract fun isBreadcrumbReported ()Z public abstract fun isErrorReported ()Z public abstract fun setBreadcrumbReported (Z)V + public abstract fun setErrorCallback (Lcom/bugsnag/android/OnErrorCallback;)V public abstract fun setErrorReported (Z)V public abstract fun setReportedResponseBody (Ljava/lang/String;)V } diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java index 4a7cbb74d5..cd1e2a4894 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java @@ -2,7 +2,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import com.bugsnag.android.OnErrorCallback; /** diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt index 81596034af..99521456ee 100644 --- a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt @@ -3,6 +3,7 @@ package com.bugsnag.android.mazerunner.scenarios import android.content.Context import android.net.Uri import com.bugsnag.android.Configuration +import com.bugsnag.android.OnErrorCallback import com.bugsnag.android.mazerunner.log import com.bugsnag.android.okhttp.BugsnagOkHttp import okhttp3.MediaType.Companion.toMediaType @@ -26,6 +27,12 @@ class OkHttpInstrumentationScenario( .maxResponseBodyCapture(MAX_CAPTURE_BYTES) .logBreadcrumbs() .addHttpErrorCodes(400, 599) + .addResponseCallback { response -> + response.errorCallback = OnErrorCallback { event -> + event.addMetadata("OkHttpInstrumentationScenario", "onErrorCallback", true) + true + } + } private val httpClient = OkHttpClient.Builder() .addInterceptor(instrumentation.createInterceptor()) diff --git a/features/full_tests/okhttp_instrumentation.feature b/features/full_tests/okhttp_instrumentation.feature index 2dd246a225..c0a75e7bc1 100644 --- a/features/full_tests/okhttp_instrumentation.feature +++ b/features/full_tests/okhttp_instrumentation.feature @@ -50,6 +50,9 @@ Feature: Capturing network breadcrumbs And the event "response.bodyLength" is greater than 1 And the event "response.body" is not null + # Validate the event metadata + And the event "metaData.OkHttpInstrumentationScenario.onErrorCallback" is true + Scenario: Failed GET requests send error reports when configured When I configure the app to run in the "GET 500" state And I run "OkHttpInstrumentationScenario" @@ -72,6 +75,9 @@ Feature: Capturing network breadcrumbs And the event "response.bodyLength" is greater than 1 And the event "response.body" is not null + # Validate the event metadata + And the event "metaData.OkHttpInstrumentationScenario.onErrorCallback" is true + Scenario: Successful requests do not emit errors When I configure the app to run in the "POST 200" state And I run "OkHttpInstrumentationScenario" From e00a339c79874202f33a96960aa81d442a12e5ac Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 9 Jan 2026 10:45:42 +0000 Subject: [PATCH 14/19] fix(http): added e2e tests for HTTP instrumentation callbacks --- .../http/HttpInstrumentedResponse.java | 3 ++- .../OkHttpInstrumentationCallbackScenario.kt | 20 ++++++++++++++ .../OkHttpInstrumentationScenario.kt | 24 +++++++++-------- .../full_tests/okhttp_instrumentation.feature | 27 +++++++++++++++++++ 4 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationCallbackScenario.kt diff --git a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java index cd1e2a4894..ce2e58424e 100644 --- a/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java @@ -1,8 +1,9 @@ package com.bugsnag.android.http; +import com.bugsnag.android.OnErrorCallback; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.bugsnag.android.OnErrorCallback; /** * Represents an HTTP response that has been instrumented by BugSnag. This interface provides diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationCallbackScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationCallbackScenario.kt new file mode 100644 index 0000000000..d873c98219 --- /dev/null +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationCallbackScenario.kt @@ -0,0 +1,20 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import com.bugsnag.android.Configuration + +class OkHttpInstrumentationCallbackScenario( + config: Configuration, + context: Context, + eventMetadata: String? +) : OkHttpInstrumentationScenario(config, context, eventMetadata) { + override val instrumentation + get() = super.instrumentation + .addRequestCallback { request -> + request.reportedRequestBody = "testing request body" + request.reportedUrl = "http://testingUrl.bugsnag.com" + } + .addResponseCallback { response -> + response.reportedResponseBody = "testing response body" + } +} diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt index 99521456ee..07f4fd0fd7 100644 --- a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt @@ -17,22 +17,24 @@ import kotlin.concurrent.thread private const val MAX_CAPTURE_BYTES = 32L private val JSON = "application/json".toMediaType() -class OkHttpInstrumentationScenario( +open class OkHttpInstrumentationScenario( config: Configuration, context: Context, eventMetadata: String? ) : Scenario(config, context, eventMetadata) { - private val instrumentation = BugsnagOkHttp() - .maxRequestBodyCapture(MAX_CAPTURE_BYTES) - .maxResponseBodyCapture(MAX_CAPTURE_BYTES) - .logBreadcrumbs() - .addHttpErrorCodes(400, 599) - .addResponseCallback { response -> - response.errorCallback = OnErrorCallback { event -> - event.addMetadata("OkHttpInstrumentationScenario", "onErrorCallback", true) - true + + protected open val instrumentation + get() = BugsnagOkHttp() + .maxRequestBodyCapture(MAX_CAPTURE_BYTES) + .maxResponseBodyCapture(MAX_CAPTURE_BYTES) + .logBreadcrumbs() + .addHttpErrorCodes(400, 599) + .addResponseCallback { response -> + response.errorCallback = OnErrorCallback { event -> + event.addMetadata("OkHttpInstrumentationScenario", "onErrorCallback", true) + true + } } - } private val httpClient = OkHttpClient.Builder() .addInterceptor(instrumentation.createInterceptor()) diff --git a/features/full_tests/okhttp_instrumentation.feature b/features/full_tests/okhttp_instrumentation.feature index c0a75e7bc1..342fde9583 100644 --- a/features/full_tests/okhttp_instrumentation.feature +++ b/features/full_tests/okhttp_instrumentation.feature @@ -53,6 +53,33 @@ Feature: Capturing network breadcrumbs # Validate the event metadata And the event "metaData.OkHttpInstrumentationScenario.onErrorCallback" is true + Scenario: HTTP Error reporting can be modified by callbacks + When I configure the app to run in the "POST 400" state + And I run "OkHttpInstrumentationCallbackScenario" + And I wait to receive a reflection + Then I wait to receive an error + And the error payload field "events" is an array with 1 elements + And the exception "errorClass" equals "HTTPError" + And the exception "message" equals "400: http://testingUrl.bugsnag.com" + And the event "context" equals "POST testingUrl.bugsnag.com" + + # Validate request fields + And the event "request.httpMethod" equals "POST" + And the event "request.httpVersion" is not null + And the event "request.bodyLength" is greater than 64 + And the error payload field "events.0.request.body" equals "testing request body" + And the error payload field "events.0.request.url" equals the stored value "expectedUrl" + And the error payload field "events.0.request.headers.Authorization" equals "[REDACTED]" + And the error payload field "events.0.request.params.password" equals "[REDACTED]" + + # Validate response fields + And the event "response.statusCode" equals 400 + And the event "response.bodyLength" is greater than 1 + And the event "response.body" equals "testing response body" + + # Validate the event metadata + And the event "metaData.OkHttpInstrumentationScenario.onErrorCallback" is true + Scenario: Failed GET requests send error reports when configured When I configure the app to run in the "GET 500" state And I run "OkHttpInstrumentationScenario" From 05f384e498d55a8a082e19bcec0e94584a3848d8 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 9 Jan 2026 11:09:21 +0000 Subject: [PATCH 15/19] fix(http): correctly use the `reportedUrl` in the error message and host --- .../okhttp/BugsnagOkHttpInterceptor.kt | 22 +++++++++++++++---- .../full_tests/okhttp_instrumentation.feature | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt index 5375a739d6..d5810ed596 100644 --- a/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -1,5 +1,6 @@ package com.bugsnag.android.okhttp +import android.net.Uri import android.os.SystemClock import android.util.Base64 import com.bugsnag.android.BreadcrumbType @@ -129,11 +130,9 @@ internal class BugsnagOkHttpInterceptor( val okHttpRequest = resp.request val okHttpResponse = resp.response - val domain = okHttpRequest.url.host - event.errors.clear() - event.addError("HTTPError", "${okHttpResponse?.code}: ${okHttpRequest.url}") - event.context = "${okHttpRequest.method} $domain" + event.addError("HTTPError", "${okHttpResponse?.code}: ${req.reportedUrl}") + event.context = "${okHttpRequest.method} ${extractDomain(req)}" event.setHttpInfo(req, resp) return@notify resp.errorCallback?.onError(event) != false @@ -141,6 +140,21 @@ internal class BugsnagOkHttpInterceptor( } } + private fun extractDomain(req: OkHttpInstrumentedRequest): String { + val reportedUrl = req.reportedUrl + if (reportedUrl != null) { + try { + val host = Uri.parse(reportedUrl).host + if (host != null) { + return host + } + } catch (_: Exception) { + } + } + + return req.request.url.host + } + private fun collateMetadata( req: OkHttpInstrumentedRequest, resp: OkHttpInstrumentedResponse, diff --git a/features/full_tests/okhttp_instrumentation.feature b/features/full_tests/okhttp_instrumentation.feature index 342fde9583..79e8ea28f8 100644 --- a/features/full_tests/okhttp_instrumentation.feature +++ b/features/full_tests/okhttp_instrumentation.feature @@ -68,7 +68,7 @@ Feature: Capturing network breadcrumbs And the event "request.httpVersion" is not null And the event "request.bodyLength" is greater than 64 And the error payload field "events.0.request.body" equals "testing request body" - And the error payload field "events.0.request.url" equals the stored value "expectedUrl" + And the error payload field "events.0.request.url" equals "http://testingUrl.bugsnag.com" And the error payload field "events.0.request.headers.Authorization" equals "[REDACTED]" And the error payload field "events.0.request.params.password" equals "[REDACTED]" From a2537f95319ecfa8f3fd37a4d545268c4de7487f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:51:24 +0000 Subject: [PATCH 16/19] build(deps): bump actions/checkout from 6.0.1 to 6.0.2 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1220d672f..5dbe05d75e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: submodules: recursive - uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 #v5.0.0 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d2975f2383..399e3ea943 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -32,7 +32,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -76,5 +76,5 @@ jobs: name: "Checksum validation of Gradle Wrappers" runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 From ad71e738e2d884f7e7c7c5f0c30a8a071ab0a5f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:51:28 +0000 Subject: [PATCH 17/19] build(deps): bump actions/setup-java from 5.1.0 to 5.2.0 Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/f2beeb24e141e01a676f977032f5a29d81c9e27e...be666c2fcd27ec809703dec50e508c2fdc7f6654) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1220d672f..bde2b9d868 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,7 +49,7 @@ jobs: submodules: recursive - uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 #v5.0.0 - - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e #v5.1.0 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0 with: distribution: 'zulu' java-version: 17 From b4bec689a9f5a89e431a341a1d84cf5d0148b945 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:51:44 +0000 Subject: [PATCH 18/19] build(deps): bump github/codeql-action from 4.31.10 to 4.32.0 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.10 to 4.32.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/cdefb33c0f6224e58673d9004f47f7cb3e328b89...b20883b0cd1f46c72ae0ba6d1090936928f9fa30) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.32.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1220d672f..cdc329e5da 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -64,7 +64,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 #v4.31.10 + uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 #v4.32.0 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -83,6 +83,6 @@ jobs: ./gradlew --no-daemon assemble - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 #v4.31.10 + uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 #v4.32.0 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d2975f2383..9fa6e741dc 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -68,7 +68,7 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: sarif_file: results.sarif From 9f6edf1b52eaccbde875bcbbc973885070fe4732 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 27 Jan 2026 08:54:45 +0000 Subject: [PATCH 19/19] v6.23.0 --- CHANGELOG.md | 7 +++++++ .../src/main/java/com/bugsnag/android/Notifier.kt | 2 +- examples/sdk-app-example/gradle/libs.versions.toml | 2 +- gradle.properties | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f673fc80..f666d6c2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 6.23.0 (2026-01-27) + +### Enhancements + +* New `BugsnagOkHttp` instrumentation to optionally report HTTP failures as errors + [#2371](https://github.com/bugsnag/bugsnag-android/pull/2371) + ## 6.22.0 (2026-01-19) ### Enhancements diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt index 50361f02f9..708ee79e4b 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt @@ -7,7 +7,7 @@ import java.io.IOException */ class Notifier @JvmOverloads constructor( var name: String = "Android Bugsnag Notifier", - var version: String = "6.22.0", + var version: String = "6.23.0", var url: String = "https://bugsnag.com" ) : JsonStream.Streamable { diff --git a/examples/sdk-app-example/gradle/libs.versions.toml b/examples/sdk-app-example/gradle/libs.versions.toml index 3458890373..d7aeea3916 100644 --- a/examples/sdk-app-example/gradle/libs.versions.toml +++ b/examples/sdk-app-example/gradle/libs.versions.toml @@ -2,7 +2,7 @@ activityCompose = "1.8.0" agp = "8.10.0" appcompat = "1.6.1" -bugsnag-android = "6.22.0" +bugsnag-android = "6.23.0" bugsnag-gradle = "0.4.0" composeBom = "2024.09.00" coreKtx = "1.16.0" diff --git a/gradle.properties b/gradle.properties index 508b808d81..4e6b921ff0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.gradle.jvmargs=-Xmx4096m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true -VERSION_NAME=6.22.0 +VERSION_NAME=6.23.0 GROUP=com.bugsnag POM_SCM_URL=https://github.com/bugsnag/bugsnag-android POM_SCM_CONNECTION=scm:git@github.com:bugsnag/bugsnag-android.git