Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions rollbar-okhttp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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-okhttp:<version>")
implementation("com.squareup.okhttp3:okhttp:<okhttp-version>")
}
```

### Gradle (Groovy)

```groovy
dependencies {
implementation 'com.rollbar:rollbar-okhttp:<version>'
implementation 'com.squareup.okhttp3:okhttp:<okhttp-version>'
}
```

## Usage

### 1. Implement `NetworkTelemetryRecorder`

```java
NetworkTelemetryRecorder recorder = new NetworkTelemetryRecorder() {
@Override
public void recordNetworkEvent(Level level, String method, String url, String statusCode) {
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` |
15 changes: 15 additions & 0 deletions rollbar-okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
group = "com.rollbar.okhttp"
Comment thread
buongarzoni marked this conversation as resolved.

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")
testImplementation("org.mockito:mockito-core:5.23.0")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
Comment thread
buongarzoni marked this conversation as resolved.
api(project(":rollbar-api"))
}

tasks.test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.rollbar.okhttp;

import com.rollbar.api.payload.data.Level;

public interface NetworkTelemetryRecorder {
void recordNetworkEvent(Level level, String method, String url, String statusCode);

void recordErrorEvent(Exception exception);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.rollbar.okhttp;

import com.rollbar.api.payload.data.Level;

import java.io.IOException;
import java.util.logging.Logger;

import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;

public class RollbarOkHttpInterceptor implements Interceptor {

private static final Logger LOGGER = Logger.getLogger(RollbarOkHttpInterceptor.class.getName());

private final NetworkTelemetryRecorder recorder;

public RollbarOkHttpInterceptor(NetworkTelemetryRecorder recorder) {
this.recorder = recorder;
}

@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();

try {
Response response = chain.proceed(request);

if (response.code() >= 400 && recorder != null) {
try {
recorder.recordNetworkEvent(
Level.CRITICAL,
request.method(),
request.url().toString(),
Comment on lines +33 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When a server redirects (e.g., 301 from /api/v1/users to /api/v2/users) and the final endpoint returns a 4xx/5xx, the telemetry records the original URL and method rather than the ones that actually produced the error. Lines 33–34 use request.method() and request.url().toString() where request is the pre-redirect chain.request(); the fix is to use response.request().method() and response.request().url().toString() to report the request that actually produced the final response.

Extended reasoning...

What the bug is and how it manifests

RollbarOkHttpInterceptor is registered as an application interceptor via addInterceptor(). Application interceptors sit above OkHttp's internal RetryAndFollowUpInterceptor, which handles redirect resolution transparently. This means chain.proceed(request) returns only after all redirects have been followed, and the returned Response is the final response in the chain.

At line 24, request = chain.request() captures the original pre-redirect request. Lines 33–34 then use request.method() and request.url().toString() to populate the telemetry event. Because these fields come from the original request — not the final redirected one — any redirect causes incorrect attribution.

The specific code path that triggers it

Why existing code doesn't prevent it

OkHttp's Response.request() returns the request that produced that specific response — for a redirect chain, this is the last request in the chain, not the first. The code never consults response.request(), so the mismatch between the status code source (final response) and the URL/method source (original request) goes uncorrected. The test suite confirms the gap: redirectResponse_doesNotRecordEvent() only tests a 301 with followRedirects(false), which does not follow the redirect at all. The redirect-then-error scenario (follow redirect -> final URL returns 5xx) is not tested.

What the impact would be

Two failure modes:

  1. Wrong URL: A 301 from /api/v1/users -> /api/v2/users that ends in a 500 is logged in Rollbar as an error at /api/v1/users. Engineers investigating via Rollbar telemetry will look at the wrong endpoint.

  2. Wrong method: Per the HTTP spec (and OkHttp's default behavior), a POST redirected with 301 or 302 becomes a GET for the final request. The telemetry would record method=POST when the actual failing request was GET, compounding the misattribution.

OkHttp follows redirects by default (followRedirects=true), so any application that hits a redirected endpoint with a 4xx/5xx final response is affected.

Step-by-step proof

  1. Client sends POST /api/v1/users via an OkHttpClient with RollbarOkHttpInterceptor.
  2. request = chain.request() -> records {method: "POST", url: "/api/v1/users"}.
  3. chain.proceed(request) -> OkHttp internally follows the 301 and sends GET /api/v2/users.
  4. The server returns 500 for /api/v2/users. response.code() = 500.
  5. recorder.recordNetworkEvent(CRITICAL, "POST", "/api/v1/users", "500") is called.
  6. Rollbar shows: error at POST /api/v1/users -> 500. Both the URL and method are wrong.
  7. response.request().method() -> "GET"; response.request().url().toString() -> "/api/v2/users". These are the correct values.

How to fix it

Replace lines 33–34 with:

This ensures the telemetry records the request that actually produced the error response, regardless of how many redirects occurred.

String.valueOf(response.code()));
Comment on lines +31 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The interceptor passes request.url().toString() (full URL including query string) to the user-supplied NetworkTelemetryRecorder, and the README example forwards this raw URL directly to Rollbar with no sanitization or warning. URLs routinely carry sensitive query parameters (API keys, OAuth tokens, PII), so users who copy-paste the README example will inadvertently send those secrets to Rollbar's servers. Add a security callout to the README and update the example to show query-parameter stripping (e.g. request.url().newBuilder().query(null).build().toString()) or at minimum note that callers are responsible for sanitizing the URL before forwarding it to Rollbar.

Extended reasoning...

What the bug is

RollbarOkHttpInterceptor.intercept() calls recorder.recordNetworkEvent(..., request.url().toString(), ...) at line 34. OkHttp's HttpUrl.toString() returns the complete URL including the query string — for example, https://api.example.com/search?api_key=sk-secret&email=user@example.com. Query parameters are a well-known carrier of sensitive data: API keys (?api_key=...), OAuth tokens (?access_token=...), session identifiers (?session_id=...), and PII such as email or SSN are all commonly passed this way.

Why the README makes this actionable

The NetworkTelemetryRecorder interface is user-controlled, so in principle users can sanitize the URL inside their own implementation. The refutation correctly identifies this. However, the README example (lines 37-38) shows exactly:

public void recordNetworkEvent(Level level, String method, String url, String statusCode) {
    rollbar.recordNetworkEventFor(level, method, url, statusCode);
}

This pattern passes the raw URL verbatim to Rollbar — an external cloud service — without any sanitization and without any comment warning that query parameters are included. There is no note in the README, no Javadoc on NetworkTelemetryRecorder, and no inline comment in the code that flags this risk. Users who follow this copy-paste example will inadvertently ship sensitive query parameters to Rollbar's servers.

How this differs from comparable libraries

The refutation compares this to OkHttp's HttpLoggingInterceptor, which also logs full URLs. The key difference: HttpLoggingInterceptor writes to a local logger under the developer's own control (and OkHttp's own docs recommend a HttpLoggingInterceptor.Logger that can be customized). This interceptor's primary documented use case, as shown in the README, is forwarding to Rollbar — a third-party SaaS. The data-flow destination changes the risk profile substantially.

Impact

Any developer who follows the README (likely the majority of new adopters) will send complete request URLs to Rollbar. If those URLs contain API keys or tokens, those credentials are now stored in a third party's servers, visible to anyone with Rollbar access, and potentially included in Rollbar exports or integrations. This is a silent data leak with no indication at the call site.

Proof by example

  1. App calls GET https://payments.example.com/charge?token=sk_live_secret&amount=100 → server returns 500.
  2. response.code() >= 400 is true → interceptor calls recorder.recordNetworkEvent(CRITICAL, "GET", "https://payments.example.com/charge?token=sk_live_secret&amount=100", "500").
  3. User's recorder (copied from README) calls rollbar.recordNetworkEventFor(level, method, url, statusCode) with the full URL.
  4. Rollbar stores token=sk_live_secret as part of the telemetry event, visible in the Rollbar dashboard.

How to fix

This is primarily a documentation gap. The README should include a security callout explaining that url includes query parameters and showing how to strip them: e.g., request.url().newBuilder().query(null).build().toString() before passing to the recorder, or stripping inside the recorder implementation. The NetworkTelemetryRecorder Javadoc should note the same. Optionally, the interceptor itself could strip query parameters by default and provide an opt-in setUrlSanitizer(Function<HttpUrl, String>) for users who need them.

} catch (Exception recorderException) {
LOGGER.log(java.util.logging.Level.WARNING,
"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.log(java.util.logging.Level.WARNING,
"NetworkTelemetryRecorder.recordErrorEvent threw an exception; "
+ "suppressing to preserve the original IOException.",
recorderException);
}
}

throw e;
Comment thread
claude[bot] marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package com.rollbar.okhttp;

import com.rollbar.api.payload.data.Level;
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.Mockito.*;

class RollbarOkHttpInterceptorTest {

private MockWebServer server;
private NetworkTelemetryRecorder recorder;
private OkHttpClient client;

@BeforeEach
void setUp() throws IOException {
server = new MockWebServer();
server.start();

recorder = mock(NetworkTelemetryRecorder.class);

client = new OkHttpClient.Builder()
.addInterceptor(new RollbarOkHttpInterceptor(recorder))
.build();
}

@AfterEach
void tearDown() throws IOException {
server.shutdown();
}

@Test
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
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
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
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
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
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
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
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
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
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));
}
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ include(
":rollbar-jakarta-web",
":rollbar-log4j2",
":rollbar-logback",
"rollbar-okhttp",
":rollbar-spring-webmvc",
":rollbar-spring6-webmvc",
":rollbar-spring-boot-webmvc",
Expand Down
Loading