Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
5762538
feat(java-agent): add rollbar-java-agent module skeleton
buongarzoni May 25, 2026
70573e5
feat(java-agent): instrument HttpURLConnection and java.net.http.Http…
buongarzoni May 25, 2026
3ee326d
feat(java-agent): instrument Apache HttpClient 4.x and 5.x
buongarzoni May 25, 2026
e5226c1
test(java-agent): add integration tests for instrumented HTTP clients
buongarzoni May 25, 2026
8ab880c
docs(java-agent): add README with installation and manual testing guide
buongarzoni May 25, 2026
ad0b2ac
chore(java-agent): upgrade wiremock to org.wiremock:wiremock:3.13.2
buongarzoni May 26, 2026
64e7df9
refactor(java-agent): replace resetForTesting with injectable Provide…
buongarzoni May 26, 2026
212ce90
fix(java-agent): resolve checkstyle violations in rollbar-java-agent
buongarzoni May 26, 2026
fe91c9a
fix(java-agent): resolve checkstyle violations in rollbar-java-agent
buongarzoni May 26, 2026
866b20b
fix(rollbar-java-agent): declare explicit dependency on jar task in test
buongarzoni May 26, 2026
6791575
feat(java-agent): instrument HttpClient.sendAsync() for async network…
buongarzoni Jun 1, 2026
ba9f0d8
fix(java-agent): fix Apache HC4 transformer to handle all execute() o…
buongarzoni Jun 1, 2026
02fe650
fix(java-agent): fix Apache HC5 transformer to handle all execute() o…
buongarzoni Jun 1, 2026
ebf0c39
docs: update readme of rollbar-java-agent
buongarzoni Jun 1, 2026
5259919
style(java-agent): fix checkstyle line length violations in HC4 and H…
buongarzoni Jun 1, 2026
d9237e4
fix(java-agent): record full URL in HC5 instrumentation
buongarzoni Jun 12, 2026
9510ed8
fix(java-agent): decouple getTelemetryTracker() from INSTANCE identity
buongarzoni Jun 12, 2026
439a3d2
refactor(java-agent): rename AgentTelemetryStore.init() to initForTes…
buongarzoni Jun 12, 2026
9b106d0
fix(java-agent): disable plain jar to prevent overwriting shaded arti…
buongarzoni Jun 29, 2026
ab969f1
fix(java-agent): always install HC4/HC5 transformers regardless of cl…
buongarzoni Jul 5, 2026
efd4c20
feat(java-agent): capture HttpURLConnection requests that skip getRes…
buongarzoni Jul 13, 2026
b46aa7b
fix(java-agent): strip query and userinfo in UrlSanitizer fallback path
buongarzoni Jul 13, 2026
7aadaaa
fix(java-agent): make shadowJar the sole artifact, prevent thin jar f…
buongarzoni Jul 13, 2026
97c3ac3
fix(java-agent): preserve host in UrlSanitizer for underscore hostnam…
buongarzoni Jul 13, 2026
ac57d62
fix(java-agent): prevent getInputStream advice recursion on connectio…
buongarzoni Jul 13, 2026
af9cdee
fix: lint
buongarzoni Jul 13, 2026
69fc32a
chore: update readme
buongarzoni Jul 13, 2026
cbab9d5
fix(java-agent): instrument Apache HC doExecute() to cover all execut…
buongarzoni Jul 13, 2026
ffd8ea2
fix(java-agent): bound scheme check in composeUrl to the authority po…
buongarzoni Aug 10, 2026
336de42
fix(java-agent): use raw URI components when stripping userinfo
buongarzoni Aug 10, 2026
e5ff2dd
fix(java-agent): support current JDKs and surface instrumentation fai…
buongarzoni Aug 10, 2026
6ff3783
fix(java-agent): shade only Byte Buddy, not the Rollbar SDK
buongarzoni Aug 10, 2026
036f414
chore(java-agent): drop unused byte-buddy-agent dependency
buongarzoni Aug 10, 2026
8808df7
docs(java-agent): narrow the zero code change claim to HTTP call sites
buongarzoni Aug 10, 2026
dd89925
fix(ci): exclude rollbar-java-agent on JDKs older than 17
buongarzoni Aug 10, 2026
ab5fa74
fix(java-agent): type Apache advice parameters as Object
buongarzoni Aug 17, 2026
cadebcf
fix(java-agent): dedup connection failures on the connection
buongarzoni Aug 18, 2026
388ba8b
perf(java-agent): restore the ignore-matcher defaults ByteBuddy provides
buongarzoni Aug 18, 2026
9ffdbe6
fix(java-agent): keep Rollbar SDK types out of the agent jar
buongarzoni Sep 24, 2026
bad4522
test(java-agent): assert the reflective getAll() contract
buongarzoni Sep 24, 2026
d9e2ceb
fix(java-agent): strip through the last '@' in the sanitizer fallback
buongarzoni Sep 24, 2026
181a261
fix(java-agent): close two credential leaks in the sanitizer fallback
buongarzoni Sep 24, 2026
54e6975
fix(java-agent): keep one telemetry buffer per application
buongarzoni Sep 24, 2026
ac6b1fd
fix(java-agent): attribute telemetry by who called, not by who regist…
buongarzoni Sep 24, 2026
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
274 changes: 274 additions & 0 deletions rollbar-java-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
# Rollbar Java Agent

