Skip to content
Open
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
Comment on lines +14 to +30
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 Installation snippet in rollbar-okhttp/README.md only lists com.rollbar:rollbar-okhttp and com.squareup.okhttp3:okhttp, but the recorder example below calls rollbar.recordNetworkEventFor(...) and rollbar.log(...) — methods defined on com.rollbar.notifier.RollbarBase in the rollbar-java module. rollbar-okhttp/build.gradle.kts only declares api(project(":rollbar-api")), and rollbar-api contains no Rollbar notifier class, so a user who copy-pastes the install snippet alongside the recorder example will get a cannot find symbol compile error for Rollbar / recordNetworkEventFor / log. Add a notifier module (e.g. com.rollbar:rollbar-java) to the install snippet, or add a note that one of the rollbar-* notifier modules is required as a prerequisite.

Extended reasoning...

What the bug is and how it manifests

The Installation section of rollbar-okhttp/README.md (lines 14–30) shows two and only two dependencies:

implementation("com.rollbar:rollbar-okhttp:<version>")
implementation("com.squareup.okhttp3:okhttp:<okhttp-version>")

Immediately below, the Usage section's recorder example calls rollbar.recordNetworkEventFor(level, method, url, statusCode) and rollbar.log(exception). Both methods live on com.rollbar.notifier.RollbarBase in the rollbar-java module — not in rollbar-okhttp and not in rollbar-api. A new user who follows the install snippet literally will not have any Rollbar class on their compile classpath and will see cannot find symbol: class Rollbar (and method recordNetworkEventFor / method log) when they try the example.

The specific code path

rollbar-okhttp/build.gradle.kts declares its public dependency tree as:

api(project(":rollbar-api"))

rollbar-api contains only payload data classes (Level, RollbarThread, etc.) — no notifier class. The notifier with recordNetworkEventFor and log is in rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java. Consumers who pull in only rollbar-okhttp therefore inherit rollbar-api transitively but not rollbar-java, which the example silently assumes.

Why existing code doesn't prevent it

The example uses an unannotated, unconstructed rollbar variable — there is no import line, no Rollbar rollbar = ... initializer, and no callout that the variable comes from a different module. Nothing in the README points the reader to rollbar-java (or rollbar-web/spring/etc.) as a prerequisite, and nothing in the build script of rollbar-okhttp causes a notifier module to be pulled in transitively.

Impact

