diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1220d672f..8e36d57c50 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -44,12 +44,12 @@ 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 - - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e #v5.1.0 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0 with: distribution: 'zulu' java-version: 17 @@ -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..ac2d86b4bd 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 @@ -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 @@ -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 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/CODEOWNERS b/CODEOWNERS index e87c8ad1e0..17e8ba20d5 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-apphang/ @lemnik @YYChen01988 bugsnag-plugin-android-exitinfo/ @lemnik @YYChen01988 diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index 20a2ba44a5..6d573db093 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; @@ -459,6 +471,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; @@ -472,6 +486,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 @@ -689,6 +705,29 @@ 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 (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; + 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 (I)V + 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..a3dcf10bf7 --- /dev/null +++ b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/RequestTest.kt @@ -0,0 +1,127 @@ +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 + + @Test + fun urlQueryIsExtracted() { + val request = Request( + "1.1", + "GET", + "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("1.1", "GET", "http://localhost/") + request.body = testBodyString + assertEquals(testBodyString, request.body) + } + + @Test + fun setBodyWithUserLength() { + val request = Request("1.1", "GET", "http://localhost/") + request.bodyLength = customBodyLength + request.body = testBodyString + + assertEquals(testBodyString, request.body) + assertEquals(customBodyLength, request.bodyLength) + } + + @Test + fun setNullBody() { + val request = Request("1.1", "GET", "http://localhost/") + request.body = testBodyString + request.body = null + assertNull(request.body) + } + + @Test + fun setHttpMethod() { + val request = Request("1.1", "GET", "http://localhost/") + assertEquals("GET", request.httpMethod) + + request.httpMethod = "POST" + assertEquals("POST", request.httpMethod) + } + + @Test + fun setHttpVersion() { + val request = Request("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("1.1", "GET", "http://localhost/") + assertEquals("http://localhost/", request.url) + + request.url = "https://google.com" + assertEquals("https://google.com", request.url) + } + + @Test + fun setUrlWithQuery() { + 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")) + assertEquals("2", request.getQueryParameter("b")) + assertEquals(setOf("a", "b"), request.queryParameterNames) + } + + @Test + fun queryParameters() { + val request = Request("1.1", "GET", "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("1.1", "GET", "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("1.1", "GET", "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 8b52c8b506..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 @@ -62,7 +62,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"); } /** @@ -546,6 +546,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(); } @@ -590,47 +699,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 54a944b7b6..a9c97aaaf3 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 @@ -140,6 +140,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 @@ -184,6 +187,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/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/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..848644a14a --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Request.java @@ -0,0 +1,213 @@ +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 Map params = new LinkedHashMap<>(); + + @Nullable + private String httpMethod; + + @Nullable + private String httpVersion; + + @Nullable + private 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.httpMethod = httpMethod; + this.httpVersion = httpVersion; + setUrl(url); + } + + /** + * 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); + } + + /** + * Return the HTTP method for this request (e.g. "GET"). + * + * @return the HTTP method + */ + @Nullable + 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(@Nullable String httpMethod) { + this.httpMethod = httpMethod; + } + + /** + * Return the HTTP version for this request (e.g. "HTTP/1.1"). + * + * @return the HTTP version + */ + @Nullable + 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(@Nullable String httpVersion) { + this.httpVersion = httpVersion; + } + + /** + * Return the URL for this request, excluding query parameters. + * + * @return the request URL + */ + @Nullable + 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(@Nullable 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..6f75880417 --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Response.java @@ -0,0 +1,47 @@ +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; + + /** + * 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; + } + + 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: 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..bea4943ef4 --- /dev/null +++ b/bugsnag-android-http-api/api/bugsnag-android-http-api.api @@ -0,0 +1,42 @@ +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 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 +} + +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/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..f1c0116b3d --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentationBuilder.java @@ -0,0 +1,133 @@ +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); + + /** + * 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); + + /** + * 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); + + /** + * 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); + + /** + * Shorthand for {@link #logBreadcrumbs(boolean) logBreadcrumbs(true)}. + * + * @return this + */ + @NonNull + HttpInstrumentationBuilder logBreadcrumbs(); + + /** + * 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. + * + * @param logBreadcrumbs true if breadcrumbs should be logged + * @return this + */ + @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 new file mode 100644 index 0000000000..912f156fb6 --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedRequest.java @@ -0,0 +1,55 @@ +package com.bugsnag.android.http; + +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 new file mode 100644 index 0000000000..ce2e58424e --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpInstrumentedResponse.java @@ -0,0 +1,100 @@ +package com.bugsnag.android.http; + +import com.bugsnag.android.OnErrorCallback; + +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); + + /** + * 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-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..deeff88852 --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpRequestCallback.java @@ -0,0 +1,20 @@ +package com.bugsnag.android.http; + +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 new file mode 100644 index 0000000000..e68dd7728a --- /dev/null +++ b/bugsnag-android-http-api/src/main/java/com/bugsnag/android/http/HttpResponseCallback.java @@ -0,0 +1,21 @@ +package com.bugsnag.android.http; + +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); +} 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..667f8d2551 100644 --- a/bugsnag-plugin-android-okhttp/detekt-baseline.xml +++ b/bugsnag-plugin-android-okhttp/detekt-baseline.xml @@ -2,10 +2,18 @@ + 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 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/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..758b127f5b --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttp.kt @@ -0,0 +1,152 @@ +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`. + */ +@Suppress("SENSELESS_COMPARISON") +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 { + if (callback != null) { + requestCallbacks.add(callback) + } + + return this + } + + override fun addResponseCallback(callback: HttpResponseCallback): BugsnagOkHttp { + 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: 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 new file mode 100644 index 0000000000..d5810ed596 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/BugsnagOkHttpInterceptor.kt @@ -0,0 +1,371 @@ +package com.bugsnag.android.okhttp + +import android.net.Uri +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.OnErrorCallback +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 { + 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) + 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 + ) + } + + if (resp.isErrorReported) { + client.notify(templateException, httpErrorOptions) { event -> + val okHttpRequest = resp.request + val okHttpResponse = resp.response + + event.errors.clear() + event.addError("HTTPError", "${okHttpResponse?.code}: ${req.reportedUrl}") + event.context = "${okHttpRequest.method} ${extractDomain(req)}" + event.setHttpInfo(req, resp) + + return@notify resp.errorCallback?.onError(event) != false + } + } + } + + 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, + 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] + + private var errorCallback: OnErrorCallback? = null + + 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 + } + + override fun setErrorCallback(onErrorCallback: OnErrorCallback?) { + this.errorCallback = onErrorCallback + } + + override fun getErrorCallback(): OnErrorCallback? { + return errorCallback + } + + private fun extractResponseBody(): String? { + if (maxResponseBodyCapture <= 0) { + return null + } + + 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() + + // Request the data we want to read + peekedBody.request(maxResponseBodyCapture) + + // Read up to maxResponseBodyCapture bytes + val bytesToRead = minOf(peekedBody.buffer.size, maxResponseBodyCapture) + 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 + } + + return 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/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt new file mode 100644 index 0000000000..b0a5916a65 --- /dev/null +++ b/bugsnag-plugin-android-okhttp/src/main/java/com/bugsnag/android/okhttp/EventRequestHelper.kt @@ -0,0 +1,68 @@ +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 com.bugsnag.android.Request as BugsnagRequest +import com.bugsnag.android.Response as BugsnagResponse + +internal fun Event.setHttpInfo( + instrumentedRequest: HttpInstrumentedRequest, + instrumentedResponse: HttpInstrumentedResponse? +) { + val url = instrumentedRequest.reportedUrl ?: return + request = BugsnagRequest( + instrumentedResponse?.response?.protocol.toVersionString(), + instrumentedRequest.request.method, + url + ) + + request?.apply { + val okReq = instrumentedRequest.request + bodyLength = bodyLengthOf(okReq) + body = instrumentedRequest.reportedRequestBody + 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 + if (okResp != null) { + response = BugsnagResponse(okResp.code).apply { + bodyLength = bodyLengthOf(okResp) + body = instrumentedResponse.reportedResponseBody + okResp.headers.forEach { (name, value) -> + addHeader(name, value) + } + } + } +} + +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" + 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/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")) + } + } +} diff --git a/dockerfiles/Dockerfile.android-publisher b/dockerfiles/Dockerfile.android-publisher index bd0fae12f5..b5159e3258 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-apphang/ bugsnag-plugin-android-apphang/ COPY bugsnag-plugin-android-exitinfo/ bugsnag-plugin-android-exitinfo/ 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/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..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 @@ -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") } 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/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 new file mode 100644 index 0000000000..07f4fd0fd7 --- /dev/null +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/OkHttpInstrumentationScenario.kt @@ -0,0 +1,99 @@ +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 +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 +private val JSON = "application/json".toMediaType() + +open class OkHttpInstrumentationScenario( + config: Configuration, + context: Context, + eventMetadata: String? +) : Scenario(config, context, eventMetadata) { + + 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()) + .build() + + private fun reflectionUrl(status: Int): Uri { + return Uri.parse(config.endpoints.notify) + .buildUpon() + .path("/reflect") + .appendQueryParameter("status", status.toString()) + .appendQueryParameter("password", "secret") + .build() + } + + 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 { + 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") + // 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.toString()) + .header("Authorization", "Bearer OpenSesame") + .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..79e8ea28f8 --- /dev/null +++ b/features/full_tests/okhttp_instrumentation.feature @@ -0,0 +1,112 @@ +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 "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" is not null + + # 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 "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]" + + # 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" + 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 .+" + + # Validate request fields + And the event "request.httpMethod" equals "GET" + And the event "request.httpVersion" is not null + 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 + 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" + Then I wait to receive a reflection + And I should receive no errors \ No newline at end of file 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 diff --git a/settings.gradle.kts b/settings.gradle.kts index f1a312fe05..5332327855 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",