A Java instrumentation agent that automatically captures HTTP network errors (4xx and 5xx responses) as Rollbar telemetry events, with no changes at your HTTP call sites.

It works by attaching to the JVM at startup via `-javaagent:` and using ByteBuddy to intercept HTTP calls across all major clients. Your request code stays exactly as it is, and you add no HTTP-related library dependencies. Setup is a one-time wiring step in your Rollbar configuration — see [What "no code changes" means here](#what-no-code-changes-means-here).

## Instrumented HTTP clients

| Client | Condition |
|--------|-----------|
| `java.net.HttpURLConnection` | Always (JDK built-in) |
| `java.net.http.HttpClient` — `send()` and `sendAsync()` | Java 11+ only |
| Apache HttpClient 4.x (`org.apache.http`) | If present on classpath |
| Apache HttpClient 5.x (`org.apache.hc.client5`) | If present on classpath |

Only 4xx and 5xx responses are recorded, along with requests that fail before a response arrives (connection refused, DNS failure, timeout). Successful requests (< 400) produce no telemetry.

**Apache HC4/HC5:** every `execute(...)` overload is covered — the request-only forms, the target-host forms (`execute(HttpHost, request)`), and the response-handler forms. The agent instruments the protected `doExecute(HttpHost, request, context)` method that all of them converge on, rather than any individual `execute()` overload, so no dispatch path is missed. Requests issued through a target-host overload carry only a path, so the agent rejoins the host from the `HttpHost` argument to record a complete URL.

### HttpURLConnection entry points

`HttpURLConnection` is captured through three entry points, so a failed request is recorded regardless of how your code consumes the response:

| Entry point | Why it is covered |
|-------------|-------------------|
| `getResponseCode()` | The caller checks the status code explicitly. |
| `getInputStream()` | The caller reads the body directly and only ever sees the `IOException` that a 4xx/5xx throws. |
| `getErrorStream()` | The caller inspects the error stream after `connect()`, or after catching the `IOException` from `getInputStream()`. |

Exactly one event is recorded per connection, even when your code hits several of these entry points (for example `getInputStream()` throwing and then `getErrorStream()` being read) — the agent deduplicates on the connection instance.

## Requirements

- Java 11 or higher **to run** the agent
- Java 17 or higher **to build** it from source — the shadow plugin that packages the fat JAR
requires a Java 17+ JVM, so on an older JDK the module is excluded from the build entirely and
`:rollbar-java-agent` tasks fail as unknown. The JAR it produces still targets Java 11.
- `rollbar-java` 2.3.0-beta.1 or newer on the application classpath — it supplies
`AgentTelemetryEventTracker`, the class that reads the agent's events (step 3)

The agent bundles only ByteBuddy, under a relocated package name, and depends on nothing else —
not even the Rollbar SDK.

### Why the agent carries no Rollbar classes

`-javaagent:` appends the agent jar to the JVM's **system** class path, and most applications do
not keep their dependencies there: a Spring Boot fat jar loads them from `BOOT-INF/lib`, a WAR from
`WEB-INF/lib`, both through a child classloader. Parent delegation only looks upward, so any SDK
type named from agent code would resolve against the system classloader and not be found.

In a method signature of the agent's `Premain-Class` that is fatal *before your application
starts*: the JVM calls `getDeclaredMethods()` on it to locate `premain`, which loads every type in
every declared signature, and the resulting `NoClassDefFoundError` aborts startup with
`FATAL ERROR in native method: processing of -javaagent failed`.

So the split runs the other way. The agent holds its events as plain string maps and never names
an SDK type; `AgentTelemetryEventTracker`, which ships in `rollbar-java` and therefore loads in
your application's classloader, reaches *up* to the system classloader to read them and turns them
into `TelemetryEvent`s. Reaching up always works; reaching down never does.

## Installation

### What "no code changes" means here

All three steps below are **required**. Step 3 touches your application once, at setup:

- **What you never change:** your HTTP call sites. Every request through `HttpURLConnection`,
`java.net.http.HttpClient`, or Apache HC 4.x/5.x is instrumented as written — no wrappers, no
interceptors, no per-call bookkeeping, and nothing to remember when you add the next HTTP call.
- **What you change once:** the agent JAR goes on your JVM's command line (step 2), and your
`Rollbar.init(...)` passes an `AgentTelemetryEventTracker` to the config builder (step 3).

That wiring cannot be made automatic today. `ConfigBuilder.build()` installs its default
`RollbarTelemetryEventTracker` whenever `telemetryEventTracker(...)` was not called, and the SDK
exposes no global registry or `ServiceLoader` hook that an agent could claim instead — so the
tracker has to be handed to the builder by the application. Skipping step 3 is silent: the agent
still records events, but into a buffer nothing ever reads (see [Behavior](#behavior)).

### 1. Build the agent JAR

```bash
./gradlew :rollbar-java-agent:shadowJar
```

The fat JAR (with ByteBuddy bundled and relocated) is written to:

```
rollbar-java-agent/build/libs/rollbar-java-agent-<version>.jar
```

This fat JAR is the module's only artifact — the thin `jar` task is disabled, and the shaded JAR is what Gradle consumers and the published Maven artifact resolve to.

### 2. Add the agent JVM flag

Add `-javaagent:` to your JVM startup arguments, pointing at the JAR built above:

```
-javaagent:/path/to/rollbar-java-agent-<version>.jar
```

**Gradle:**
```kotlin
jvmArgs("-javaagent:/path/to/rollbar-java-agent-<version>.jar")
```

**Maven Surefire / Failsafe:**
```xml
<argLine>-javaagent:/path/to/rollbar-java-agent-<version>.jar</argLine>
```

**Docker / environment variable:**
```bash
JAVA_TOOL_OPTIONS="-javaagent:/path/to/rollbar-java-agent-<version>.jar"
```

### 3. Wire into your Rollbar configuration (required)

```java
import com.rollbar.notifier.Rollbar;
import com.rollbar.notifier.telemetry.AgentTelemetryEventTracker;

import static com.rollbar.notifier.config.ConfigBuilder.withAccessToken;

Rollbar rollbar = Rollbar.init(
withAccessToken("your-access-token")
.environment("production")
.telemetryEventTracker(new AgentTelemetryEventTracker())
.build()
);
```

`AgentTelemetryEventTracker` comes from `rollbar-java`, which your application already depends on,
so the agent JAR itself is **not** a compile dependency — it only needs to be on the `-javaagent:`
flag. The tracker also records the events your application reports itself, exactly as the default
`RollbarTelemetryEventTracker` does, and merges both streams in timestamp order.

Without the agent attached the tracker just works as the default one, and says so once in the log —
useful when the same build runs with and without the agent.

That's the last application change you make. From here on, every HTTP call — including ones you add later — automatically produces a telemetry event in the Rollbar error report for any 4xx or 5xx response, with no further code changes.

## Behavior

| Scenario | Action |
|----------|--------|
| Response status `< 400` | No telemetry recorded |
| Response status `>= 400` | Records a network telemetry event with `Level.CRITICAL` |
| Connection failure / I/O error (connection refused, DNS failure, timeout) | Records a `Network error: <message>` telemetry event with `Level.CRITICAL` |
| The same request seen through several entry points | Deduplicated — one event per request |
| Installation step 3 not done | **Misconfiguration.** Events accumulate in the agent's buffer (capacity 100 per application, oldest dropped) and are never sent — nothing reads them into your `Rollbar` instance. Silent apart from the missing telemetry. |

The agent never throws into your application: every advice body swallows all errors, so a failure inside the instrumentation cannot break an HTTP call.

## Several applications in one JVM

A servlet container — Tomcat, WildFly — runs many deployments in one JVM, each with its own copy of
the SDK, its own access token and its own Rollbar project. The agent is attached once for the whole
JVM, so it keeps **one buffer per application** rather than one for the process.

Each event is filed under the classloader of the application that made the HTTP call, and each
application is handed back only its own events and those recorded by classloaders nested inside it
(a JSP or plugin loader). One deployment's internal hostnames, paths and status codes never reach
another's error reports, and a busy deployment cannot evict a quiet one's events — the 100-event cap
is per application.

Which application made the call is decided from two pieces of evidence, because neither is enough
on its own:

- **The thread.** A servlet container sets the context classloader to the deployment's own before
handing it a request, and a thread pool the application created inherits it.
- **The stack.** The first frame below the JDK and the agent is application code, whatever thread it
runs on. This is what attributes a call made from a shared pool — a `ForkJoinPool.commonPool`
worker carries the container's classloader, and `CompletableFuture.supplyAsync` and parallel
streams land there.

When one classloader is nested inside the other, the nested one wins; when neither contains the
other, the thread's owner does. An event that still cannot be tied to an application is filed under
the agent's own classloader, where only a caller from there can see it — a plain `java -cp`
deployment, whose application really does live there. It is never handed to somebody else merely for
being the only one asking: the JVM's other applications need not use `AgentTelemetryEventTracker` at
all, and their traffic is instrumented just the same.

One deployment rule follows from this: **keep `rollbar-java` inside the application**
(`WEB-INF/lib`), not in the container's shared `lib`. The classloader that loads the SDK is what
identifies the application when it reads its events; one shared copy makes every deployment answer
to the same identity.

## Security

URLs can carry sensitive data in query parameters or basic-auth credentials. The agent **strips userinfo, query parameters, and the URL fragment** before recording.

For example, a request to:
```
https://user:secret@api.example.com/charge?token=sk_live_abc#section
```
is recorded as:
```
https://api.example.com/charge
```

## Internal API

`AgentTelemetryStore.getAll(ClassLoader application)` is the contract between the agent and
`rollbar-java`: it returns the events that application may see as `List<Map<String, String>>`, each
map carrying `type`, `level`, `source` and `timestamp_ms` alongside the event body.
`AgentTelemetryEventTracker` calls it reflectively, so the signature cannot change without changing
both sides.

`AgentTelemetryStore.getAll()` (no argument) answers for whichever application the calling code
belongs to. It is a read like any other — it is for diagnostics, and calling it changes nothing
about what anyone else is shown.

Two methods exist for tests only. Do not call them in production code.

- `AgentTelemetryStore.resetForTesting()` — drops every buffered event and restores the default clock.
- `NetworkEventBridge.resetRecordedForTesting()` — clears the deduplication state, so events from a previous test do not suppress recording in the next one.

## Testing

### Automated tests

```bash
./gradlew :rollbar-java-agent:test
```

This runs the full test suite (WireMock-backed integration tests for each instrumented client).

### Manual smoke test

1. Build the agent JAR:
```bash
./gradlew :rollbar-java-agent:shadowJar
```

2. Write a small program that triggers a 4xx or 5xx:
```java
import com.rollbar.notifier.Rollbar;
import com.rollbar.notifier.telemetry.AgentTelemetryEventTracker;

import java.net.HttpURLConnection;
import java.net.URL;

import static com.rollbar.notifier.config.ConfigBuilder.withAccessToken;

public class SmokeTest {
public static void main(String[] args) throws Exception {
Rollbar rollbar = Rollbar.init(
withAccessToken("your-access-token")
.environment("test")
.telemetryEventTracker(new AgentTelemetryEventTracker())
.build()
);

// Trigger a 404 — captured as a telemetry event on the next error report
HttpURLConnection conn = (HttpURLConnection) new URL("https://httpstat.us/404").openConnection();
int code = conn.getResponseCode();
conn.disconnect();

System.out.println("Response: " + code);

// Send an error to Rollbar — the 404 telemetry event will appear alongside it
rollbar.error(new RuntimeException("smoke test error"));
}
}
```

3. Run with the agent:
```bash
java -javaagent:rollbar-java-agent/build/libs/rollbar-java-agent-<version>.jar \
-cp "your-app.jar" \
SmokeTest
```

4. Check your Rollbar dashboard — the error report for "smoke test error" should show a **Network** telemetry event for the 404 in the telemetry timeline.
95 changes: 95 additions & 0 deletions rollbar-java-agent/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
plugins {
`java-library`
// Successor to the abandoned com.github.johnrengelman.shadow. Required at 9.x: Byte Buddy 1.18
// ships Java 24 class files under META-INF/versions/24 (its bridge to the JDK's own class file
// API), which older shadow releases cannot read. 9.5+ needs Gradle 9, so 9.4.3 is the ceiling
// until this build's Gradle is upgraded.
id("com.gradleup.shadow") version "9.4.3"
}

// Dependencies that get relocated into the fat jar. shadowJar merges runtimeClasspath by default,
// which is why this configuration exists: Byte Buddy is the only thing that belongs inside the
// agent jar. The agent's own code compiles against no Rollbar module at all — see
// AgentTelemetryStore for why nothing the agent loads may name an SDK type.
val shaded: Configuration by configurations.creating

// compileOnly: these are inside the jar, so they must not also be published as runtime
// dependencies of the agent.
configurations.compileOnly.configure { extendsFrom(shaded) }

dependencies {
// Byte Buddy must be able to parse the class files of the JDK it runs on: the agent
// instruments JDK classes, which always carry the running JDK's class file version. A version
// older than the runtime fails to transform them (see the compatibility table at
// https://github.com/raphw/byte-buddy#java-version-compatibility), so keep this current.
// byte-buddy alone: AgentBuilder ships in the core artifact. byte-buddy-agent supplies
// ByteBuddyAgent/VirtualMachine for attaching to a *running* JVM, which is the attaching
// process's job, not this agent's — premain/agentmain receive their Instrumentation from the
// JVM directly.
shaded("net.bytebuddy:byte-buddy:1.18.11")
compileOnly("org.apache.httpcomponents:httpclient:4.5.14")
compileOnly("org.apache.httpcomponents.client5:httpclient5:5.3.1")

testImplementation(platform("org.junit:junit-bom:5.14.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.mockito:mockito-core:5.11.0")
// Test-only: AgentTelemetryEventTrackerIntegrationTest wires the SDK-side tracker to this
// agent the way an application does. Main code must not depend on these — the agent jar is
// loaded by the system classloader, which in a Spring Boot fat jar or a WAR cannot see the
// application's copy of the SDK.
testImplementation(project(":rollbar-java"))
testImplementation("org.wiremock:wiremock:3.13.2")
testImplementation("org.apache.httpcomponents:httpclient:4.5.14")
testImplementation("org.apache.httpcomponents.client5:httpclient5:5.3.1")
}

tasks.jar {
enabled = false
}

// java-library wires tasks.jar into apiElements/runtimeElements; replace it with shadowJar so
// Gradle's variant system and vanniktech publishing both see the fat jar as the primary artifact.
listOf(configurations.apiElements, configurations.runtimeElements).forEach { cfg ->
cfg.configure {
outgoing.artifacts.clear()
outgoing.artifact(tasks.shadowJar)
}
}

tasks.shadowJar {
archiveClassifier.set("")
// Embed only the `shaded` configuration, not the default runtimeClasspath. Everything else —
// rollbar-api, rollbar-java, SLF4J — stays an ordinary external dependency resolved from the
// application's own classpath.
configurations.set(listOf(shaded))
manifest {
attributes(
"Premain-Class" to "com.rollbar.agent.RollbarAgent",
"Agent-Class" to "com.rollbar.agent.RollbarAgent",
"Can-Redefine-Classes" to "true",
"Can-Retransform-Classes" to "true"
)
}
relocate("net.bytebuddy", "com.rollbar.agent.shaded.bytebuddy")
mergeServiceFiles()
}

// Override root's Java 8 compatibility — this agent targets Java 11+ to support
// java.net.http.HttpClient instrumentation.
tasks.withType<JavaCompile>().configureEach {
options.release.set(11)
}

tasks.test {
useJUnitPlatform()
val agentJar = tasks.shadowJar.get().archiveFile.get().asFile
// Load as Java agent (instruments HTTP classes on startup)
jvmArgs("-javaagent:$agentJar")
// Also put on test classpath — the TCCL reflection bridge finds agent classes via the
// system classloader; mirrors production use where rollbar-java-agent is a Gradle/Maven dep
classpath += files(agentJar)
// AgentClassLoaderIsolationTest inspects the shipped jar and launches a JVM with it.
systemProperty("rollbar.agent.jar", agentJar.absolutePath)
dependsOn(tasks.shadowJar)
}
Loading
Loading