A first-time adopter of the OkHttp integration who lands on this README and copies the install snippet will get a compile error on the example. Severity is small because most realistic adopters of an OkHttp interceptor for Rollbar will already have rollbar-java configured elsewhere in their app (otherwise they'd have nothing to call recordNetworkEventFor on), but for a clean module-specific quickstart it is still a documentation gap.

Step-by-step proof

  1. New user reads rollbar-okhttp/README.md and adds only the two listed dependencies to their build.gradle.kts: com.rollbar:rollbar-okhttp and com.squareup.okhttp3:okhttp.
  2. Gradle resolves the compile classpath: rollbar-okhttp brings rollbar-api transitively (declared as api in its build script) plus okhttp.
  3. User copies the recorder example, which references rollbar.recordNetworkEventFor(...) and rollbar.log(...).
  4. grep -r "class Rollbar " rollbar-api → no match. grep -rn recordNetworkEventFor rollbar-api → no match.
  5. grep -rn "recordNetworkEventFor" rollbar-java/src/main/java/com/rollbar/notifier/RollbarBase.java → defined at line 102 (and log(Throwable) is also defined here).
  6. javac fails: error: cannot find symbol: class Rollbar and (after manually importing) error: cannot find symbol: method recordNetworkEventFor.

How to fix it

Add a third line to both the Kotlin DSL and Groovy install snippets — implementation("com.rollbar:rollbar-java:<version>") — or add a one-line note above the snippet stating that one of the rollbar-* notifier modules (e.g. rollbar-java, rollbar-web, rollbar-spring-boot-webmvc) must also be on the classpath to obtain the Rollbar notifier used by the example.


### 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` |
24 changes: 24 additions & 0 deletions rollbar-okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
plugins {
id("java")
}
Comment thread
buongarzoni marked this conversation as resolved.
Outdated

group = "com.rollbar.okhttp"
Comment thread
buongarzoni marked this conversation as resolved.
version = "2.2.0"
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

repositories {
mavenCentral()
}

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")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 rollbar-okhttp/build.gradle.kts:8 declares testImplementation("org.mockito:mockito-core:5.23.0"), but the root subprojects block already adds org.mockito:mockito-core:5.18.0 to every non-example non-Android subproject (build.gradle.kts:55). Both versions land on the test classpath; Gradle resolves to the highest (5.23.0), so tests pass — but rollbar-okhttp silently diverges from every other module's pinned version, and a future root mockito bump won't apply uniformly here. Drop the local line to inherit 5.18.0 (matching rollbar-reactive-streams, rollbar-web, etc.), or if 5.23.0 is required, bump the root version so all modules stay aligned.

Extended reasoning...

What the bug is

rollbar-okhttp/build.gradle.kts line 8 has testImplementation("org.mockito:mockito-core:5.23.0"), while the root build.gradle.kts subprojects block (line 52–56) already adds testImplementation "org.mockito:mockito-core:5.18.0" to every non-example, non-Android subproject. The result is two versions of mockito-core on the test classpath for this single module, and divergence from every other rollbar-* module that quietly inherits 5.18.0 (rollbar-reactive-streams, rollbar-web, rollbar-java, etc., none of which redeclare mockito locally).

Why the build still works today

Gradle's default conflict resolution strategy for the same module-coordinate with two versions is highest-wins. Both 5.18.0 and 5.23.0 are valid, the API surface used by these tests is stable across both, and so resolution picks 5.23.0 and tests run green. There is no compile or runtime error, and no test failure. That is exactly why the divergence is silent and why this is filed as a maintainability concern rather than a functional bug.

Why it still matters

Two concrete maintenance hazards:

  1. Inversion on the next root bump. If someone bumps the root mockito (say to 5.24+) for a security or fix reason, every other module picks it up, but rollbar-okhttp stays pinned to 5.23.0 — a strictly older version than the root intended. The local override silently regresses the very thing the root bump was meant to fix.
  2. Discoverability. A developer searching for "which mockito version do we use" finds 5.18.0 in the root and trusts it, then later discovers via dependency reports that one module quietly uses something else.

Why existing code doesn't prevent it

There is no version-alignment task or platform/BOM constraint enforcing a single mockito version across the SDK. The root subprojects block adds the dependency by string coordinate, and a local override on the same coordinate stacks rather than shadows; Gradle simply resolves the conflict at configuration time without any warning.

Step-by-step proof

  1. Gradle evaluates rollbar-okhttp/build.gradle.kts after the root build script.
  2. Root subprojects block has already added org.mockito:mockito-core:5.18.0 to testImplementation for rollbar-okhttp (line 55).
  3. The local file then adds org.mockito:mockito-core:5.23.0 to testImplementation (line 8).
  4. ./gradlew :rollbar-okhttp:dependencies --configuration testRuntimeClasspath will list both and report the resolution as 5.23.0 with a "selected by conflict resolution" annotation.
  5. ./gradlew :rollbar-web:dependencies --configuration testRuntimeClasspath shows only 5.18.0 — a real per-module version skew.

How to fix it

Either drop line 8 entirely (preferred — matches every other module) so the inherited 5.18.0 is used, or, if 5.23.0 was deliberately needed for OkHttp 5 / mockwebserver 5 compatibility, bump the root version on line 55 of build.gradle.kts so all modules upgrade together. The current state guarantees the next mockito bump will diverge again.

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,44 @@
package com.rollbar.okhttp;

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

import java.io.IOException;

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

public class RollbarOkHttpInterceptor implements Interceptor {

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) {
recorder.recordNetworkEvent(
Level.CRITICAL,
request.method(),
request.url().toString(),
String.valueOf(response.code()));
}

return response;

} catch (IOException e) {
if (recorder != null) {
recorder.recordErrorEvent(e);
}

throw e;
Comment thread
claude[bot] marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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());
}
}
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