diff --git a/rollbar-okhttp/README.md b/rollbar-okhttp/README.md new file mode 100644 index 00000000..14341ad2 --- /dev/null +++ b/rollbar-okhttp/README.md @@ -0,0 +1,95 @@ +# Rollbar OkHttp Integration + +This module provides an [OkHttp Interceptor](https://square.github.io/okhttp/features/interceptors/) that automatically captures network telemetry for the Rollbar Java SDK. + +It records: + +- **Network telemetry events** for HTTP responses with status code `>= 400` (client and server errors). +- **Error events** for connection failures, timeouts, and other I/O exceptions. + +## Installation + +### Gradle (Kotlin DSL) + +```kotlin +dependencies { + implementation("com.rollbar:rollbar-java:") + implementation("com.rollbar:rollbar-okhttp:") + implementation("com.squareup.okhttp3:okhttp:") +} +``` + +### Gradle (Groovy) + +```groovy +dependencies { + implementation 'com.rollbar:rollbar-java:' + implementation 'com.rollbar:rollbar-okhttp:' + implementation 'com.squareup.okhttp3:okhttp:' +} +``` + +## Usage + +### 1. Implement `NetworkTelemetryRecorder` + +```java +NetworkTelemetryRecorder recorder = new NetworkTelemetryRecorder() { + @Override + public void recordNetworkEvent(Level level, String method, String url, String statusCode) { + // url has userinfo, query parameters, and fragment stripped by default + // (see Security section below) + rollbar.recordNetworkEventFor(level, method, url, statusCode); + } + + @Override + public void recordErrorEvent(Exception exception) { + rollbar.log(exception); + } +}; +``` + +### 2. Add the interceptor to your OkHttpClient + +```java +OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(recorder)) + .build(); +``` + +### 3. Make requests as usual + +```java +Request request = new Request.Builder() + .url("https://api.example.com/data") + .build(); + +Response response = client.newCall(request).execute(); +``` + +The interceptor will automatically record telemetry events to Rollbar without interfering with the request/response flow. + +## Behavior + +| Scenario | Action | +|-----------------------------------|---------------------------------------------------------| +| Recorder is `null` | No telemetry or log is recorded | +| Response status `< 400` | No telemetry recorded, response returned normally | +| Response status `>= 400` | Records a network telemetry event with `Level.CRITICAL` | +| Connection failure / timeout | Records an error event, then rethrows the `IOException` | + +## Security + +URLs can carry sensitive data in several components. To prevent accidental leakage to Rollbar, the interceptor **strips userinfo (basic-auth credentials), query parameters, and the fragment by default** before passing the URL to `NetworkTelemetryRecorder`. + +For example, a request to `https://user:secret@api.example.com/charge?token=sk_live_secret#section` will be recorded as `https://api.example.com/charge`. + +If your URLs do not contain sensitive query parameters and you need them for debugging, you can opt in to the full URL by supplying a custom sanitizer: + +```java +OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(recorder, HttpUrl::toString)) + .build(); +``` + +When using a custom sanitizer, you are responsible for ensuring that sensitive query parameters are removed before the URL reaches Rollbar. diff --git a/rollbar-okhttp/build.gradle.kts b/rollbar-okhttp/build.gradle.kts new file mode 100644 index 00000000..ff3b8bf3 --- /dev/null +++ b/rollbar-okhttp/build.gradle.kts @@ -0,0 +1,13 @@ +dependencies { + testImplementation(platform("org.junit:junit-bom:5.14.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + api(project(":rollbar-api")) + api("org.slf4j:slf4j-api:1.7.25") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/NetworkTelemetryRecorder.java b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/NetworkTelemetryRecorder.java new file mode 100644 index 00000000..3b00db0e --- /dev/null +++ b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/NetworkTelemetryRecorder.java @@ -0,0 +1,27 @@ +package com.rollbar.okhttp; + +import com.rollbar.api.payload.data.Level; + +/** + * Records network telemetry events and errors for HTTP requests. + */ +public interface NetworkTelemetryRecorder { + /** + * Records a completed network request as a telemetry event. + * + * @param level the severity level to attach to the telemetry event + * @param method the HTTP method (e.g. GET, POST) + * @param url the request URL with userinfo (basic-auth credentials), query parameters, + * and fragment stripped by default; supply a custom sanitizer to + * {@link RollbarOkHttpInterceptor} to change this behavior + * @param statusCode the HTTP response status code as a string (e.g. "200", "404") + */ + void recordNetworkEvent(Level level, String method, String url, String statusCode); + + /** + * Records a network error event when an HTTP request fails with an exception. + * + * @param exception the exception thrown during the request + */ + void recordErrorEvent(Exception exception); +} diff --git a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java new file mode 100644 index 00000000..edbd709e --- /dev/null +++ b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/RollbarOkHttpInterceptor.java @@ -0,0 +1,88 @@ +package com.rollbar.okhttp; + +import com.rollbar.api.payload.data.Level; + +import java.io.IOException; +import java.util.Objects; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RollbarOkHttpInterceptor implements Interceptor { + + private static final Logger LOGGER = LoggerFactory.getLogger(RollbarOkHttpInterceptor.class); + + private static final UrlSanitizer DEFAULT_URL_SANITIZER = + url -> url + .newBuilder() + .username("") + .password("") + .query(null) + .fragment(null) + .build() + .toString(); + + private final NetworkTelemetryRecorder recorder; + private final UrlSanitizer urlSanitizer; + + public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder) { + this(recorder, DEFAULT_URL_SANITIZER); + } + + public RollbarOkHttpInterceptor( + NetworkTelemetryRecorder recorder, + UrlSanitizer urlSanitizer) { + this.recorder = recorder; + this.urlSanitizer = Objects.requireNonNull(urlSanitizer, "urlSanitizer must not be null"); + } + + @NotNull + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + + try { + Response response = chain.proceed(request); + + if (response.code() >= 400 && recorder != null) { + String sanitizedUrl; + try { + sanitizedUrl = urlSanitizer.sanitize(request.url()); + } catch (Exception sanitizerException) { + LOGGER.warn("urlSanitizer threw an exception; " + + "suppressing to preserve the interceptor contract.", sanitizerException); + return response; + } + try { + recorder.recordNetworkEvent( + Level.CRITICAL, + request.method(), + sanitizedUrl, + String.valueOf(response.code())); + } catch (Exception recorderException) { + LOGGER.warn("NetworkTelemetryRecorder.recordNetworkEvent threw an exception; " + + "suppressing to preserve the interceptor contract.", recorderException); + } + } + + return response; + + } catch (IOException e) { + if (recorder != null) { + try { + recorder.recordErrorEvent(e); + } catch (Exception recorderException) { + LOGGER.warn("NetworkTelemetryRecorder.recordErrorEvent threw an exception; " + + "suppressing to preserve the original IOException.", recorderException); + } + } + + throw e; + } + } +} diff --git a/rollbar-okhttp/src/main/java/com/rollbar/okhttp/UrlSanitizer.java b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/UrlSanitizer.java new file mode 100644 index 00000000..6dfa6106 --- /dev/null +++ b/rollbar-okhttp/src/main/java/com/rollbar/okhttp/UrlSanitizer.java @@ -0,0 +1,8 @@ +package com.rollbar.okhttp; + +import okhttp3.HttpUrl; + +@FunctionalInterface +public interface UrlSanitizer { + String sanitize(HttpUrl url); +} diff --git a/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java new file mode 100644 index 00000000..2ca8f8d6 --- /dev/null +++ b/rollbar-okhttp/src/test/java/com/rollbar/okhttp/RollbarOkHttpInterceptorTest.java @@ -0,0 +1,256 @@ +package com.rollbar.okhttp; + +import com.rollbar.api.payload.data.Level; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.SocketPolicy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; + +public class RollbarOkHttpInterceptorTest { + + private MockWebServer server; + private NetworkTelemetryRecorder recorder; + private OkHttpClient client; + + @BeforeEach + public void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + + recorder = mock(NetworkTelemetryRecorder.class); + + client = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(recorder)) + .build(); + } + + @AfterEach + public void tearDown() throws IOException { + server.shutdown(); + } + + @Test + public void successfulResponse_doesNotRecordEvent() throws IOException { + server.enqueue(new MockResponse().setResponseCode(200)); + + Request request = new Request.Builder().url(server.url("/ok")).build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertEquals(200, response.code()); + verifyNoInteractions(recorder); + } + + @Test + public void redirectResponse_doesNotRecordEvent() throws IOException { + server.enqueue(new MockResponse().setResponseCode(301).addHeader("Location", "/other")); + + OkHttpClient noFollowClient = client.newBuilder().followRedirects(false).build(); + Request request = new Request.Builder().url(server.url("/redirect")).build(); + Response response = noFollowClient.newCall(request).execute(); + response.close(); + + assertEquals(301, response.code()); + verifyNoInteractions(recorder); + } + + @Test + public void clientErrorResponse_recordsNetworkEvent() throws IOException { + server.enqueue(new MockResponse().setResponseCode(404)); + + Request request = new Request.Builder().url(server.url("/not-found")).build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertEquals(404, response.code()); + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), contains("/not-found"), eq("404")); + verify(recorder, never()).recordErrorEvent(any()); + } + + @Test + public void serverErrorResponse_recordsNetworkEvent() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + Request request = new Request.Builder().url(server.url("/error")).build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertEquals(500, response.code()); + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), contains("/error"), eq("500")); + verify(recorder, never()).recordErrorEvent(any()); + } + + @Test + public void connectionFailure_recordsErrorEvent() { + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + + Request request = new Request.Builder().url(server.url("/fail")).build(); + + assertThrows(IOException.class, () -> client.newCall(request).execute()); + + verify(recorder).recordErrorEvent(any(IOException.class)); + verify(recorder, never()).recordNetworkEvent(any(), any(), any(), any()); + } + + @Test + public void postRequest_recordsCorrectMethod() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + Request request = new Request.Builder() + .url(server.url("/post")) + .post(okhttp3.RequestBody.create("body", okhttp3.MediaType.parse("text/plain"))) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + verify(recorder).recordNetworkEvent(eq(Level.CRITICAL), eq("POST"), any(), eq("500")); + } + + @Test + public void nullRecorder_errorResponse_doesNotThrowNPE() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + OkHttpClient nullRecorderClient = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(null)) + .build(); + + Request request = new Request.Builder().url(server.url("/error")).build(); + Response response = nullRecorderClient.newCall(request).execute(); + response.close(); + + assertEquals(500, response.code()); + } + + @Test + public void nullRecorder_connectionFailure_doesNotThrow() { + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + + OkHttpClient nullRecorderClient = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(null)) + .build(); + + Request request = new Request.Builder().url(server.url("/fail")).build(); + + assertThrows(IOException.class, () -> nullRecorderClient.newCall(request).execute()); + } + + @Test + public void recorderThrowsOnErrorResponse_responseStillReturned() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + doThrow(new RuntimeException("recorder boom")) + .when(recorder) + .recordNetworkEvent(any(), any(), any(), any()); + + Request request = new Request.Builder().url(server.url("/error")).build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertEquals(500, response.code()); + } + + @Test + public void recorderThrowsOnConnectionFailure_originalIOExceptionPropagates() { + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + + doThrow(new RuntimeException("recorder boom")) + .when(recorder) + .recordErrorEvent(any()); + + Request request = new Request.Builder().url(server.url("/fail")).build(); + + assertThrows(IOException.class, () -> client.newCall(request).execute()); + verify(recorder).recordErrorEvent(any(IOException.class)); + } + + @Test + public void defaultSanitizer_stripsQueryParamsFromUrl() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + Request request = new Request.Builder() + .url(server.url("/sensitive?token=sk_live_secret&email=user@example.com")) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), + argThat(url -> url.contains("/sensitive") && !url.contains("secret") && !url.contains("email")), + eq("500")); + } + + @Test + public void defaultSanitizer_stripsCredentialsAndFragment() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + HttpUrl urlWithCredentials = server.url("/charge") + .newBuilder() + .username("anyUser") + .password("anyPassword") + .addQueryParameter("token", "abc") + .fragment("section") + .build(); + + Request request = new Request.Builder().url(urlWithCredentials).build(); + Response response = client.newCall(request).execute(); + response.close(); + + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), + argThat(url -> url.contains("/charge") + && !url.contains("anyUser") + && !url.contains("anyPassword") + && !url.contains("token") + && !url.contains("section")), + eq("500")); + } + + @Test + public void customSanitizerThrows_responseStillReturnedAndRecorderNotCalled() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + OkHttpClient throwingClient = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(recorder, + url -> { throw new IllegalStateException("bad url"); })) + .build(); + + Request request = new Request.Builder().url(server.url("/error")).build(); + Response response = throwingClient.newCall(request).execute(); + response.close(); + + assertEquals(500, response.code()); + verify(recorder, never()).recordNetworkEvent(any(), any(), any(), any()); + } + + @Test + public void customSanitizer_isAppliedToUrl() throws IOException { + server.enqueue(new MockResponse().setResponseCode(500)); + + OkHttpClient customClient = new OkHttpClient.Builder() + .addInterceptor(new RollbarOkHttpInterceptor(recorder, url -> "Updated String")) + .build(); + + Request request = new Request.Builder() + .url(server.url("/path?secret=abc")) + .build(); + Response response = customClient.newCall(request).execute(); + response.close(); + + verify(recorder).recordNetworkEvent( + eq(Level.CRITICAL), eq("GET"), eq("Updated String"), eq("500")); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b56b7aee..73e10c5b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include( ":rollbar-jakarta-web", ":rollbar-log4j2", ":rollbar-logback", + ":rollbar-okhttp", ":rollbar-spring-webmvc", ":rollbar-spring6-webmvc", ":rollbar-spring-boot-webmvc",