diff --git a/rollbar-java-agent/README.md b/rollbar-java-agent/README.md new file mode 100644 index 00000000..8183c674 --- /dev/null +++ b/rollbar-java-agent/README.md @@ -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-.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-.jar +``` + +**Gradle:** +```kotlin +jvmArgs("-javaagent:/path/to/rollbar-java-agent-.jar") +``` + +**Maven Surefire / Failsafe:** +```xml +-javaagent:/path/to/rollbar-java-agent-.jar +``` + +**Docker / environment variable:** +```bash +JAVA_TOOL_OPTIONS="-javaagent:/path/to/rollbar-java-agent-.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: ` 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>`, 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-.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. diff --git a/rollbar-java-agent/build.gradle.kts b/rollbar-java-agent/build.gradle.kts new file mode 100644 index 00000000..66695dd4 --- /dev/null +++ b/rollbar-java-agent/build.gradle.kts @@ -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().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) +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/AgentTelemetryStore.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/AgentTelemetryStore.java new file mode 100644 index 00000000..ceab2499 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/AgentTelemetryStore.java @@ -0,0 +1,356 @@ +package com.rollbar.agent; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Bounded, in-memory buffer of the telemetry events the agent's instrumentation records, kept one + * buffer per application. + * + *

Nothing in this package may reference a {@code com.rollbar.api} or + * {@code com.rollbar.notifier} type. The agent jar is appended to the system + * class path by {@code -javaagent:}, while the Rollbar SDK usually lives in a child classloader — + * {@code BOOT-INF/lib} under a Spring Boot fat jar, {@code WEB-INF/lib} under a servlet container. + * Parent delegation only looks upward, so an SDK type named from here resolves against the system + * classloader and is not found. In a method signature of the {@code Premain-Class} that is fatal + * before the application starts: the JVM calls {@code getDeclaredMethods()} to locate + * {@code premain}, which loads every type in every declared signature, and a + * {@code NoClassDefFoundError} there aborts the JVM with "processing of -javaagent failed". + * + *

So events are held as plain {@link String} maps, which every classloader agrees on. + * {@code com.rollbar.notifier.telemetry.AgentTelemetryEventTracker}, which ships in + * {@code rollbar-java} and therefore loads in the application's own classloader, reads them back + * through the system classloader and converts them into {@code TelemetryEvent}s. + * + *

Each map carries the reserved keys {@link #KEY_TYPE}, {@link #KEY_LEVEL}, {@link #KEY_SOURCE} + * and {@link #KEY_TIMESTAMP_MS}; every other entry is the event body. + * + *

Partitioning. One JVM can host several applications — WARs in Tomcat or + * WildFly — each with its own SDK, its own access token and its own Rollbar project. The agent is + * loaded once for all of them, so a single shared buffer would put one application's hostnames, + * paths and status codes into another's error reports, and let a busy application evict a quiet + * one's events. Instead each event is filed under the classloader of the application that made the + * HTTP call, and {@link #getAll(ClassLoader)} returns only that application's own events and those + * recorded by classloaders nested inside it. Nothing is ever handed to a caller on the strength of + * being the only one asking: an application the agent has never heard from — one that does not use + * {@code AgentTelemetryEventTracker} at all — still has its traffic instrumented, and its events + * must not become somebody else's. + * + *

An event whose application cannot be identified is filed under the classloader that loaded + * the agent, where only a caller from that same classloader can see it. That is the plain + * {@code java -cp} deployment, whose application really does live there. + */ +public final class AgentTelemetryStore { + + /** + * Telemetry type, as the Rollbar payload spells it ({@code network}, {@code manual}). + */ + public static final String KEY_TYPE = "type"; + + /** + * Severity, as the Rollbar payload spells it ({@code critical}). + */ + public static final String KEY_LEVEL = "level"; + + /** + * Event source, as the Rollbar payload spells it ({@code server}). + */ + public static final String KEY_SOURCE = "source"; + + /** + * Event time in milliseconds since the epoch, as a decimal string. + */ + public static final String KEY_TIMESTAMP_MS = "timestamp_ms"; + + /** + * Maximum number of buffered events per application; the oldest are dropped once it is + * reached. + */ + public static final int MAX_EVENTS = 100; + + private static final String TYPE_NETWORK = "network"; + private static final String TYPE_MANUAL = "manual"; + private static final String LEVEL_CRITICAL = "critical"; + private static final String SOURCE_SERVER = "server"; + + private static final String BODY_KEY_METHOD = "method"; + private static final String BODY_KEY_URL = "url"; + private static final String BODY_KEY_STATUS_CODE = "status_code"; + private static final String BODY_KEY_MESSAGE = "message"; + + private static final Object LOCK = new Object(); + + private static final ClassLoader AGENT_LOADER = AgentTelemetryStore.class.getClassLoader(); + private static final ClassLoader PLATFORM_LOADER = ClassLoader.getPlatformClassLoader(); + + // Weak keys: an undeployed application's classloader must stay collectable, and it would not be + // if the store held it. The buffered values are Strings only, so nothing here points back at a + // key and keeps its entry alive. + private static final Map>> EVENTS = new WeakHashMap<>(); + + private static final Comparator> BY_TIMESTAMP = + new Comparator>() { + @Override + public int compare(Map left, Map right) { + return Long.compare(timestampOf(left), timestampOf(right)); + } + }; + + // Overridable so tests can assert on timestamps. Not a java.util.function type: keeping this + // class free of anything but the most basic JDK types is what lets any classloader read it. + private static volatile Clock clock = new SystemClock(); + + private AgentTelemetryStore() {} + + /** + * Records a network telemetry event for a request that returned a 4xx or 5xx status. + * + * @param method the HTTP verb (e.g. {@code GET}). + * @param url the sanitized request URL. + * @param statusCode the response status code, as a string. + */ + public static void recordNetworkEvent(String method, String url, String statusCode) { + recordNetworkEvent(currentOrigin(), method, url, statusCode); + } + + /** + * Records a network telemetry event against an application captured earlier. + * + *

For a response that arrives on a thread of the HTTP client's own — the completion of an + * async request — where neither the thread nor the stack still points at the caller. The + * instrumentation captures {@link #currentOrigin()} when the request is made and hands it back + * here. + * + * @param origin the application that made the request. + * @param method the HTTP verb (e.g. {@code GET}). + * @param url the sanitized request URL. + * @param statusCode the response status code, as a string. + */ + public static void recordNetworkEvent(ClassLoader origin, String method, String url, + String statusCode) { + Map event = newEvent(TYPE_NETWORK); + putIfPresent(event, BODY_KEY_METHOD, method); + putIfPresent(event, BODY_KEY_URL, url); + putIfPresent(event, BODY_KEY_STATUS_CODE, statusCode); + add(event, orSystem(origin)); + } + + /** + * Records a manual telemetry event for a request that failed before a response arrived. + * + * @param message the failure description. + */ + public static void recordErrorEvent(String message) { + recordErrorEvent(currentOrigin(), message); + } + + /** + * Records a manual telemetry event against an application captured earlier. + * + * @param origin the application that made the request. + * @param message the failure description. + */ + public static void recordErrorEvent(ClassLoader origin, String message) { + Map event = newEvent(TYPE_MANUAL); + putIfPresent(event, BODY_KEY_MESSAGE, message); + add(event, orSystem(origin)); + } + + /** + * Returns a snapshot of the events the given application may see, oldest first. + * + *

Part of the reflective contract with {@code rollbar-java} — {@code public static}, one + * {@link ClassLoader} argument, returning only JDK types. + * + * @param application the classloader of the application asking, normally the one that loaded the + * SDK; {@code null} is read as the system classloader. + * @return the events visible to it, each an independent copy. + */ + public static List> getAll(ClassLoader application) { + ClassLoader requester = orSystem(application); + List> snapshot = new ArrayList<>(); + + synchronized (LOCK) { + for (Map.Entry>> buffer : EVENTS.entrySet()) { + if (!visibleTo(buffer.getKey(), requester)) { + continue; + } + for (Map event : buffer.getValue()) { + snapshot.add(Collections.unmodifiableMap(new HashMap<>(event))); + } + } + } + + // Several buffers can contribute, and they interleave in time. + snapshot.sort(BY_TIMESTAMP); + return snapshot; + } + + /** + * Returns the events visible to the calling thread's context classloader. + * + *

For diagnostics and tests. An application reads its own events through + * {@link #getAll(ClassLoader)}, which does not depend on which thread happens to ask. + * + * @return the events visible to the caller, each an independent copy. + */ + public static List> getAll() { + return getAll(currentOrigin()); + } + + /** + * Drops every buffered event. For tests. + */ + public static void resetForTesting() { + synchronized (LOCK) { + EVENTS.clear(); + } + clock = new SystemClock(); + } + + /** + * Replaces the clock used to timestamp events, so tests can assert on exact values. For tests. + * + * @param clock the replacement clock. + */ + static void setClockForTesting(Clock clock) { + AgentTelemetryStore.clock = clock; + } + + /** + * Whether events recorded under {@code origin} belong to the application {@code requester}. + * + *

Only downward: a classloader nested inside the application — a JSP or plugin loader — is + * still that application. Upward is the container's own classloader, shared by every deployment, + * and sideways is another deployment; neither is ever this application's to report. + */ + private static boolean visibleTo(ClassLoader origin, ClassLoader requester) { + return origin == requester || isNestedIn(origin, requester); + } + + /** + * Whether {@code loader} is a strict descendant of {@code ancestor} in the delegation chain. + */ + private static boolean isNestedIn(ClassLoader loader, ClassLoader ancestor) { + for (ClassLoader parent = loader.getParent(); parent != null; parent = parent.getParent()) { + if (parent == ancestor) { + return true; + } + } + return false; + } + + private static Map newEvent(String type) { + Map event = new HashMap<>(); + event.put(KEY_TYPE, type); + event.put(KEY_LEVEL, LEVEL_CRITICAL); + event.put(KEY_SOURCE, SOURCE_SERVER); + event.put(KEY_TIMESTAMP_MS, Long.toString(clock.currentTimeMillis())); + return event; + } + + private static void putIfPresent(Map event, String key, String value) { + if (value != null) { + event.put(key, value); + } + } + + private static void add(Map event, ClassLoader origin) { + synchronized (LOCK) { + Deque> buffer = EVENTS.get(origin); + if (buffer == null) { + buffer = new ArrayDeque<>(); + EVENTS.put(origin, buffer); + } + if (buffer.size() >= MAX_EVENTS) { + buffer.pollFirst(); + } + buffer.addLast(event); + } + } + + /** + * The application the calling code belongs to. + * + *

Two pieces of evidence, because neither alone is enough. 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 — but a {@code ForkJoinPool.commonPool} worker carries the + * container's, and a parallel stream or {@code CompletableFuture.supplyAsync} lands there. The + * stack says who actually called: the first frame below the JDK and the agent is application + * code, whatever thread it runs on. + * + *

When one of the two is nested inside the other, the nested one wins, being the closer of + * the two to a single deployment: that is what rescues the call made on a shared pool, where the + * thread belongs to the container and the stack to the application. When neither contains the + * other — a library loaded beside the application rather than within it — the thread's owner + * wins, since a container states thread ownership deliberately and the agent has no business + * filing an event under a deployment the thread does not belong to. + * + *

Walking the stack costs more than reading a field, which is why this runs only when an + * event is recorded — a 4xx, a 5xx or a connection failure — and never on a successful request. + */ + static ClassLoader currentOrigin() { + ClassLoader context = orSystem(Thread.currentThread().getContextClassLoader()); + ClassLoader caller = callingApplication(); + if (caller == null || caller == context) { + return context; + } + return isNestedIn(caller, context) ? caller : context; + } + + private static ClassLoader callingApplication() { + try { + return StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .map(StackWalker.StackFrame::getDeclaringClass) + .map(Class::getClassLoader) + .filter(AgentTelemetryStore::isApplicationClassLoader) + .findFirst() + .orElse(null)); + } catch (Throwable ignored) { + // A SecurityManager can refuse the walk, and an agent must never break the call it observes. + // The context classloader still answers. + return null; + } + } + + // Everything the JDK and the agent itself are loaded by is infrastructure, not an application. + // In a plain `java -cp app.jar` deployment the application is loaded by the agent's own + // classloader too, and is correctly left to the context classloader to identify. + private static boolean isApplicationClassLoader(ClassLoader loader) { + return loader != null && loader != AGENT_LOADER && loader != PLATFORM_LOADER; + } + + private static ClassLoader orSystem(ClassLoader classLoader) { + return classLoader != null ? classLoader : ClassLoader.getSystemClassLoader(); + } + + private static long timestampOf(Map event) { + try { + return Long.parseLong(event.get(KEY_TIMESTAMP_MS)); + } catch (RuntimeException e) { + return 0L; + } + } + + /** + * Source of event timestamps. + */ + interface Clock { + long currentTimeMillis(); + } + + private static final class SystemClock implements Clock { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/NetworkEventBridge.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/NetworkEventBridge.java new file mode 100644 index 00000000..66b9d560 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/NetworkEventBridge.java @@ -0,0 +1,188 @@ +package com.rollbar.agent; + +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * Called by JDK-class advice via reflection to bridge the classloader gap. + * + *

ByteBuddy advice inlined into bootstrap/platform classloader classes (e.g. + * {@code HttpURLConnection}, {@code HttpClient}) cannot directly reference classes loaded further + * down the delegation chain. Advice code uses + * {@code Thread.currentThread().getContextClassLoader().loadClass(...)} to reach this class and + * delegates all Rollbar-specific logic here. + * + *

Like the rest of this package, it must not name any Rollbar SDK type — see + * {@link AgentTelemetryStore} for why. + */ +public final class NetworkEventBridge { + + // Tracks connections/responses already recorded to deduplicate re-entrant calls. + // WeakHashMap so entries are garbage-collected when the connection is released. + private static final Set RECORDED = Collections.newSetFromMap( + Collections.synchronizedMap(new WeakHashMap<>()) + ); + + // Re-entry guard for the getInputStream/getErrorStream advice: invoking getResponseCode() to + // trigger recording can, on connection-level failures (responseCode stays -1), cause the JDK's + // getResponseCode() to call getInputStream() again — re-firing the advice and recursing until a + // StackOverflowError. This ThreadLocal breaks that loop. + private static final ThreadLocal TRIGGERING_RESPONSE_CODE = + ThreadLocal.withInitial(() -> Boolean.FALSE); + + private NetworkEventBridge() {} + + public static void resetRecordedForTesting() { + RECORDED.clear(); + TRIGGERING_RESPONSE_CODE.remove(); + } + + /** + * Returns {@code true} if the caller may proceed to trigger {@code getResponseCode()}; + * {@code false} if a trigger is already in progress on this thread (re-entrant call). + * + *

The caller that receives {@code true} must call {@link #exitResponseCodeTrigger()} in a + * {@code finally} block. A re-entrant caller receives {@code false} and must not call exit. + */ + public static boolean enterResponseCodeTrigger() { + if (TRIGGERING_RESPONSE_CODE.get()) { + return false; + } + TRIGGERING_RESPONSE_CODE.set(Boolean.TRUE); + return true; + } + + /** + * Clears the re-entry guard set by {@link #enterResponseCodeTrigger()}. + */ + public static void exitResponseCodeTrigger() { + TRIGGERING_RESPONSE_CODE.remove(); + } + + /** + * Marks the given key as recorded. Returns {@code true} if this is the first time, + * {@code false} if already recorded (duplicate/re-entrant call). + */ + public static boolean markAsRecorded(Object key) { + return RECORDED.add(key); + } + + /** + * Records a network telemetry event for the given key if not already recorded. + * + *

Uses the key as a deduplication token — subsequent calls with the same key are ignored. + */ + public static void recordNetworkEvent(Object key, String method, String url, String statusCode) { + if (!markAsRecorded(key)) { + return; // deduplicate re-entrant calls for the same connection + } + AgentTelemetryStore.recordNetworkEvent(method, UrlSanitizer.sanitize(url), statusCode); + } + + /** + * Returns a {@link java.util.function.BiConsumer} that records telemetry when an async + * HTTP response completes. Intended to be chained via {@code CompletableFuture.whenComplete}. + * + *

The callback is created here, in the agent's own classloader, so it can call the rest of + * this class directly — advice inlined into the JDK's HTTP client cannot, and would pay the + * reflection cost of crossing the classloader gap on every completion. + */ + public static java.util.function.BiConsumer createAsyncCallback( + Object request) { + // Captured here, on the thread that issued the request: by the time the callback runs, the + // HTTP client's own thread carries neither the caller's context classloader nor its stack, so + // the application that made the call could no longer be identified. + ClassLoader origin = AgentTelemetryStore.currentOrigin(); + return (response, thrown) -> { + try { + if (thrown != null) { + if (markAsRecorded(thrown)) { + String message = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + AgentTelemetryStore.recordErrorEvent(origin, errorMessage(message)); + } + return; + } + if (response != null) { + // Look up methods via public interfaces, not the internal JDK implementation class. + // NetworkEventBridge runs in the app classloader (unnamed module) and cannot access + // jdk.internal.net.http.*; java.net.http.* is exported and accessible. + Class httpResponseIface = Class.forName("java.net.http.HttpResponse"); + Class httpRequestIface = Class.forName("java.net.http.HttpRequest"); + int statusCode = (Integer) httpResponseIface.getMethod("statusCode").invoke(response); + if (statusCode >= 400 && markAsRecorded(response)) { + Object uri = httpRequestIface.getMethod("uri").invoke(request); + String method = (String) httpRequestIface.getMethod("method").invoke(request); + AgentTelemetryStore.recordNetworkEvent(origin, method, + UrlSanitizer.sanitize(uri.toString()), String.valueOf(statusCode)); + } + } + } catch (Throwable ignored) { + // Callback must never throw — swallow all errors + } + }; + } + + /** + * Joins a base URI with a request URI, for clients that dispatch a target host separately from a + * request whose URI may be relative. + * + *

Apache HC's {@code doExecute(HttpHost, request, context)} receives the target host as its + * own argument, so a request issued through the host-based {@code execute(HttpHost, request)} + * overloads carries only a path (e.g. {@code /charge}). Rejoining the two is what keeps the host + * in the recorded URL. A request URI that is already absolute is returned untouched, and a null + * base (HC leaves the target null for a relative URI it could not resolve) degrades to the path + * alone. + * + * @param baseUri the target host as a URI (e.g. {@code https://api.example.com}), or null + * @param requestUri the request URI, absolute or relative, or null + * @return the joined URL — never null, so the caller always has something to sanitize + */ + public static String composeUrl(String baseUri, String requestUri) { + if (requestUri == null || requestUri.isEmpty()) { + return baseUri != null ? baseUri : ""; + } + if (isAbsolute(requestUri)) { + return requestUri; + } + if (baseUri == null) { + return requestUri; + } + if (requestUri.startsWith("/")) { + return baseUri.concat(requestUri); + } + return baseUri.concat("/").concat(requestUri); + } + + // Bound the "://" search to the characters before the first '/', '?', or '#', i.e. to where a + // scheme could legally appear. A relative request URI can carry a nested absolute URL in its + // query or path — /api/redirect?url=https://other.example.com/foo from an OAuth redirect + // endpoint, URL shortener, or proxy-style API — and treating that as already-absolute would drop + // the target host, leaving the sanitized telemetry URL as a bare path with no host at all. + private static boolean isAbsolute(String requestUri) { + for (int i = 0; i < requestUri.length(); i++) { + char character = requestUri.charAt(i); + if (character == '/' || character == '?' || character == '#') { + return false; + } + if (character == ':' && requestUri.startsWith("://", i)) { + return true; + } + } + return false; + } + + /** + * Records a manual error telemetry event with the given message. + * + *

Called when an HTTP request fails with an I/O exception rather than a status code. + */ + public static void recordError(String message) { + AgentTelemetryStore.recordErrorEvent(errorMessage(message)); + } + + private static String errorMessage(String message) { + return "Network error: " + (message != null ? message : "unknown"); + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/RollbarAgent.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/RollbarAgent.java new file mode 100644 index 00000000..146259ee --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/RollbarAgent.java @@ -0,0 +1,111 @@ +package com.rollbar.agent; + +import com.rollbar.agent.instrumentation.ApacheHttpClient4Instrumentation; +import com.rollbar.agent.instrumentation.ApacheHttpClient5Instrumentation; +import com.rollbar.agent.instrumentation.HttpUrlConnectionInstrumentation; +import com.rollbar.agent.instrumentation.JavaHttpClientInstrumentation; +import java.lang.instrument.Instrumentation; +import java.util.concurrent.atomic.AtomicInteger; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatchers; +import net.bytebuddy.utility.JavaModule; + +/** + * Java agent entry point. Attach with {@code -javaagent:/path/to/rollbar-java-agent.jar}. + * + *

Wire into your Rollbar configuration with: + *

+ *   Rollbar.init(withAccessToken("...")
+ *       .telemetryEventTracker(new AgentTelemetryEventTracker())
+ *       .build());
+ * 
+ * + *

{@code AgentTelemetryEventTracker} ships in {@code rollbar-java}, not here, and no method of + * this class may mention a Rollbar SDK type. The JVM calls {@code getDeclaredMethods()} on the + * {@code Premain-Class} to find {@code premain}, which loads every type named in every declared + * signature; an SDK type there is looked up in the system classloader, where an application that + * keeps its dependencies in a child loader (Spring Boot fat jar, WAR) does not have it, and the + * resulting {@code NoClassDefFoundError} kills the JVM at startup with "processing of -javaagent + * failed". See {@link AgentTelemetryStore}. + */ +public class RollbarAgent { + + private RollbarAgent() {} + + public static void premain(String args, Instrumentation inst) { + installInstrumentation(inst); + } + + public static void agentmain(String args, Instrumentation inst) { + installInstrumentation(inst); + } + + private static void installInstrumentation(Instrumentation inst) { + // Override ByteBuddy's default ignore matcher, which excludes everything loaded by the + // bootstrap and extension classloaders, so we can instrument the JDK HTTP clients + // (HttpURLConnection, HttpClient) that live there. + // + // ignore() replaces that default rather than adding to it, so the rest of the default has to + // be restored by hand. Only the classloader exclusion is deliberately dropped: + // - net.bytebuddy.*/com.rollbar.agent.shaded.* — avoids instrumentation loops. + // - sun.reflect.*/jdk.internal.reflect.* — the reflection machinery an advice body itself + // runs through. + // - isSynthetic() — this matcher is the pre-gate on *every* class load in the JVM. Without + // it, every lambda and dynamic proxy the application ever generates is handed to all four + // type matchers below, two of which run the hasSuperType() hierarchy walk their own + // comments call relatively costly. None of them can ever match a synthetic class, so that + // work is pure overhead — paid application-wide, for the life of the process. + AgentBuilder builder = new AgentBuilder.Default() + .ignore(ElementMatchers.nameStartsWith("net.bytebuddy.") + .or(ElementMatchers.nameStartsWith("com.rollbar.agent.shaded.")) + .or(ElementMatchers.nameStartsWith("sun.reflect.")) + .or(ElementMatchers.nameStartsWith("jdk.internal.reflect.")) + .or(ElementMatchers.isSynthetic())) + .with(new ErrorReportingListener()) + .with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE) + .with(AgentBuilder.TypeStrategy.Default.REDEFINE); + + HttpUrlConnectionInstrumentation.install(builder, inst); + JavaHttpClientInstrumentation.installIfAvailable(builder, inst); + ApacheHttpClient4Instrumentation.installIfAvailable(builder, inst); + ApacheHttpClient5Instrumentation.installIfAvailable(builder, inst); + } + + /** + * Reports transformation failures to {@code System.err}. + * + *

ByteBuddy's default listener discards them, which turns the most likely whole-agent failure + * into a silent one: ByteBuddy can only parse class files up to the JDK version it was built + * against, and the classes this agent instruments ({@code HttpURLConnection}, + * {@code HttpClient}) always carry the running JDK's class file version. Run on a JDK newer than + * the bundled ByteBuddy and every transformation fails to parse — the agent installs cleanly, + * records nothing, and the first symptom is missing telemetry. Bumping ByteBuddy fixes today's + * JDKs; this listener is what makes tomorrow's diagnosable. + * + *

Output is capped: a version mismatch fails for every instrumented type, and an agent must + * not flood a process's stderr. + */ + static final class ErrorReportingListener extends AgentBuilder.Listener.Adapter { + + static final int MAX_REPORTS = 10; + + private final AtomicInteger reported = new AtomicInteger(); + + @Override + public void onError(String typeName, ClassLoader classLoader, JavaModule module, + boolean loaded, Throwable throwable) { + int count = reported.incrementAndGet(); + if (count > MAX_REPORTS) { + return; + } + System.err.println("[rollbar-java-agent] failed to instrument " + typeName + ": " + + throwable); + if (count == MAX_REPORTS) { + System.err.println("[rollbar-java-agent] further instrumentation errors suppressed; " + + "if these mention an unsupported class file version, this agent's ByteBuddy is " + + "older than the JDK it is running on"); + } + } + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/UrlSanitizer.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/UrlSanitizer.java new file mode 100644 index 00000000..e06e4653 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/UrlSanitizer.java @@ -0,0 +1,108 @@ +package com.rollbar.agent; + +import java.net.URI; +import java.net.URISyntaxException; + +public final class UrlSanitizer { + + private UrlSanitizer() {} + + /** + * Strips userinfo, query parameters, and fragment from the URL, leaving only + * scheme, host, port, and path. + */ + public static String sanitize(String rawUrl) { + if (rawUrl == null) { + return null; + } + try { + URI uri = new URI(rawUrl); + // Use the authority rather than getHost(): URI parses authorities that fail RFC 2396 + // server-based grammar (e.g. underscores in Kubernetes/AD internal DNS) in registry-based + // mode, where getHost() returns null and the host would be silently dropped. Strip userinfo + // from the authority manually. + // + // Use the *raw* (still percent-encoded) components: getAuthority()/getUserInfo() decode their + // value, so a password holding an encoded '@' — https://user:p%40ss@example.com/path — + // decodes to user:p@ss@example.com and the delimiter search would keep 'ss@example.com', + // recording part of the credential as the host. + String authority = uri.getRawAuthority(); + if (authority == null) { + // No authority to strip (relative URI, or opaque URI such as mailto:); dropping query and + // fragment with plain string ops is all that is left to do. + return fallbackSanitize(rawUrl); + } + // Last '@' wins: unencoded '@' is illegal inside userinfo, so a second one means a malformed + // authority that registry-based parsing let through — strip through it rather than leak it. + int at = authority.lastIndexOf('@'); + if (at >= 0) { + authority = authority.substring(at + 1); + } + String path = uri.getRawPath() != null ? uri.getRawPath() : ""; + String scheme = uri.getScheme(); + return (scheme != null ? scheme + "://" : "//") + authority + path; + } catch (URISyntaxException e) { + return fallbackSanitize(rawUrl); + } + } + + // URI rejected the URL (e.g. unescaped space, bad percent-escape). Strip query, fragment, and + // userinfo with plain string ops rather than failing open with the raw URL. + private static String fallbackSanitize(String rawUrl) { + // Drop query string and fragment — take everything before the first '?' or '#'. + int end = rawUrl.length(); + int query = rawUrl.indexOf('?'); + int header = rawUrl.indexOf('#'); + if (query >= 0) { + end = query; + } + if (header >= 0) { + end = Math.min(end, header); + } + String result = rawUrl.substring(0, end); + + // Drop userinfo: scheme://user:pass@host/path → scheme://host/path. Bound the '@' search to + // the authority component (before the first '/', '?', or '#') so an '@' inside the path — e.g. + // /@handle or /@scope/pkg — is not mistaken for the userinfo separator, which would delete the + // real host and promote a path segment to host. + int authorityStart = authorityStart(result); + if (authorityStart < 0) { + return result; + } + + int authorityEnd = result.length(); + for (int i = authorityStart; i < result.length(); i++) { + char c = result.charAt(i); + if (c == '/' || c == '?' || c == '#') { + authorityEnd = i; + break; + } + } + + // Last '@' within those bounds wins, matching the primary path above: an unencoded '@' is + // illegal inside userinfo, so a second one means malformed credentials. Stopping at the + // first one would leave everything between them — the tail of a password — in the URL. + int atSign = result.lastIndexOf('@', authorityEnd - 1); + if (atSign >= authorityStart) { + result = result.substring(0, authorityStart) + result.substring(atSign + 1); + } + return result; + } + + /** + * Where the authority begins, or -1 for a URL that has none. + * + *

A leading {@code //} is checked first, and not only after {@code ://} fails to match: in a + * network-path reference such as {@code //host/proxy/https://elsewhere}, the first {@code ://} + * belongs to a nested URL in the path, and taking it would leave the real authority — where the + * credentials are — unstripped. + */ + private static int authorityStart(String url) { + if (url.startsWith("//")) { + // RFC 3986 network-path reference: the authority follows the two slashes, scheme omitted. + return 2; + } + int schemeEnd = url.indexOf("://"); + return schemeEnd >= 0 ? schemeEnd + 3 : -1; + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient4Instrumentation.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient4Instrumentation.java new file mode 100644 index 00000000..91e5dd6e --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient4Instrumentation.java @@ -0,0 +1,177 @@ +package com.rollbar.agent.instrumentation; + +import com.rollbar.agent.NetworkEventBridge; +import java.lang.instrument.Instrumentation; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Installs ByteBuddy advice on Apache HttpClient 4.x to capture network errors. + */ +public final class ApacheHttpClient4Instrumentation { + + private ApacheHttpClient4Instrumentation() {} + + /** + * Installs a ByteBuddy transformer for Apache HttpClient 4.x. + * + *

The transformer fires only if a subtype of + * {@code org.apache.http.impl.client.CloseableHttpClient} is loaded at runtime; if HC4 is absent + * the registered matcher simply never matches. + */ + public static void installIfAvailable(AgentBuilder builder, Instrumentation inst) { + // Always install — ByteBuddy intercepts class loading at the JVM level regardless of which + // classloader (system, app, or child) eventually loads the client. If HC4 is absent the + // transformer simply never fires. We must not use Class.forName here: loading + // CloseableHttpClient before the transformer is installed prevents instrumentation because no + // RedefinitionStrategy is configured. + builder + // doExecute() is declared abstract on CloseableHttpClient and implemented by its subclasses + // (InternalHttpClient, MinimalHttpClient, third-party wrappers), so we match subtypes. + // The name filters keep the (relatively costly) hierarchy walk off the JDK classes that + // reach this matcher because RollbarAgent un-ignores java.* for the JDK HTTP clients. + .type(ElementMatchers.not(ElementMatchers.nameStartsWith("java.")) + .and(ElementMatchers.not(ElementMatchers.nameStartsWith("javax."))) + .and(ElementMatchers.not(ElementMatchers.nameStartsWith("jdk."))) + .and(ElementMatchers.not(ElementMatchers.nameStartsWith("sun."))) + .and(ElementMatchers.hasSuperType( + ElementMatchers.named("org.apache.http.impl.client.CloseableHttpClient")))) + .transform((b, typeDescription, classLoader, module, protectionDomain) -> + b.visit(Advice.to(DoExecuteAdvice.class) + // Target doExecute(HttpHost, HttpRequest, HttpContext) — the real convergence point + // of every dispatch path. Verified against httpclient-4.5.14 bytecode: the + // HttpUriRequest overloads reach it via determineTarget(), the HttpHost overloads + // invoke it directly, and the ResponseHandler overloads route through + // execute(HttpHost, HttpRequest, HttpContext). Instrumenting any public execute() + // overload instead would miss the paths that bypass it. Exactly one advice + // invocation per request, whichever overload the caller used. + .on(ElementMatchers.named("doExecute") + .and(ElementMatchers.not(ElementMatchers.isAbstract())) + .and(ElementMatchers.not(ElementMatchers.isBridge())) + .and(ElementMatchers.takesArgument(0, + ElementMatchers.named("org.apache.http.HttpHost"))) + .and(ElementMatchers.takesArgument(1, + ElementMatchers.named("org.apache.http.HttpRequest"))) + .and(ElementMatchers.takesArgument(2, + ElementMatchers.named("org.apache.http.protocol.HttpContext"))))) + ) + .installOn(inst); + } + + /** + * Records a 4xx/5xx HC 4.x response as telemetry; other statuses are ignored. Called by + * {@link DoExecuteAdvice}, which cannot name {@code org.apache.http} types itself. + * + *

Members are read through the public {@code org.apache.http} interfaces rather than the + * concrete class of each object, because HC 4.x hands back package-private implementations — + * {@code doExecute} returns {@code org.apache.http.impl.execchain.HttpResponseProxy} — and a + * {@link java.lang.reflect.Method} looked up on such a class cannot be invoked. The reflection + * runs once per request, which is immaterial next to the HTTP call it describes. + * + * @param target the request target the client dispatched to, or null + * @param request the executed request + * @param response the response returned by {@code doExecute} + */ + public static void recordResponse(Object target, Object request, Object response) { + try { + Object statusLine = invokeVia("org.apache.http.HttpResponse", response, "getStatusLine"); + if (statusLine == null) { + return; + } + int statusCode = + (Integer) invokeVia("org.apache.http.StatusLine", statusLine, "getStatusCode"); + if (statusCode < 400) { + return; + } + Object requestLine = invokeVia("org.apache.http.HttpRequest", request, "getRequestLine"); + if (requestLine == null) { + return; + } + String method = (String) invokeVia("org.apache.http.RequestLine", requestLine, "getMethod"); + String requestUri = (String) invokeVia("org.apache.http.RequestLine", requestLine, "getUri"); + // The host-based overloads carry the target separately from a request whose URI may be + // just a path, so rejoin the two rather than reading the request URI alone. + String base = target != null + ? (String) invokeVia("org.apache.http.HttpHost", target, "toURI") : null; + NetworkEventBridge.recordNetworkEvent( + response, + method, + NetworkEventBridge.composeUrl(base, requestUri), + String.valueOf(statusCode) + ); + } catch (Throwable ignored) { + // Telemetry must never disrupt the instrumented request + } + } + + /** + * Invokes {@code methodName} on {@code receiver} through the named public API type, resolved from + * the receiver's own classloader — the one that loaded HC 4.x, which the agent's classloader may + * not be able to see. + */ + private static Object invokeVia(String apiTypeName, Object receiver, String methodName) + throws ReflectiveOperationException { + ClassLoader classLoader = receiver.getClass().getClassLoader(); + Class apiType = Class.forName(apiTypeName, false, + classLoader != null ? classLoader : ClassLoader.getSystemClassLoader()); + return apiType.getMethod(methodName).invoke(receiver); + } + + /** + * Apache HC 4.x runs in the application classloader, so the advice body can reference Rollbar + * classes directly without the TCCL reflection bridge. + * + *

The advice signature, however, must not name {@code org.apache.http} types. + * {@code Advice.to(DoExecuteAdvice.class)} resolves this method's parameter and return types via + * {@link Class#getDeclaredMethods()}, in the classloader that loaded the advice class — the + * agent's, which under {@code -javaagent} is the system classloader. Wherever HC 4.x is loaded by + * a child classloader the agent cannot see (Spring Boot executable jars, per-WAR container + * classloaders, OSGi bundles), that lookup throws {@link NoClassDefFoundError} inside the + * transformer; the AgentBuilder reports it and moves on, and HC 4.x is silently never + * instrumented. So everything the advice touches is typed {@link Object} and read reflectively in + * {@link ApacheHttpClient4Instrumentation#recordResponse}, which runs after the weave and can + * resolve those types from the client's own classloader. + * + *

String concatenation in the advice body must use {@link String#concat} or + * {@link StringBuilder} rather than the {@code +} operator. Apache HC 4.x jars are compiled at + * class-file version 50 (Java 6); the Java 9+ compiler emits {@code invokedynamic} for {@code +} + * concatenation, which ByteBuddy cannot inline into a Java 6 class file. Delegating to + * {@link NetworkEventBridge} keeps concatenation out of the inlined code entirely. + */ + public static class DoExecuteAdvice { + + /** + * Fires after {@code doExecute(HttpHost, HttpRequest, HttpContext)} returns or throws, + * recording 4xx/5xx responses as telemetry. + * + *

The response (or the thrown exception) is the deduplication key, so a client that wraps + * another {@code CloseableHttpClient} — where the outer and inner {@code doExecute} both fire + * for one request — still records a single event. + */ + @Advice.OnMethodExit(onThrowable = Throwable.class) + public static void onExit( + @Advice.Argument(0) Object target, + @Advice.Argument(1) Object request, + @Advice.Return Object response, + @Advice.Thrown Throwable thrown + ) { + try { + if (thrown != null) { + if (NetworkEventBridge.markAsRecorded(thrown)) { + String message = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + NetworkEventBridge.recordError(message); + } + return; + } + + if (response != null && request != null) { + ApacheHttpClient4Instrumentation.recordResponse(target, request, response); + } + } catch (Throwable ignored) { + // Advice must never throw + } + } + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient5Instrumentation.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient5Instrumentation.java new file mode 100644 index 00000000..02187d77 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient5Instrumentation.java @@ -0,0 +1,177 @@ +package com.rollbar.agent.instrumentation; + +import com.rollbar.agent.NetworkEventBridge; +import java.lang.instrument.Instrumentation; +import java.lang.reflect.InvocationTargetException; + +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Installs ByteBuddy advice on Apache HttpClient 5.x to capture network errors. + */ +public final class ApacheHttpClient5Instrumentation { + + private ApacheHttpClient5Instrumentation() {} + + /** + * Installs a ByteBuddy transformer for Apache HttpClient 5.x. + * + *

The transformer fires only if a subtype of + * {@code org.apache.hc.client5.http.impl.classic.CloseableHttpClient} is loaded at runtime; if + * HC5 is absent the registered matcher simply never matches. + */ + public static void installIfAvailable(AgentBuilder builder, Instrumentation inst) { + // Always install — ByteBuddy intercepts class loading at the JVM level regardless of which + // classloader (system, app, or child) eventually loads the client. If HC5 is absent the + // transformer simply never fires. We must not use Class.forName here: loading + // CloseableHttpClient before the transformer is installed prevents instrumentation because no + // RedefinitionStrategy is configured. + builder + // doExecute() is declared abstract on CloseableHttpClient and implemented by its subclasses + // (InternalHttpClient, MinimalHttpClient, third-party wrappers), so we match subtypes. + // The name filters keep the (relatively costly) hierarchy walk off the JDK classes that + // reach this matcher because RollbarAgent un-ignores java.* for the JDK HTTP clients. + .type(ElementMatchers.not(ElementMatchers.nameStartsWith("java.")) + .and(ElementMatchers.not(ElementMatchers.nameStartsWith("javax."))) + .and(ElementMatchers.not(ElementMatchers.nameStartsWith("jdk."))) + .and(ElementMatchers.not(ElementMatchers.nameStartsWith("sun."))) + .and(ElementMatchers.hasSuperType(ElementMatchers.named( + "org.apache.hc.client5.http.impl.classic.CloseableHttpClient")))) + .transform((b, typeDescription, classLoader, module, protectionDomain) -> + b.visit(Advice.to(DoExecuteAdvice.class) + // Target doExecute(HttpHost, ClassicHttpRequest, HttpContext) — the real + // convergence point of every dispatch path. Verified against httpclient5-5.3.1 + // bytecode: the request-only overloads reach it via determineTarget(), the HttpHost + // overloads invoke it directly, and the HttpClientResponseHandler overloads route + // through execute(HttpHost, ClassicHttpRequest, HttpContext, handler), which calls + // it too. Instrumenting any public execute() overload instead would miss the paths + // that bypass it, and the handler overloads erase their return type to Object, + // which cannot bind to @Advice.Return. doExecute() has a single concrete signature, + // so both problems disappear. + .on(ElementMatchers.named("doExecute") + .and(ElementMatchers.not(ElementMatchers.isAbstract())) + .and(ElementMatchers.not(ElementMatchers.isBridge())) + .and(ElementMatchers.takesArgument(0, + ElementMatchers.named("org.apache.hc.core5.http.HttpHost"))) + .and(ElementMatchers.takesArgument(1, + ElementMatchers.named("org.apache.hc.core5.http.ClassicHttpRequest"))) + .and(ElementMatchers.takesArgument(2, + ElementMatchers.named("org.apache.hc.core5.http.protocol.HttpContext"))))) + ) + .installOn(inst); + } + + /** + * Records a 4xx/5xx HC 5.x response as telemetry; other statuses are ignored. Called by + * {@link DoExecuteAdvice}, which cannot name {@code org.apache.hc} types itself. + * + *

Members are read through the public {@code org.apache.hc.core5.http} interfaces rather than + * the concrete class of each object, because HC 5.x hands back package-private implementations — + * {@code doExecute} returns the adapter {@code CloseableHttpResponse.adapt} produces — and a + * {@link java.lang.reflect.Method} looked up on such a class cannot be invoked. The reflection + * runs once per request, which is immaterial next to the HTTP call it describes. + * + * @param target the request target the client dispatched to, or null + * @param request the executed request + * @param response the response returned by {@code doExecute} + */ + public static void recordResponse(Object target, Object request, Object response) { + try { + int statusCode = + (Integer) invokeVia("org.apache.hc.core5.http.HttpResponse", response, "getCode"); + if (statusCode < 400) { + return; + } + String requestUri; + try { + Object uri = invokeVia("org.apache.hc.core5.http.HttpRequest", request, "getUri"); + requestUri = uri != null ? uri.toString() : null; + } catch (InvocationTargetException ignored) { + // getUri() throws URISyntaxException for a request URI HC could not assemble; the raw + // request URI is still worth recording. + requestUri = + (String) invokeVia("org.apache.hc.core5.http.HttpRequest", request, "getRequestUri"); + } + String method = + (String) invokeVia("org.apache.hc.core5.http.HttpRequest", request, "getMethod"); + // The host-based overloads carry the target separately from a request whose URI may be + // just a path, so rejoin the two rather than reading the request URI alone. + String base = target != null + ? (String) invokeVia("org.apache.hc.core5.http.HttpHost", target, "toURI") : null; + NetworkEventBridge.recordNetworkEvent( + response, + method, + NetworkEventBridge.composeUrl(base, requestUri), + String.valueOf(statusCode) + ); + } catch (Throwable ignored) { + // Telemetry must never disrupt the instrumented request + } + } + + /** + * Invokes {@code methodName} on {@code receiver} through the named public API type, resolved from + * the receiver's own classloader — the one that loaded HC 5.x, which the agent's classloader may + * not be able to see. + */ + private static Object invokeVia(String apiTypeName, Object receiver, String methodName) + throws ReflectiveOperationException { + ClassLoader classLoader = receiver.getClass().getClassLoader(); + Class apiType = Class.forName(apiTypeName, false, + classLoader != null ? classLoader : ClassLoader.getSystemClassLoader()); + return apiType.getMethod(methodName).invoke(receiver); + } + + /** + * Apache HC 5.x runs in the application classloader, so the advice body can reference Rollbar + * classes directly without the TCCL reflection bridge. + * + *

The advice signature, however, must not name {@code org.apache.hc} types. + * {@code Advice.to(DoExecuteAdvice.class)} resolves this method's parameter and return types via + * {@link Class#getDeclaredMethods()}, in the classloader that loaded the advice class — the + * agent's, which under {@code -javaagent} is the system classloader. Wherever HC 5.x is loaded by + * a child classloader the agent cannot see (Spring Boot executable jars, per-WAR container + * classloaders, OSGi bundles), that lookup throws {@link NoClassDefFoundError} inside the + * transformer; the AgentBuilder reports it and moves on, and HC 5.x is silently never + * instrumented. So everything the advice touches is typed {@link Object} and read reflectively in + * {@link ApacheHttpClient5Instrumentation#recordResponse}, which runs after the weave and can + * resolve those types from the client's own classloader. + */ + public static class DoExecuteAdvice { + + /** + * Fires after {@code doExecute(HttpHost, ClassicHttpRequest, HttpContext)} returns or throws, + * recording 4xx/5xx responses as telemetry. + * + *

The response (or the thrown exception) is the deduplication key, so a client that wraps + * another {@code CloseableHttpClient} — where the outer and inner {@code doExecute} both fire + * for one request — still records a single event. + */ + @Advice.OnMethodExit(onThrowable = Throwable.class) + public static void onExit( + @Advice.Argument(0) Object target, + @Advice.Argument(1) Object request, + @Advice.Return Object response, + @Advice.Thrown Throwable thrown + ) { + try { + if (thrown != null) { + if (NetworkEventBridge.markAsRecorded(thrown)) { + String message = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + NetworkEventBridge.recordError(message); + } + return; + } + + if (response != null && request != null) { + ApacheHttpClient5Instrumentation.recordResponse(target, request, response); + } + } catch (Throwable ignored) { + // Advice must never throw + } + } + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentation.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentation.java new file mode 100644 index 00000000..5f27e9f7 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentation.java @@ -0,0 +1,190 @@ +package com.rollbar.agent.instrumentation; + +import java.lang.instrument.Instrumentation; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Installs ByteBuddy advice on {@code java.net.HttpURLConnection} to capture network errors. + */ +public final class HttpUrlConnectionInstrumentation { + + private HttpUrlConnectionInstrumentation() {} + + /** + * Instruments {@code HttpURLConnection} to record 4xx/5xx responses and network errors. + * + *

Three entry points are covered: + *

    + *
  • {@code getResponseCode()} on the base class — catches callers that check the code + * explicitly.
  • + *
  • {@code getInputStream()} on concrete subclasses — catches the common pattern where the + * caller reads the body directly and only sees the IOException on 4xx.
  • + *
  • {@code getErrorStream()} on concrete subclasses — catches callers that check for an error + * stream after {@code connect()} or after catching the IOException from + * {@code getInputStream()}.
  • + *
+ * + *

{@code getInputStream} and {@code getErrorStream} advice simply invoke + * {@code getResponseCode()} to trigger the base-class advice; deduplication in + * {@link com.rollbar.agent.NetworkEventBridge} ensures only one event is emitted per connection. + */ + public static void install(AgentBuilder builder, Instrumentation inst) { + builder + .type(ElementMatchers.named("java.net.HttpURLConnection")) + .transform((b, typeDescription, classLoader, module, protectionDomain) -> + b.visit(Advice.to(GetResponseCodeAdvice.class) + .on(ElementMatchers.named("getResponseCode"))) + ) + .installOn(inst); + + // getInputStream() and getErrorStream() are overridden in concrete subclasses, so we must + // target subtypes rather than java.net.HttpURLConnection itself. + builder + .type(ElementMatchers.isSubTypeOf(java.net.HttpURLConnection.class) + .and(ElementMatchers.not(ElementMatchers.named("java.net.HttpURLConnection")))) + .transform((b, typeDescription, classLoader, module, protectionDomain) -> + b.visit(Advice.to(GetInputStreamAdvice.class) + .on(ElementMatchers.named("getInputStream") + .and(ElementMatchers.not(ElementMatchers.isAbstract())))) + .visit(Advice.to(GetErrorStreamAdvice.class) + .on(ElementMatchers.named("getErrorStream") + .and(ElementMatchers.not(ElementMatchers.isAbstract())))) + ) + .installOn(inst); + } + + /** + * Advice inlined into concrete {@code HttpURLConnection.getInputStream()}. + * + *

When {@code getInputStream()} throws (4xx/5xx response), invokes {@code getResponseCode()} + * so that {@link GetResponseCodeAdvice} records the event. Deduplication in + * {@link com.rollbar.agent.NetworkEventBridge} prevents double-recording if the caller also calls + * {@code getResponseCode()} or {@code getErrorStream()} afterwards. + */ + public static class GetInputStreamAdvice { + + /** + * Fires when {@code getInputStream()} throws, ensuring the failed request is recorded. + */ + @Advice.OnMethodExit(onThrowable = Throwable.class) + public static void onExit( + @Advice.This Object connection, + @Advice.Thrown Throwable thrown + ) { + if (thrown == null) { + return; + } + try { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = ClassLoader.getSystemClassLoader(); + } + Class bridge = classLoader.loadClass("com.rollbar.agent.NetworkEventBridge"); + + // Re-entry guard: on connection-level failures (responseCode == -1) the JDK's + // getResponseCode() calls getInputStream() again, which re-fires this advice. Without the + // guard that recurses until a StackOverflowError, recording nothing. + Boolean entered = (Boolean) bridge.getMethod("enterResponseCodeTrigger").invoke(null); + if (entered == null || !entered) { + return; + } + try { + connection.getClass().getMethod("getResponseCode").invoke(connection); + } finally { + bridge.getMethod("exitResponseCodeTrigger").invoke(null); + } + } catch (Throwable ignored) { + // Advice must never throw + } + } + } + + /** + * Advice inlined into concrete {@code HttpURLConnection.getErrorStream()}. + * + *

A non-null return means the server sent a 4xx/5xx response. Invokes + * {@code getResponseCode()} so that {@link GetResponseCodeAdvice} records the event. + * Deduplication in {@link com.rollbar.agent.NetworkEventBridge} prevents double-recording. + */ + public static class GetErrorStreamAdvice { + + /** + * Fires when {@code getErrorStream()} returns a non-null stream. + */ + @Advice.OnMethodExit + public static void onExit( + @Advice.This Object connection, + @Advice.Return Object errorStream + ) { + if (errorStream == null) { + return; + } + try { + connection.getClass().getMethod("getResponseCode").invoke(connection); + } catch (Throwable ignored) { + // Advice must never throw + } + } + } + + /** + * Advice inlined into {@code java.net.HttpURLConnection.getResponseCode()}. + * + *

Only JDK types are referenced directly. The Rollbar bridge is reached via TCCL + * to cross the classloader boundary. The connection instance is used as the deduplication + * key — getResponseCode() is called re-entrantly up to 3 times per request internally. + */ + public static class GetResponseCodeAdvice { + + /** + * Fires after {@code getResponseCode()} returns or throws, recording 4xx/5xx as telemetry. + * + *

Uses the connection instance as a deduplication key (on both branches) since + * {@code getResponseCode()} is called re-entrantly up to 3 times per request internally. + * Keying the error branch on the {@code Throwable} instead does not deduplicate: a + * connection-level failure (refused, DNS, TLS, connect timeout) leaves {@code responseCode} at + * -1, so the JDK retries {@code getInputStream()} internally and constructs a fresh + * exception object per attempt. The identity set then sees several distinct objects and lets + * each through, recording one "Network error" event per retry for a single failed request. + */ + @Advice.OnMethodExit(onThrowable = Throwable.class) + public static void onExit( + @Advice.This Object connection, + @Advice.Return int statusCode, + @Advice.Thrown Throwable thrown + ) { + try { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = ClassLoader.getSystemClassLoader(); + } + Class bridge = cl.loadClass("com.rollbar.agent.NetworkEventBridge"); + + if (thrown != null) { + Boolean recorded = (Boolean) bridge + .getMethod("markAsRecorded", Object.class).invoke(null, connection); + if (recorded) { + String msg = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + bridge.getMethod("recordError", String.class).invoke(null, msg); + } + return; + } + + if (statusCode >= 400) { + Object url = connection.getClass().getMethod("getURL").invoke(connection); + String urlStr = url != null ? url.toString() : ""; + String method = (String) connection.getClass() + .getMethod("getRequestMethod").invoke(connection); + bridge.getMethod("recordNetworkEvent", + Object.class, String.class, String.class, String.class) + .invoke(null, connection, method, urlStr, String.valueOf(statusCode)); + } + } catch (Throwable ignored) { + // Advice must never throw — swallow all errors including Error subclasses + } + } + } +} diff --git a/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentation.java b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentation.java new file mode 100644 index 00000000..f36b1971 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentation.java @@ -0,0 +1,146 @@ +package com.rollbar.agent.instrumentation; + +import java.lang.instrument.Instrumentation; +import java.net.http.HttpClient; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Installs ByteBuddy advice on {@code java.net.http.HttpClient} subtypes to capture network errors. + */ +public final class JavaHttpClientInstrumentation { + + private JavaHttpClientInstrumentation() {} + + /** + * Instruments {@code java.net.http.HttpClient} subtypes if available on the current JVM. + * + *

Does nothing if {@code java.net.http.HttpClient} is not present (i.e. below Java 11). + */ + public static void installIfAvailable(AgentBuilder builder, Instrumentation inst) { + try { + Class.forName("java.net.http.HttpClient"); + } catch (ClassNotFoundException e) { + return; + } + + builder + .type(ElementMatchers.isSubTypeOf(HttpClient.class)) + .transform((b, typeDescription, classLoader, module, protectionDomain) -> + b.visit(Advice.to(SendAdvice.class).on(ElementMatchers.named("send"))) + .visit(Advice.to(SendAsyncAdvice.class).on(ElementMatchers.named("sendAsync"))) + ) + .installOn(inst); + } + + /** + * Advice inlined into JDK's HttpClient concrete implementation's sendAsync(). + * + *

At method exit, chains a {@code whenComplete} callback onto the returned + * {@code CompletableFuture}. The callback is created via the bridge (in the app classloader) + * so it can reference Rollbar types without further reflection at completion time. + * Deduplication is handled by the bridge using the response object as the key, which works the + * same way as for sync send(): both HttpClientFacade and HttpClientImpl contribute a callback, + * but only the first one to run with a given response object records an event. + */ + public static class SendAsyncAdvice { + + /** + * Fires after {@code sendAsync()} returns, chaining a telemetry callback on the future. + */ + @Advice.OnMethodExit(onThrowable = Throwable.class) + public static void onExit( + @Advice.Argument(0) Object request, + @Advice.Return Object future, + @Advice.Thrown Throwable thrown + ) { + try { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = ClassLoader.getSystemClassLoader(); + } + Class bridge = cl.loadClass("com.rollbar.agent.NetworkEventBridge"); + + if (thrown != null) { + Boolean recorded = (Boolean) bridge + .getMethod("markAsRecorded", Object.class).invoke(null, thrown); + if (recorded) { + String message = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + bridge.getMethod("recordError", String.class).invoke(null, message); + } + return; + } + + if (future != null) { + Object callback = bridge + .getMethod("createAsyncCallback", Object.class).invoke(null, request); + future.getClass() + .getMethod("whenComplete", java.util.function.BiConsumer.class) + .invoke(future, callback); + } + } catch (Throwable ignored) { + // Advice must never throw — swallow all errors including Error subclasses + } + } + } + + /** + * Advice inlined into JDK's HttpClient concrete implementation's send(). + * + *

Only JDK types are referenced directly. The Rollbar bridge is reached via TCCL + * to cross the classloader boundary. The response object is used as a deduplication key + * since both HttpClientFacade and HttpClientImpl instrument send() and the same response + * object flows through both. + */ + public static class SendAdvice { + + /** + * Fires after {@code send()} returns or throws, recording 4xx/5xx responses as telemetry. + * + *

Uses the response object as a deduplication key to avoid duplicate events when both + * {@code HttpClientFacade} and {@code HttpClientImpl} invoke this advice. + */ + @Advice.OnMethodExit(onThrowable = Throwable.class) + public static void onExit( + @Advice.Argument(0) Object request, + @Advice.Return Object response, + @Advice.Thrown Throwable thrown + ) { + try { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = ClassLoader.getSystemClassLoader(); + } + Class bridge = cl.loadClass("com.rollbar.agent.NetworkEventBridge"); + + if (thrown != null) { + Boolean recorded = (Boolean) bridge + .getMethod("markAsRecorded", Object.class).invoke(null, thrown); + if (recorded) { + String message = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + bridge.getMethod("recordError", String.class).invoke(null, message); + } + return; + } + + if (response != null) { + int statusCode = (Integer) response.getClass().getMethod("statusCode").invoke(response); + if (statusCode >= 400) { + Object uri = request.getClass().getMethod("uri").invoke(request); + String method = (String) request.getClass().getMethod("method").invoke(request); + // response object is the dedup key — unique per send() call, shared between + // HttpClientFacade and HttpClientImpl so only one event is recorded + bridge.getMethod("recordNetworkEvent", + Object.class, String.class, String.class, String.class) + .invoke(null, response, method, uri.toString(), String.valueOf(statusCode)); + } + } + } catch (Throwable ignored) { + // Advice must never throw — swallow all errors including Error subclasses + } + } + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/AgentClassLoaderIsolationTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/AgentClassLoaderIsolationTest.java new file mode 100644 index 00000000..3de3801c --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/AgentClassLoaderIsolationTest.java @@ -0,0 +1,324 @@ +package com.rollbar.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Guards the classloader contract the agent depends on. + * + *

{@code -javaagent:} appends the agent jar to the system class path, while an + * application normally keeps the Rollbar SDK in a child classloader ({@code BOOT-INF/lib} in a + * Spring Boot fat jar, {@code WEB-INF/lib} in a WAR). An SDK type named from agent code therefore + * resolves against the system classloader and is not found — and in a signature of the + * {@code Premain-Class} that kills the JVM before {@code main} runs, because the JVM calls + * {@code getDeclaredMethods()} on it to locate {@code premain}. + */ +public class AgentClassLoaderIsolationTest { + + private static final List FORBIDDEN_REFERENCES = + Arrays.asList("com/rollbar/api/", "com/rollbar/notifier/"); + + @Test + public void agentJarClasses_doNotReferenceRollbarSdkTypes() throws IOException { + List offenders = new ArrayList<>(); + try (JarFile jar = new JarFile(agentJar())) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".class")) { + continue; + } + byte[] bytecode = readAll(jar, entry); + for (String forbidden : FORBIDDEN_REFERENCES) { + if (contains(bytecode, forbidden)) { + offenders.add(entry.getName() + " -> " + forbidden); + } + } + } + } + + assertTrue(offenders.isEmpty(), + "the agent jar loads in the system classloader, which in a Spring Boot fat jar or a WAR " + + "cannot see the application's SDK; move anything that needs an SDK type into " + + "rollbar-java (see AgentTelemetryEventTracker). Offending classes: " + offenders); + } + + @Test + public void premain_startsAndRecordsWithoutTheSdkOnTheClasspath() throws Exception { + // The reported failure in full: a JVM whose classpath has no Rollbar SDK at all. Before the + // split this aborted with "FATAL ERROR in native method: processing of -javaagent failed". + Path java = Path.of(System.getProperty("java.home"), "bin", "java"); + Process process = new ProcessBuilder( + java.toString(), + "-javaagent:" + agentJar().getAbsolutePath(), + "-cp", probeClasspath(), + PremainProbe.class.getName()) + .redirectErrorStream(true) + .start(); + + String output = new String(readAll(process.getInputStream()), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "probe JVM did not exit: " + output); + assertEquals(0, process.exitValue(), "probe JVM failed to start under -javaagent:\n" + output); + assertTrue(output.contains("probe-ok events=1"), + "agent must still record without the SDK present, got:\n" + output); + } + + @Test + public void sdkInChildClassLoader_readsEventsFromTheSystemClassLoaderStore() throws Exception { + // Simulates the Spring Boot / WAR layout: the agent's store in the system classloader, the SDK + // in a child of it. The SDK-side tracker has to reach *up* for the events, which is the only + // direction that works. + Class store = systemClassLoaderStore(); + store.getMethod("resetForTesting").invoke(null); + + try (URLClassLoader applicationLoader = new ApplicationClassLoader(sdkUrls())) { + Class trackerClass = applicationLoader + .loadClass("com.rollbar.notifier.telemetry.AgentTelemetryEventTracker"); + assertEquals(applicationLoader, trackerClass.getClassLoader(), + "the tracker must come from the child loader, not from the system class path"); + + // Recorded on a thread belonging to that application, as a servlet container would. + recordAs(applicationLoader, store, "https://api.example.com/charge", "503"); + + Object tracker = trackerClass.getDeclaredConstructor().newInstance(); + List events = (List) trackerClass.getMethod("getAll").invoke(tracker); + + assertEquals(1, events.size(), "the SDK must see the event the agent recorded"); + String event = events.get(0).toString(); + assertTrue(event.contains("status_code=503"), event); + assertTrue(event.contains("https://api.example.com/charge"), event); + } finally { + store.getMethod("resetForTesting").invoke(null); + } + } + + @Test + public void twoApplicationsInOneJvm_doNotSeeEachOthersEvents() throws Exception { + // Two WARs in one container, each with its own copy of the SDK and its own access token. The + // agent is loaded once for the whole JVM, so without partitioning one deployment's internal + // hostnames and paths would be attached to the other deployment's Rollbar reports. + Class store = systemClassLoaderStore(); + store.getMethod("resetForTesting").invoke(null); + + try (URLClassLoader firstApp = new ApplicationClassLoader(sdkUrls()); + URLClassLoader secondApp = new ApplicationClassLoader(sdkUrls())) { + Object firstTracker = newTracker(firstApp); + Object secondTracker = newTracker(secondApp); + + recordAs(firstApp, store, "https://first.internal/charge", "500"); + recordAs(secondApp, store, "https://second.internal/refund", "503"); + + String firstSees = telemetryOf(firstTracker).toString(); + String secondSees = telemetryOf(secondTracker).toString(); + + assertEquals(1, telemetryOf(firstTracker).size(), firstSees); + assertTrue(firstSees.contains("https://first.internal/charge"), firstSees); + assertFalse(firstSees.contains("second.internal"), + "the other deployment's URLs must not reach this one's report: " + firstSees); + + assertEquals(1, telemetryOf(secondTracker).size(), secondSees); + assertTrue(secondSees.contains("https://second.internal/refund"), secondSees); + assertFalse(secondSees.contains("first.internal"), + "the other deployment's URLs must not reach this one's report: " + secondSees); + } finally { + store.getMethod("resetForTesting").invoke(null); + } + } + + @Test + public void callOnAThreadOwnedByNoApplication_isStillAttributedToTheCallingApplication() + throws Exception { + // A ForkJoinPool.commonPool worker carries the container's classloader, not the application's, + // so the thread alone cannot say whose call this is — and an event nobody can claim is an + // event nobody is shown. The stack still names the caller, which is what keeps telemetry from + // quietly disappearing whenever an application calls out from a shared pool. + Class store = systemClassLoaderStore(); + store.getMethod("resetForTesting").invoke(null); + + try (URLClassLoader app = new ApplicationClassLoader(applicationUrls())) { + Class applicationCode = app.loadClass(CallingApplication.class.getName()); + assertEquals(app, applicationCode.getClassLoader(), + "the calling class must belong to the application, not to the test"); + + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(ClassLoader.getSystemClassLoader()); + try { + applicationCode.getMethod("makeCall", String.class) + .invoke(null, "https://first.internal/charge"); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + + String seen = telemetryOf(newTracker(app)).toString(); + assertTrue(seen.contains("https://first.internal/charge"), + "the application must still be shown the call its own code made: " + seen); + } finally { + store.getMethod("resetForTesting").invoke(null); + } + } + + private static Class systemClassLoaderStore() throws ClassNotFoundException { + return ClassLoader.getSystemClassLoader().loadClass(AgentTelemetryStore.class.getName()); + } + + private static Object newTracker(ClassLoader application) throws Exception { + return application.loadClass("com.rollbar.notifier.telemetry.AgentTelemetryEventTracker") + .getDeclaredConstructor().newInstance(); + } + + private static List telemetryOf(Object tracker) throws Exception { + return (List) tracker.getClass().getMethod("getAll").invoke(tracker); + } + + /** Records an event the way an HTTP call made by that application's code would. */ + private static void recordAs(ClassLoader application, Class store, String url, + String statusCode) throws Exception { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(application); + try { + store.getMethod("recordNetworkEvent", String.class, String.class, String.class) + .invoke(null, "GET", url, statusCode); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + /** Classes the child loader must load itself: the SDK, plus the logging API it needs. */ + private static URL[] sdkUrls() { + return new URL[] { + codeSourceOf("com.rollbar.notifier.telemetry.TelemetryEventTracker"), + codeSourceOf("com.rollbar.api.payload.data.TelemetryEvent"), + codeSourceOf("org.slf4j.Logger"), + }; + } + + /** The SDK, plus this test's own classes, so the application can have its own calling code. */ + private static URL[] applicationUrls() { + List urls = new ArrayList<>(Arrays.asList(sdkUrls())); + urls.add(CallingApplication.class.getProtectionDomain().getCodeSource().getLocation()); + return urls.toArray(new URL[0]); + } + + private static URL codeSourceOf(String className) { + try { + return Class.forName(className).getProtectionDomain().getCodeSource().getLocation(); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("not on the test classpath: " + className, e); + } + } + + private static File agentJar() { + String path = System.getProperty("rollbar.agent.jar"); + if (path == null) { + fail("the rollbar.agent.jar system property is set by the test task; run through Gradle"); + } + File jar = new File(path); + assertTrue(jar.isFile(), "agent jar not built: " + path); + return jar; + } + + private static String probeClasspath() { + return new File(PremainProbe.class.getProtectionDomain().getCodeSource().getLocation() + .getPath()).getAbsolutePath(); + } + + private static byte[] readAll(JarFile jar, JarEntry entry) throws IOException { + try (InputStream in = jar.getInputStream(entry)) { + return readAll(in); + } + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + + // Class names live in the constant pool as modified UTF-8, so a raw byte scan finds every + // reference a class file can carry — field and method descriptors, signatures, and the names of + // types it merely mentions. + private static boolean contains(byte[] bytecode, String text) { + byte[] needle = text.getBytes(StandardCharsets.UTF_8); + outer: + for (int i = 0; i <= bytecode.length - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (bytecode[i + j] != needle[j]) { + continue outer; + } + } + return true; + } + return false; + } + + /** + * Loads the SDK itself instead of delegating for it, so the application's copy is invisible to + * the parent — the asymmetry a Spring Boot fat jar or a WAR creates, whatever the test harness + * happens to put on the system class path. + */ + private static final class ApplicationClassLoader extends URLClassLoader { + + private static final List OWNED_PACKAGES = Arrays.asList( + "com.rollbar.notifier.", + "com.rollbar.api.", + "org.slf4j.", + // The application's own calling code. Named class by class, because the rest of + // com.rollbar.agent is the agent itself and must stay shared with the parent — one store + // for the JVM is the whole point. + CallingApplication.class.getName()); + + ApplicationClassLoader(URL[] urls) { + super(urls, ClassLoader.getSystemClassLoader()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null && owns(name)) { + loaded = findClass(name); + } + if (loaded == null) { + return super.loadClass(name, resolve); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + private static boolean owns(String name) { + for (String owned : OWNED_PACKAGES) { + if (name.startsWith(owned)) { + return true; + } + } + return false; + } + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/AgentTelemetryStoreTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/AgentTelemetryStoreTest.java new file mode 100644 index 00000000..0b3ddb4f --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/AgentTelemetryStoreTest.java @@ -0,0 +1,196 @@ +package com.rollbar.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.singletonList; + +public class AgentTelemetryStoreTest { + + @BeforeEach + @AfterEach + public void reset() { + AgentTelemetryStore.resetForTesting(); + } + + @Test + public void recordNetworkEvent_capturesRequestAndPayloadFields() { + AgentTelemetryStore.setClockForTesting(() -> 1_000_000L); + + AgentTelemetryStore.recordNetworkEvent("GET", "https://api.example.com/charge", "404"); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("network", event.get("type")); + assertEquals("critical", event.get("level")); + assertEquals("server", event.get("source")); + assertEquals("1000000", event.get("timestamp_ms")); + assertEquals("GET", event.get("method")); + assertEquals("https://api.example.com/charge", event.get("url")); + assertEquals("404", event.get("status_code")); + } + + @Test + public void recordErrorEvent_isRecordedAsManual() { + AgentTelemetryStore.recordErrorEvent("Network error: connection refused"); + + Map event = AgentTelemetryStore.getAll().get(0); + assertEquals("manual", event.get("type")); + assertEquals("Network error: connection refused", event.get("message")); + } + + @Test + public void record_omitsNullFieldsRatherThanStoringNulls() { + // The maps cross a classloader boundary and end up in a JSON payload; a null value there is a + // "null" string in the report at best. + AgentTelemetryStore.recordNetworkEvent("GET", null, "500"); + + Map event = AgentTelemetryStore.getAll().get(0); + assertFalse(event.containsKey("url")); + assertNull(event.get("url")); + } + + @Test + public void getAll_dropsOldestOnceCapacityIsReached() { + for (int i = 0; i < AgentTelemetryStore.MAX_EVENTS + 5; i++) { + AgentTelemetryStore.recordNetworkEvent("GET", "https://api.example.com/" + i, "500"); + } + + List> events = AgentTelemetryStore.getAll(); + assertEquals(AgentTelemetryStore.MAX_EVENTS, events.size()); + assertEquals("https://api.example.com/5", events.get(0).get("url"), "oldest must be dropped"); + } + + @Test + public void getAll_returnsSnapshotsCallersCannotCorrupt() { + AgentTelemetryStore.recordNetworkEvent("GET", "https://api.example.com/charge", "404"); + + List> first = AgentTelemetryStore.getAll(); + assertThrows(UnsupportedOperationException.class, () -> first.get(0).put("url", "tampered")); + first.clear(); + + assertEquals(1, AgentTelemetryStore.getAll().size()); + } + + @Test + public void getAll_keepsTheSignatureTheSdkLooksUpReflectively() throws Exception { + // AgentTelemetryEventTracker resolves these methods by name and casts getAll's result, so a + // change here breaks the SDK at runtime rather than at compile time. The element types are + // part of the contract too: the maps cross a classloader boundary, so they may hold only + // types both classloaders agree on. + Method getAll = AgentTelemetryStore.class.getMethod("getAll", ClassLoader.class); + + assertTrue(Modifier.isPublic(getAll.getModifiers())); + assertTrue(Modifier.isStatic(getAll.getModifiers())); + assertEquals("java.util.List>", + getAll.getGenericReturnType().getTypeName()); + } + + @Test + public void getAll_doesNotShowOneApplicationTheEventsOfAnother() throws Exception { + // Two WARs in one container: the agent is loaded once for both, so without partitioning the + // hostnames and paths of one would appear in the other's Rollbar reports. + try (URLClassLoader firstApp = application(); URLClassLoader secondApp = application()) { + recordAs(firstApp, "https://first.internal/charge"); + recordAs(secondApp, "https://second.internal/refund"); + + assertEquals(singletonList("https://first.internal/charge"), urlsSeenBy(firstApp)); + assertEquals(singletonList("https://second.internal/refund"), urlsSeenBy(secondApp)); + } + } + + @Test + public void getAll_includesEventsRecordedByLoadersNestedInTheApplication() throws Exception { + // A JSP or plugin classloader inside the deployment is still the deployment. + try (URLClassLoader app = application(); + URLClassLoader nested = new URLClassLoader(new URL[0], app)) { + recordAs(nested, "https://first.internal/charge"); + + assertEquals(singletonList("https://first.internal/charge"), urlsSeenBy(app)); + } + } + + @Test + public void getAll_withholdsEventsItCannotAttributeEvenFromTheOnlyCaller() throws Exception { + // The JVM's other applications need not use AgentTelemetryEventTracker at all — their traffic + // is instrumented regardless, and the agent never hears from them. So "nobody else is asking" + // is not evidence that an unattributable event belongs to the one application that is. + try (URLClassLoader app = application()) { + // Filed against the agent's own classloader, which is where an event lands when neither the + // thread nor the stack names an application. + AgentTelemetryStore.recordNetworkEvent(AgentTelemetryStore.class.getClassLoader(), + "GET", "https://someone-elses.internal/charge", "500"); + + assertTrue(AgentTelemetryStore.getAll(app).isEmpty(), + "an event that could not be attributed is nobody's to report"); + } + } + + @Test + public void getAll_doesNotRegisterOrOtherwiseChangeWhatOthersSee() throws Exception { + // getAll is a read. A diagnostic call from anywhere must not alter what an application is + // shown afterwards. + try (URLClassLoader app = application()) { + recordAs(app, "https://first.internal/charge"); + + AgentTelemetryStore.getAll(); + AgentTelemetryStore.getAll(ClassLoader.getSystemClassLoader()); + + assertEquals(singletonList("https://first.internal/charge"), urlsSeenBy(app)); + } + } + + @Test + public void getAll_capacityIsPerApplication() throws Exception { + // A busy deployment must not evict a quiet one's events. + try (URLClassLoader firstApp = application(); URLClassLoader secondApp = application()) { + recordAs(secondApp, "https://second.internal/refund"); + for (int i = 0; i < AgentTelemetryStore.MAX_EVENTS + 5; i++) { + recordAs(firstApp, "https://first.internal/" + i); + } + + assertEquals(AgentTelemetryStore.MAX_EVENTS, urlsSeenBy(firstApp).size()); + assertEquals(singletonList("https://second.internal/refund"), urlsSeenBy(secondApp)); + } + } + + /** A stand-in for a deployment's classloader: its own, parented to the system classloader. */ + private static URLClassLoader application() { + return new URLClassLoader(new URL[0], ClassLoader.getSystemClassLoader()); + } + + /** Records an event the way an HTTP call made by {@code origin}'s code would. */ + private static void recordAs(ClassLoader origin, String url) { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(origin); + try { + AgentTelemetryStore.recordNetworkEvent("GET", url, "500"); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + private static List urlsSeenBy(ClassLoader application) { + List urls = new ArrayList<>(); + for (Map event : AgentTelemetryStore.getAll(application)) { + urls.add(event.get("url")); + } + return urls; + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/CallingApplication.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/CallingApplication.java new file mode 100644 index 00000000..59d5d66d --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/CallingApplication.java @@ -0,0 +1,17 @@ +package com.rollbar.agent; + +/** + * Stands in for application code that makes an instrumented HTTP call. + * + *

{@link AgentClassLoaderIsolationTest} loads a copy of this class into a classloader of its + * own, so that the frame this class contributes to the stack belongs to that "application" while + * the thread's context classloader says something else entirely. + */ +public final class CallingApplication { + + private CallingApplication() {} + + public static void makeCall(String url) { + AgentTelemetryStore.recordNetworkEvent("GET", url, "500"); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/NetworkEventBridgeTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/NetworkEventBridgeTest.java new file mode 100644 index 00000000..ad54a0fe --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/NetworkEventBridgeTest.java @@ -0,0 +1,74 @@ +package com.rollbar.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class NetworkEventBridgeTest { + + @Test + public void composeUrl_joinsHostWithRelativePath() { + assertEquals("https://api.example.com/charge", + NetworkEventBridge.composeUrl("https://api.example.com", "/charge")); + } + + @Test + public void composeUrl_joinsHostWithPortAndRelativePath() { + assertEquals("http://localhost:8080/charge", + NetworkEventBridge.composeUrl("http://localhost:8080", "/charge")); + } + + @Test + public void composeUrl_insertsSeparatorForPathWithoutLeadingSlash() { + assertEquals("https://api.example.com/charge", + NetworkEventBridge.composeUrl("https://api.example.com", "charge")); + } + + @Test + public void composeUrl_leavesAbsoluteRequestUriUntouched() { + assertEquals("https://other.example.com/charge", + NetworkEventBridge.composeUrl( + "https://api.example.com", "https://other.example.com/charge")); + } + + @Test + public void composeUrl_keepsBaseWhenNestedUrlAppearsInQuery() { + // '://' inside the query (OAuth redirect, URL shortener, proxy-style API) must not be read as + // a scheme — otherwise the target host is dropped and sanitizing leaves a hostless path. + assertEquals("https://api.example.com/api/redirect?url=https://other.example.com/foo", + NetworkEventBridge.composeUrl( + "https://api.example.com", "/api/redirect?url=https://other.example.com/foo")); + } + + @Test + public void composeUrl_keepsBaseWhenNestedUrlAppearsInPath() { + assertEquals("https://api.example.com/proxy/https://other.example.com/foo", + NetworkEventBridge.composeUrl( + "https://api.example.com", "/proxy/https://other.example.com/foo")); + } + + @Test + public void composeUrl_keepsBaseWhenNestedUrlAppearsInFragment() { + assertEquals("https://api.example.com/page#https://other.example.com", + NetworkEventBridge.composeUrl( + "https://api.example.com", "/page#https://other.example.com")); + } + + @Test + public void composeUrl_withoutBase_returnsRequestUri() { + assertEquals("/charge", NetworkEventBridge.composeUrl(null, "/charge")); + } + + @Test + public void composeUrl_withoutRequestUri_returnsBase() { + assertEquals("https://api.example.com", + NetworkEventBridge.composeUrl("https://api.example.com", null)); + assertEquals("https://api.example.com", + NetworkEventBridge.composeUrl("https://api.example.com", "")); + } + + @Test + public void composeUrl_withNeither_returnsEmptyString() { + assertEquals("", NetworkEventBridge.composeUrl(null, null)); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/PremainProbe.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/PremainProbe.java new file mode 100644 index 00000000..7c338ea8 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/PremainProbe.java @@ -0,0 +1,39 @@ +package com.rollbar.agent; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; + +/** + * Runs in a JVM started with {@code -javaagent:} and no Rollbar SDK on the classpath, + * launched by {@link AgentClassLoaderIsolationTest}. It must not reference any SDK type, junit, or + * anything else outside the JDK and the agent jar itself. + * + *

Prints {@code probe-ok events=} on success; anything else (or a non-zero exit) is the + * failure the test reports. + */ +public final class PremainProbe { + + private PremainProbe() {} + + public static void main(String[] args) throws IOException { + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } // closed here, so the request below is refused + + HttpURLConnection connection = + (HttpURLConnection) new URL("http://127.0.0.1:" + closedPort + "/x").openConnection(); + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + try { + connection.getResponseCode(); + } catch (IOException expected) { + // expected: nothing is listening on the port + } + connection.disconnect(); + + System.out.println("probe-ok events=" + AgentTelemetryStore.getAll().size()); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/RollbarAgentTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/RollbarAgentTest.java new file mode 100644 index 00000000..13dd50fa --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/RollbarAgentTest.java @@ -0,0 +1,71 @@ +package com.rollbar.agent; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + + +public class RollbarAgentTest { + + @Test + public void errorReportingListener_reportsFailureToStderr() { + String output = captureStderr(listener -> + listener.onError("java.net.HttpURLConnection", null, null, false, + new IllegalArgumentException("Unsupported class file major version 69"))); + + assertTrue(output.contains("java.net.HttpURLConnection"), "must name the type that failed"); + assertTrue(output.contains("Unsupported class file major version 69"), + "must surface the underlying cause"); + } + + @Test + public void errorReportingListener_capsOutput() { + // A ByteBuddy/JDK version mismatch fails for every instrumented type; an agent must not + // flood the host process's stderr. + int attempts = RollbarAgent.ErrorReportingListener.MAX_REPORTS + 20; + String output = captureStderr(listener -> { + for (int i = 0; i < attempts; i++) { + listener.onError("com.example.Type" + i, null, null, false, new RuntimeException("boom")); + } + }); + + assertTrue(output.contains("com.example.Type0"), "first failure must be reported"); + assertFalse(output.contains("com.example.Type" + (attempts - 1)), + "reporting must stop once the cap is reached"); + assertTrue(output.contains("further instrumentation errors suppressed"), + "must say that output was truncated"); + } + + private static String captureStderr(Consumer action) { + PrintStream original = System.err; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + try { + System.setErr(new PrintStream(captured, true)); + action.accept(new RollbarAgent.ErrorReportingListener()); + } finally { + System.setErr(original); + } + return captured.toString(); + } + + @Test + public void urlSanitizer_stripsQueryAndFragment() { + String sanitized = UrlSanitizer.sanitize("https://api.example.com/path?token=secret#section"); + assertEquals("https://api.example.com/path", sanitized); + } + + @Test + public void urlSanitizer_handlesNullGracefully() { + assertNull(UrlSanitizer.sanitize(null)); + } + + @Test + public void urlSanitizer_handlesInvalidUrl() { + String raw = "not a url"; + assertEquals(raw, UrlSanitizer.sanitize(raw)); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/UrlSanitizerTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/UrlSanitizerTest.java new file mode 100644 index 00000000..ad35561c --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/UrlSanitizerTest.java @@ -0,0 +1,165 @@ +package com.rollbar.agent; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class UrlSanitizerTest { + + @Test + public void normalUrl_stripsQueryAndFragment() { + assertEquals( + "https://example.com/path", + UrlSanitizer.sanitize("https://example.com/path?token=secret&key=value") + ); + } + + @Test + public void normalUrl_stripsUserinfo() { + assertEquals( + "https://example.com/path", + UrlSanitizer.sanitize("https://user:pass@example.com/path") + ); + } + + @Test + public void normalUrl_stripsFragment() { + assertEquals( + "https://example.com/path", + UrlSanitizer.sanitize("https://example.com/path#section") + ); + } + + @Test + public void urlWithUnescapedSpace_fallback_stripsQuery() { + // URI rejects unescaped spaces; fallback must still strip the query string. + String result = UrlSanitizer.sanitize("http://example.com/path with spaces?token=secret"); + assertFalse(result.contains("token"), "query must be stripped even on parse failure"); + assertFalse(result.contains("secret"), "secret value must be stripped even on parse failure"); + assertTrue(result.contains("example.com"), "host should be preserved"); + } + + @Test + public void urlWithUnescapedSpace_fallback_stripsUserinfo() { + String result = UrlSanitizer.sanitize("http://user:pass@example.com/path with spaces"); + assertFalse(result.contains("pass"), "userinfo must be stripped even on parse failure"); + assertTrue(result.contains("example.com"), "host should be preserved"); + } + + @Test + public void urlWithBadPercentEncoding_fallback_stripsQuery() { + String result = UrlSanitizer.sanitize("http://example.com/path%zz?secret=abc"); + assertFalse(result.contains("secret"), "query must be stripped even on parse failure"); + } + + @Test + public void underscoreHostname_preservesHost() { + // Underscore hostnames (Kubernetes service DNS, Windows/AD internal DNS) parse in URI's + // registry-based mode, where getHost() returns null. The host must not be dropped. + assertEquals( + "http://s3_bucket.example.com/path", + UrlSanitizer.sanitize("http://s3_bucket.example.com/path?token=secret") + ); + } + + @Test + public void underscoreHostname_withUserinfoAndPort_stripsUserinfoKeepsHost() { + assertEquals( + "http://my_svc.internal:8080/v1", + UrlSanitizer.sanitize("http://user:pass@my_svc.internal:8080/v1?x=1") + ); + } + + @Test + public void atSignInPath_fallback_doesNotPromotePathToHost() { + // Unescaped space forces the fallback; the '@' inside the path must not be mistaken for the + // userinfo separator (which would delete the host and promote 'johndoe' to host). + String result = UrlSanitizer.sanitize("http://example.com/@johndoe/messages?bad space"); + assertEquals("http://example.com/@johndoe/messages", result); + } + + @Test + public void encodedAtSignInUserinfo_stripsWholeCredential() { + // getAuthority() decodes %40 to '@', which would make the delimiter search stop inside the + // password and record 'ss@example.com' as the host. The raw authority must be used instead. + assertEquals( + "https://example.com/path", + UrlSanitizer.sanitize("https://user:p%40ss@example.com/path?token=secret") + ); + } + + @Test + public void encodedAtSignInUserinfo_withoutPassword_stripsWholeCredential() { + assertEquals( + "https://example.com/path", + UrlSanitizer.sanitize("https://user%40example.com@example.com/path") + ); + } + + @Test + public void unencodedAtSignInUserinfo_fallback_stripsWholeCredential() { + // The trailing '%' is a malformed escape, so this takes the fallback path. Stripping through + // the *first* '@' would leave "http://ss@example.com/path%" — the tail of the password, in + // the URL that ships to Rollbar. The primary path already strips through the last '@'. + assertEquals( + "http://example.com/path%", + UrlSanitizer.sanitize("http://user:pa@ss@example.com/path%") + ); + } + + @Test + public void unencodedAtSignInUserinfo_fallback_doesNotReachIntoThePath() { + // The last '@' rule stays inside the authority: an '@' in the path must not be treated as the + // userinfo delimiter, or the real host is deleted and a path segment promoted to host. + assertEquals( + "http://example.com/@johndoe/a%", + UrlSanitizer.sanitize("http://user:pa@ss@example.com/@johndoe/a%") + ); + } + + @Test + public void schemeRelativeUrl_fallback_stripsUserinfo() { + // No scheme, so userinfo stripping cannot key off "://" — the authority still starts right + // after the two slashes, and the credentials in it still have to go. + assertEquals( + "//example.com/path%", + UrlSanitizer.sanitize("//user:pass@example.com/path%") + ); + } + + @Test + public void schemeRelativeUrl_fallback_stripsUserinfoBeforeNestedUrlInPath() { + // The first "://" here belongs to the nested URL in the path. Keying off it would leave the + // real authority, credentials included, untouched. + assertEquals( + "//example.com/proxy/https://other.example.com/a%", + UrlSanitizer.sanitize("//user:pass@example.com/proxy/https://other.example.com/a%") + ); + } + + @Test + public void schemeRelativeUrl_fallback_keepsAtSignInPath() { + assertEquals( + "//example.com/@johndoe/a%", + UrlSanitizer.sanitize("//example.com/@johndoe/a%") + ); + } + + @Test + public void encodedPathIsPreserved() { + assertEquals( + "https://example.com/a%20b/c", + UrlSanitizer.sanitize("https://example.com/a%20b/c?x=1") + ); + } + + @Test + public void relativeUrl_stripsQueryKeepsPath() { + assertEquals("/api/redirect", UrlSanitizer.sanitize("/api/redirect?url=https://other.com/foo")); + } + + @Test + public void nullUrl_returnsNull() { + assertNull(UrlSanitizer.sanitize(null)); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient4InstrumentationTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient4InstrumentationTest.java new file mode 100644 index 00000000..70b39b9d --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient4InstrumentationTest.java @@ -0,0 +1,174 @@ +package com.rollbar.agent.instrumentation; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.rollbar.agent.AgentTelemetryStore; +import com.rollbar.agent.NetworkEventBridge; +import org.apache.http.HttpHost; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicHttpRequest; +import org.apache.http.protocol.BasicHttpContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +public class ApacheHttpClient4InstrumentationTest { + + private WireMockServer server; + private CloseableHttpClient client; + + @BeforeEach + public void setUp() { + server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + server.start(); + client = HttpClients.createDefault(); + AgentTelemetryStore.resetForTesting(); + NetworkEventBridge.resetRecordedForTesting(); + } + + @AfterEach + public void tearDown() throws Exception { + client.close(); + server.stop(); + } + + @Test + public void successResponse_doesNotRecordEvent() throws Exception { + server.stubFor(get(urlEqualTo("/ok")).willReturn(aResponse().withStatus(200))); + + client.execute(new HttpGet(server.baseUrl() + "/ok")).close(); + + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void clientErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(get(urlEqualTo("/not-found")).willReturn(aResponse().withStatus(404))); + + client.execute(new HttpGet(server.baseUrl() + "/not-found")).close(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("network", event.get("type")); + assertEquals("404", event.get("status_code")); + assertEquals("GET", event.get("method")); + assertTrue(event.get("url").contains("/not-found")); + } + + @Test + public void serverErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(post(urlEqualTo("/error")).willReturn(aResponse().withStatus(500))); + + client.execute(new HttpPost(server.baseUrl() + "/error")).close(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code")); + assertEquals("POST", event.get("method")); + } + + @Test + public void responseHandlerOverload_recordsNetworkEvent() throws Exception { + // execute(HttpUriRequest, ResponseHandler) routes through execute(HttpHost, HttpRequest, ...), + // bypassing the single-request overloads. Instrumenting doExecute() — which every dispatch + // path converges on — is what makes this path visible. + server.stubFor(get(urlEqualTo("/handler")).willReturn(aResponse().withStatus(404))); + + ResponseHandler handler = response -> response.getStatusLine().getStatusCode(); + int status = client.execute(new HttpGet(server.baseUrl() + "/handler"), handler); + + assertEquals(404, status); + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("404", event.get("status_code")); + assertTrue(event.get("url").endsWith("/handler")); + } + + @Test + public void hostBasedOverload_recordsNetworkEventWithFullUrl() throws Exception { + // execute(HttpHost, HttpRequest) invokes doExecute() directly. The request carries only a path, + // so the host must be rejoined from the HttpHost argument for the URL to be usable. + server.stubFor(get(urlEqualTo("/charge")).willReturn(aResponse().withStatus(500))); + + HttpHost target = new HttpHost("localhost", server.port(), "http"); + client.execute(target, new BasicHttpRequest("GET", "/charge")).close(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code")); + assertEquals(server.baseUrl() + "/charge", event.get("url")); + } + + @Test + public void hostBasedOverloadWithContext_recordsNetworkEvent() throws Exception { + server.stubFor(get(urlEqualTo("/charge")).willReturn(aResponse().withStatus(503))); + + HttpHost target = new HttpHost("localhost", server.port(), "http"); + client.execute(target, new BasicHttpRequest("GET", "/charge"), new BasicHttpContext()).close(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("503", event.get("status_code")); + assertEquals(server.baseUrl() + "/charge", event.get("url")); + } + + @Test + public void urlSanitization_stripsQuery() throws Exception { + server.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(500))); + + client.execute(new HttpGet(server.baseUrl() + "/path?token=secret")).close(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + String url = event.get("url"); + assertTrue(url.contains("/path")); + assertFalse(url.contains("secret")); + } + + /** + * ByteBuddy resolves an advice method's parameter and return types in the classloader that loaded + * the advice class — the agent's. Naming an {@code org.apache.http} type here throws + * NoClassDefFoundError at weave time wherever HC 4.x lives in a classloader the agent cannot see + * (Spring Boot executable jars, per-WAR container classloaders, OSGi), silently disabling this + * instrumentation. A flat test classpath cannot reproduce that, so pin the signature instead. + */ + @Test + public void adviceSignature_namesNoApacheTypes() { + for (Method method + : ApacheHttpClient4Instrumentation.DoExecuteAdvice.class.getDeclaredMethods()) { + if (method.isSynthetic()) { + continue; // e.g. JaCoCo's $jacocoInit(), which ByteBuddy never resolves as advice + } + for (Class parameterType : method.getParameterTypes()) { + assertTrue(isAgentVisible(parameterType), + "advice parameter type must be resolvable from the agent's classloader: " + + parameterType.getName()); + } + assertTrue(isAgentVisible(method.getReturnType()), + "advice return type must be resolvable from the agent's classloader: " + + method.getReturnType().getName()); + } + } + + private static boolean isAgentVisible(Class type) { + String name = type.getName(); + return type.isPrimitive() || name.startsWith("java.") || name.startsWith("com.rollbar."); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient5InstrumentationTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient5InstrumentationTest.java new file mode 100644 index 00000000..05c09ed9 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient5InstrumentationTest.java @@ -0,0 +1,194 @@ +package com.rollbar.agent.instrumentation; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.rollbar.agent.AgentTelemetryStore; +import com.rollbar.agent.NetworkEventBridge; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.http.protocol.BasicHttpContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +public class ApacheHttpClient5InstrumentationTest { + + private WireMockServer server; + private CloseableHttpClient client; + + @BeforeEach + public void setUp() { + server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + server.start(); + client = HttpClients.createDefault(); + AgentTelemetryStore.resetForTesting(); + NetworkEventBridge.resetRecordedForTesting(); + } + + @AfterEach + public void tearDown() throws Exception { + client.close(); + server.stop(); + } + + @Test + public void successResponse_doesNotRecordEvent() throws Exception { + server.stubFor(get(urlEqualTo("/ok")).willReturn(aResponse().withStatus(200))); + + try (CloseableHttpResponse r = client.execute( + new BasicClassicHttpRequest("GET", server.baseUrl() + "/ok"))) { + // consume response + } + + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void clientErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(get(urlEqualTo("/not-found")).willReturn(aResponse().withStatus(404))); + + try (CloseableHttpResponse r = client.execute( + new BasicClassicHttpRequest("GET", server.baseUrl() + "/not-found"))) { + // consume response + } + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("network", event.get("type")); + assertEquals("404", event.get("status_code")); + assertEquals("GET", event.get("method")); + String url = event.get("url"); + assertTrue(url.startsWith("http://"), "URL should include scheme: " + url); + assertTrue(url.contains("localhost"), "URL should include host: " + url); + assertTrue(url.contains("/not-found"), "URL should include path: " + url); + } + + @Test + public void serverErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(post(urlEqualTo("/error")).willReturn(aResponse().withStatus(500))); + + try (CloseableHttpResponse r = client.execute( + new BasicClassicHttpRequest("POST", server.baseUrl() + "/error"))) { + // consume response + } + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code")); + assertEquals("POST", event.get("method")); + } + + @Test + public void responseHandlerOverload_recordsNetworkEvent() throws Exception { + // execute(ClassicHttpRequest, HttpClientResponseHandler) routes through the HttpHost-based + // chain, bypassing the single-request overloads. Instrumenting doExecute() — which every + // dispatch path converges on — is what makes this path visible. + server.stubFor(get(urlEqualTo("/handler")).willReturn(aResponse().withStatus(404))); + + HttpClientResponseHandler handler = response -> response.getCode(); + int status = client.execute( + new BasicClassicHttpRequest("GET", server.baseUrl() + "/handler"), handler); + + assertEquals(404, status); + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("404", event.get("status_code").toString()); + assertTrue(event.get("url").endsWith("/handler")); + } + + @Test + public void hostBasedOverload_recordsNetworkEventWithFullUrl() throws Exception { + // execute(HttpHost, ClassicHttpRequest) invokes doExecute() directly. The request carries only + // a path, so the host must be rejoined from the HttpHost argument for the URL to be usable. + server.stubFor(get(urlEqualTo("/charge")).willReturn(aResponse().withStatus(500))); + + HttpHost target = new HttpHost("http", "localhost", server.port()); + client.execute(target, new BasicClassicHttpRequest("GET", "/charge")).close(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code").toString()); + assertEquals(server.baseUrl() + "/charge", event.get("url")); + } + + @Test + public void hostBasedOverloadWithContext_recordsNetworkEvent() throws Exception { + server.stubFor(get(urlEqualTo("/charge")).willReturn(aResponse().withStatus(503))); + + HttpHost target = new HttpHost("http", "localhost", server.port()); + try (CloseableHttpResponse response = client.execute( + target, new BasicClassicHttpRequest("GET", "/charge"), new BasicHttpContext())) { + assertEquals(503, response.getCode()); + } + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("503", event.get("status_code").toString()); + assertEquals(server.baseUrl() + "/charge", event.get("url")); + } + + @Test + public void urlSanitization_stripsQuery() throws Exception { + server.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(500))); + + try (CloseableHttpResponse r = client.execute( + new BasicClassicHttpRequest("GET", server.baseUrl() + "/path?token=secret"))) { + // consume response + } + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + String url = event.get("url"); + assertTrue(url.startsWith("http://"), "URL should include scheme: " + url); + assertTrue(url.contains("localhost"), "URL should include host: " + url); + assertTrue(url.contains("/path"), "URL should include path: " + url); + assertFalse(url.contains("secret"), "URL should not contain query params: " + url); + } + + /** + * ByteBuddy resolves an advice method's parameter and return types in the classloader that loaded + * the advice class — the agent's. Naming an {@code org.apache.hc} type here throws + * NoClassDefFoundError at weave time wherever HC 5.x lives in a classloader the agent cannot see + * (Spring Boot executable jars, per-WAR container classloaders, OSGi), silently disabling this + * instrumentation. A flat test classpath cannot reproduce that, so pin the signature instead. + */ + @Test + public void adviceSignature_namesNoApacheTypes() { + for (Method method + : ApacheHttpClient5Instrumentation.DoExecuteAdvice.class.getDeclaredMethods()) { + if (method.isSynthetic()) { + continue; // e.g. JaCoCo's $jacocoInit(), which ByteBuddy never resolves as advice + } + for (Class parameterType : method.getParameterTypes()) { + assertTrue(isAgentVisible(parameterType), + "advice parameter type must be resolvable from the agent's classloader: " + + parameterType.getName()); + } + assertTrue(isAgentVisible(method.getReturnType()), + "advice return type must be resolvable from the agent's classloader: " + + method.getReturnType().getName()); + } + } + + private static boolean isAgentVisible(Class type) { + String name = type.getName(); + return type.isPrimitive() || name.startsWith("java.") || name.startsWith("com.rollbar."); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentationTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentationTest.java new file mode 100644 index 00000000..0d2259fb --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentationTest.java @@ -0,0 +1,226 @@ +package com.rollbar.agent.instrumentation; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.rollbar.agent.AgentTelemetryStore; +import com.rollbar.agent.NetworkEventBridge; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +public class HttpUrlConnectionInstrumentationTest { + + private WireMockServer server; + + @BeforeEach + public void setUp() { + server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + server.start(); + AgentTelemetryStore.resetForTesting(); + NetworkEventBridge.resetRecordedForTesting(); + } + + @AfterEach + public void tearDown() { + server.stop(); + } + + @Test + public void successResponse_doesNotRecordEvent() throws IOException { + server.stubFor(get(urlEqualTo("/ok")).willReturn(aResponse().withStatus(200))); + + makeRequest("GET", "/ok"); + + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void clientErrorResponse_recordsNetworkEvent() throws IOException { + server.stubFor(get(urlEqualTo("/not-found")).willReturn(aResponse().withStatus(404))); + + makeRequest("GET", "/not-found"); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("network", event.get("type")); + assertEquals("404", event.get("status_code")); + assertEquals("GET", event.get("method")); + assertTrue(event.get("url").contains("/not-found")); + } + + @Test + public void serverErrorResponse_recordsNetworkEvent() throws IOException { + server.stubFor(get(urlEqualTo("/error")).willReturn(aResponse().withStatus(500))); + + makeRequest("GET", "/error"); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code")); + } + + @Test + public void redirectResponse_doesNotRecordEvent() throws IOException { + server.stubFor(get(urlEqualTo("/redirect")).willReturn( + aResponse().withStatus(301).withHeader("Location", "/other"))); + + HttpURLConnection conn = (HttpURLConnection) new URL(server.baseUrl() + "/redirect").openConnection(); + conn.setInstanceFollowRedirects(false); + conn.getResponseCode(); + conn.disconnect(); + + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void urlSanitization_stripsQueryAndCredentials() throws IOException { + server.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(500))); + + HttpURLConnection conn = (HttpURLConnection) new URL( + server.baseUrl() + "/path?secret=abc&token=xyz" + ).openConnection(); + conn.getResponseCode(); + conn.disconnect(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + String url = event.get("url"); + assertTrue(url.contains("/path")); + assertFalse(url.contains("secret")); + assertFalse(url.contains("token")); + } + + @Test + public void getInputStream_on4xx_recordsNetworkEvent() throws IOException { + server.stubFor(get(urlEqualTo("/not-found")).willReturn(aResponse().withStatus(404))); + + HttpURLConnection conn = (HttpURLConnection) new URL(server.baseUrl() + "/not-found") + .openConnection(); + conn.setRequestMethod("GET"); + try { + conn.getInputStream(); + } catch (IOException ignored) { + // expected for 4xx + } + conn.disconnect(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("404", event.get("status_code")); + assertEquals("GET", event.get("method")); + } + + @Test + public void getInputStream_on2xx_doesNotRecordEvent() throws IOException { + server.stubFor(get(urlEqualTo("/ok")).willReturn(aResponse().withStatus(200))); + + HttpURLConnection conn = (HttpURLConnection) new URL(server.baseUrl() + "/ok") + .openConnection(); + conn.setRequestMethod("GET"); + conn.getInputStream().close(); + conn.disconnect(); + + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void getInputStream_thenGetErrorStream_doesNotDoubleRecord() throws IOException { + server.stubFor(get(urlEqualTo("/not-found")).willReturn(aResponse().withStatus(404))); + + HttpURLConnection conn = (HttpURLConnection) new URL(server.baseUrl() + "/not-found") + .openConnection(); + conn.setRequestMethod("GET"); + try { + conn.getInputStream(); + } catch (IOException ignored) { + // expected for 4xx + } + conn.getErrorStream(); + conn.disconnect(); + + assertEquals(1, AgentTelemetryStore.getAll().size()); + } + + @Test + public void getInputStream_connectionRefused_recordsErrorWithoutRecursion() throws IOException { + // A connection-level failure leaves responseCode == -1, so the JDK's getResponseCode() calls + // getInputStream() again. Before the re-entry guard this recursed until a StackOverflowError + // that the advice swallowed, recording nothing. Now it must record a single error event and + // return promptly. + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } // socket closed here → connections to closedPort are refused + + HttpURLConnection conn = (HttpURLConnection) new URL( + "http://127.0.0.1:" + closedPort + "/x").openConnection(); + conn.setConnectTimeout(1000); + conn.setReadTimeout(1000); + try { + conn.getInputStream(); + fail("expected connection to be refused"); + } catch (IOException expected) { + // expected: nothing is listening on the port + } + conn.disconnect(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size(), "connection failure should record exactly one event"); + Map event = events.get(0); + assertEquals("manual", event.get("type")); + assertTrue(event.get("message").contains("Network error"), + "error event should carry a network-error message"); + } + + @Test + public void getResponseCode_connectionRefused_recordsSingleError() throws IOException { + // The other entry point into the same failure. Deduplicating on the thrown exception recorded + // three events here: responseCode stays -1, so the JDK retries getInputStream() internally and + // builds a fresh exception object per attempt, which an identity-keyed set cannot collapse. + // The connection instance is the one identity that is stable across those retries. + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } // socket closed here → connections to closedPort are refused + + HttpURLConnection conn = (HttpURLConnection) new URL( + "http://127.0.0.1:" + closedPort + "/x").openConnection(); + conn.setConnectTimeout(1000); + conn.setReadTimeout(1000); + try { + conn.getResponseCode(); + fail("expected connection to be refused"); + } catch (IOException expected) { + // expected: nothing is listening on the port + } + conn.disconnect(); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size(), "connection failure should record exactly one event"); + Map event = events.get(0); + assertEquals("manual", event.get("type")); + assertTrue(event.get("message").contains("Network error"), + "error event should carry a network-error message"); + } + + private void makeRequest(String method, String path) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(server.baseUrl() + path).openConnection(); + conn.setRequestMethod(method); + conn.getResponseCode(); + conn.disconnect(); + } +} diff --git a/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentationTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentationTest.java new file mode 100644 index 00000000..a076bd4f --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentationTest.java @@ -0,0 +1,176 @@ +package com.rollbar.agent.instrumentation; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.rollbar.agent.AgentTelemetryStore; +import com.rollbar.agent.NetworkEventBridge; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +public class JavaHttpClientInstrumentationTest { + + private WireMockServer server; + private HttpClient client; + + @BeforeEach + public void setUp() { + server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + server.start(); + client = HttpClient.newHttpClient(); + AgentTelemetryStore.resetForTesting(); + NetworkEventBridge.resetRecordedForTesting(); + } + + @AfterEach + public void tearDown() { + server.stop(); + } + + @Test + public void successResponse_doesNotRecordEvent() throws Exception { + server.stubFor(get(urlEqualTo("/ok")).willReturn(aResponse().withStatus(200))); + + client.send( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/ok")).GET().build(), + HttpResponse.BodyHandlers.discarding() + ); + + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void clientErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(get(urlEqualTo("/not-found")).willReturn(aResponse().withStatus(404))); + + client.send( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/not-found")).GET().build(), + HttpResponse.BodyHandlers.discarding() + ); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("network", event.get("type")); + assertEquals("404", event.get("status_code")); + assertEquals("GET", event.get("method")); + assertTrue(event.get("url").contains("/not-found")); + } + + @Test + public void serverErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(post(urlEqualTo("/error")).willReturn(aResponse().withStatus(500))); + + client.send( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/error")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.discarding() + ); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code")); + assertEquals("POST", event.get("method")); + } + + @Test + public void sendAsync_successResponse_doesNotRecordEvent() throws Exception { + server.stubFor(get(urlEqualTo("/ok-async")).willReturn(aResponse().withStatus(200))); + + client.sendAsync( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/ok-async")).GET().build(), + HttpResponse.BodyHandlers.discarding() + ).get(5, TimeUnit.SECONDS); + + // whenComplete callbacks fire in the HTTP thread; no event expected for 2xx + Thread.sleep(50); + assertTrue(AgentTelemetryStore.getAll().isEmpty()); + } + + @Test + public void sendAsync_clientErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(get(urlEqualTo("/not-found-async")).willReturn(aResponse().withStatus(404))); + + client.sendAsync( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/not-found-async")).GET().build(), + HttpResponse.BodyHandlers.discarding() + ).get(5, TimeUnit.SECONDS); + + List> events = awaitEvents(AgentTelemetryStore::getAll); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("network", event.get("type")); + assertEquals("404", event.get("status_code")); + assertEquals("GET", event.get("method")); + assertTrue(event.get("url").contains("/not-found-async")); + } + + @Test + public void sendAsync_serverErrorResponse_recordsNetworkEvent() throws Exception { + server.stubFor(post(urlEqualTo("/error-async")).willReturn(aResponse().withStatus(500))); + + client.sendAsync( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/error-async")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.discarding() + ).get(5, TimeUnit.SECONDS); + + List> events = awaitEvents(AgentTelemetryStore::getAll); + assertEquals(1, events.size()); + Map event = events.get(0); + assertEquals("500", event.get("status_code")); + assertEquals("POST", event.get("method")); + } + + /** + * Polls until the supplier returns a list with at least {@code minCount} elements or + * {@code timeoutMs} elapses. The {@code whenComplete} callbacks from async advice fire in + * the HTTP-client thread, so they may arrive a few milliseconds after {@code get()} returns. + */ + private static List> awaitEvents( + Supplier>> supplier) + throws InterruptedException { + long deadline = System.currentTimeMillis() + (long) 1000; + List> events; + do { + events = supplier.get(); + if (!events.isEmpty()) { + return events; + } + Thread.sleep(5); + } while (System.currentTimeMillis() < deadline); + return events; + } + + @Test + public void urlSanitization_stripsQuery() throws Exception { + server.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(500))); + + client.send( + HttpRequest.newBuilder(URI.create(server.baseUrl() + "/path?token=secret")).GET().build(), + HttpResponse.BodyHandlers.discarding() + ); + + List> events = AgentTelemetryStore.getAll(); + assertEquals(1, events.size()); + Map event = events.get(0); + String url = event.get("url"); + assertTrue(url.contains("/path")); + assertFalse(url.contains("secret")); + } +} diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/telemetry/AgentTelemetryEventTracker.java b/rollbar-java/src/main/java/com/rollbar/notifier/telemetry/AgentTelemetryEventTracker.java new file mode 100644 index 00000000..1b841f0a --- /dev/null +++ b/rollbar-java/src/main/java/com/rollbar/notifier/telemetry/AgentTelemetryEventTracker.java @@ -0,0 +1,298 @@ +package com.rollbar.notifier.telemetry; + +import com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Source; +import com.rollbar.api.payload.data.TelemetryEvent; +import com.rollbar.api.payload.data.TelemetryType; +import com.rollbar.notifier.provider.Provider; +import com.rollbar.notifier.provider.timestamp.TimestampProvider; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link TelemetryEventTracker} that merges the HTTP events recorded by {@code + * rollbar-java-agent} with the events the application records itself. + * + *

Pass one to the config builder to make agent telemetry show up in reports: + *

+ *   Rollbar.init(withAccessToken("...")
+ *       .telemetryEventTracker(new AgentTelemetryEventTracker())
+ *       .build());
+ * 
+ * + *

Without the agent attached this behaves exactly like {@link RollbarTelemetryEventTracker}: + * the agent's buffer is simply not there, which is logged once and then ignored. + * + *

Why the events arrive as maps. {@code -javaagent:} appends the agent jar to + * the system class path, so the agent's classes load in the system classloader. The SDK + * normally does not: under a Spring Boot fat jar it lives in {@code BOOT-INF/lib}, under a servlet + * container in {@code WEB-INF/lib}, both loaded by a child classloader the system classloader + * cannot see. The agent therefore holds its events as plain {@link String} maps and never names an + * SDK type, and this class — which loads in the application's classloader, where the SDK types + * are — reads them back through the system classloader and converts them here. + */ +public class AgentTelemetryEventTracker implements TelemetryEventTracker { + + private static final Logger LOGGER = + LoggerFactory.getLogger(AgentTelemetryEventTracker.class); + + private static final String AGENT_STORE_CLASS = "com.rollbar.agent.AgentTelemetryStore"; + private static final String AGENT_STORE_GET_ALL_METHOD = "getAll"; + + private static final String KEY_TYPE = "type"; + private static final String KEY_LEVEL = "level"; + private static final String KEY_SOURCE = "source"; + private static final String KEY_TIMESTAMP_MS = "timestamp_ms"; + + private static final Set RESERVED_KEYS = reservedKeys(); + + private final TelemetryEventTracker delegate; + private final AgentEventSource agentEventSource; + private final int maximumTelemetryData; + + /** + * Construct an {@link AgentTelemetryEventTracker} holding up to + * {@link RollbarTelemetryEventTracker#MAXIMUM_CAPACITY_FOR_TELEMETRY_EVENTS} events. + */ + public AgentTelemetryEventTracker() { + this(new TimestampProvider(), + RollbarTelemetryEventTracker.MAXIMUM_CAPACITY_FOR_TELEMETRY_EVENTS); + } + + /** + * Construct an {@link AgentTelemetryEventTracker}. + * + * @param timestampProvider A Provider of timestamps for the events this tracker records + * itself. Events coming from the agent are timestamped by the agent. + * @param maximumTelemetryData Maximum number of events returned by {@link #getAll()}, counting + * the agent's and the application's together. + */ + public AgentTelemetryEventTracker(Provider timestampProvider, int maximumTelemetryData) { + this(new RollbarTelemetryEventTracker(timestampProvider, maximumTelemetryData), + new SystemClassLoaderAgentEventSource(), maximumTelemetryData); + } + + AgentTelemetryEventTracker(TelemetryEventTracker delegate, AgentEventSource agentEventSource, + int maximumTelemetryData) { + this.delegate = delegate; + this.agentEventSource = agentEventSource; + this.maximumTelemetryData = maximumTelemetryData; + } + + /** + * Get the application's and the agent's events, oldest first, capped at the configured maximum. + */ + @Override + public List getAll() { + List agentEvents = readAgentEvents(); + List events = delegate.getAll(); + if (agentEvents.isEmpty()) { + return events; + } + + List merged = new ArrayList<>(events.size() + agentEvents.size()); + // The two buffers are each in order, but interleave in time, so the merged timeline has to be + // sorted. The sort is stable, which keeps same-millisecond events in their recorded order. + for (TelemetryEvent event : events) { + merged.add(new TimestampedEvent(event)); + } + for (TelemetryEvent event : agentEvents) { + merged.add(new TimestampedEvent(event)); + } + merged.sort(TimestampedEvent.BY_TIMESTAMP); + + int from = Math.max(0, merged.size() - Math.max(maximumTelemetryData, 0)); + List result = new ArrayList<>(merged.size() - from); + for (TimestampedEvent timestamped : merged.subList(from, merged.size())) { + result.add(timestamped.event); + } + return result; + } + + @Override + public void recordLogEventFor(Level level, Source source, String message) { + delegate.recordLogEventFor(level, source, message); + } + + @Override + public void recordManualEventFor(Level level, Source source, String message) { + delegate.recordManualEventFor(level, source, message); + } + + @Override + public void recordNavigationEventFor(Level level, Source source, String from, String to) { + delegate.recordNavigationEventFor(level, source, from, to); + } + + @Override + public void recordNetworkEventFor(Level level, Source source, String method, String url, + String statusCode) { + delegate.recordNetworkEventFor(level, source, method, url, statusCode); + } + + private List readAgentEvents() { + List> raw = agentEventSource.getAll(); + if (raw == null || raw.isEmpty()) { + return Collections.emptyList(); + } + List events = new ArrayList<>(raw.size()); + for (Map event : raw) { + TelemetryEvent converted = toTelemetryEvent(event); + if (converted != null) { + events.add(converted); + } + } + return events; + } + + // The maps cross a classloader boundary and are written by a separately versioned artifact, so + // nothing about their contents is guaranteed at compile time: an unreadable event is dropped + // rather than allowed to break the payload it was meant to annotate. + private static TelemetryEvent toTelemetryEvent(Map event) { + if (event == null) { + return null; + } + TelemetryType type = telemetryTypeOf(event.get(KEY_TYPE)); + Level level = Level.lookupByName(event.get(KEY_LEVEL)); + if (type == null || level == null) { + LOGGER.debug("Ignoring agent telemetry event with unknown type or level: {}", event); + return null; + } + + Map body = new HashMap<>(event); + body.keySet().removeAll(RESERVED_KEYS); + + return new TelemetryEvent(type, level, timestampOf(event), sourceOf(event.get(KEY_SOURCE)), + body); + } + + private static TelemetryType telemetryTypeOf(String name) { + for (TelemetryType type : TelemetryType.values()) { + if (type.asJson().equals(name)) { + return type; + } + } + return null; + } + + private static Source sourceOf(String name) { + for (Source source : Source.values()) { + if (source.asJson().equals(name)) { + return source; + } + } + return Source.SERVER; + } + + private static Long timestampOf(Map event) { + String timestamp = event.get(KEY_TIMESTAMP_MS); + try { + return Long.valueOf(timestamp); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static Set reservedKeys() { + Set keys = new HashSet<>(); + keys.add(KEY_TYPE); + keys.add(KEY_LEVEL); + keys.add(KEY_SOURCE); + keys.add(KEY_TIMESTAMP_MS); + return Collections.unmodifiableSet(keys); + } + + /** The agent's event buffer, as seen from the application's classloader. */ + interface AgentEventSource { + List> getAll(); + } + + /** + * Reads the agent's buffer reflectively through the system classloader. + * + *

The lookup goes through {@link ClassLoader#getSystemClassLoader()} rather than this class's + * own loader: {@code -javaagent:} puts the agent there, and a child loader holding the SDK can + * always reach up to it, while the reverse never works. + * + *

The call carries this class's own classloader, which is the application's: one agent serves + * every application in the JVM, and that is what tells the store whose events to hand back. So + * keep the SDK inside the application — {@code WEB-INF/lib}, not the container's shared + * {@code lib} — or every deployment answers to the same classloader and to the same events. + */ + private static final class SystemClassLoaderAgentEventSource implements AgentEventSource { + + private static final ClassLoader APPLICATION = + AgentTelemetryEventTracker.class.getClassLoader(); + + private volatile Method getAll; + private volatile boolean lookupFailed; + + @Override + @SuppressWarnings("unchecked") + public List> getAll() { + Method method = getAll; + if (method == null && !lookupFailed) { + method = resolve(AGENT_STORE_GET_ALL_METHOD); + getAll = method; + } + if (method == null) { + return Collections.emptyList(); + } + try { + return (List>) method.invoke(null, APPLICATION); + } catch (Exception e) { + LOGGER.warn("Could not read telemetry events from the Rollbar Java agent", e); + return Collections.emptyList(); + } + } + + private Method resolve(String name) { + if (lookupFailed) { + return null; + } + try { + Class store = ClassLoader.getSystemClassLoader().loadClass(AGENT_STORE_CLASS); + return store.getMethod(name, ClassLoader.class); + } catch (ClassNotFoundException e) { + LOGGER.info("The Rollbar Java agent is not attached to this JVM; only telemetry events " + + "recorded by the application will be reported. Add -javaagent: to capture HTTP errors automatically."); + } catch (Exception e) { + LOGGER.warn("The Rollbar Java agent is attached but its telemetry store could not be " + + "read; check that the agent and rollbar-java versions match", e); + } + lookupFailed = true; + return null; + } + } + + private static final class TimestampedEvent { + + static final Comparator BY_TIMESTAMP = new Comparator() { + @Override + public int compare(TimestampedEvent left, TimestampedEvent right) { + return Long.compare(left.timestamp, right.timestamp); + } + }; + + final TelemetryEvent event; + final long timestamp; + + TimestampedEvent(TelemetryEvent event) { + this.event = event; + Object timestamp = event.asJson().get(KEY_TIMESTAMP_MS); + this.timestamp = timestamp instanceof Number ? ((Number) timestamp).longValue() : 0L; + } + } +} diff --git a/rollbar-java/src/test/java/com/rollbar/notifier/telemetry/AgentTelemetryEventTrackerTest.java b/rollbar-java/src/test/java/com/rollbar/notifier/telemetry/AgentTelemetryEventTrackerTest.java new file mode 100644 index 00000000..0b4dd5e6 --- /dev/null +++ b/rollbar-java/src/test/java/com/rollbar/notifier/telemetry/AgentTelemetryEventTrackerTest.java @@ -0,0 +1,132 @@ +package com.rollbar.notifier.telemetry; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Source; +import com.rollbar.api.payload.data.TelemetryEvent; +import com.rollbar.api.payload.data.TelemetryType; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AgentTelemetryEventTrackerTest { + + private static final int MAXIMUM_TELEMETRY_DATA = 4; + + private final FakeAgentEventSource agentEvents = new FakeAgentEventSource(); + private final RollbarTelemetryEventTracker delegate = + new RollbarTelemetryEventTracker(new FixedTimestampProvider(), MAXIMUM_TELEMETRY_DATA); + private final AgentTelemetryEventTracker sut = + new AgentTelemetryEventTracker(delegate, agentEvents, MAXIMUM_TELEMETRY_DATA); + + @Test + public void shouldConvertAgentEventsIntoTelemetryEvents() { + agentEvents.add(networkEvent(10L, "404")); + + List events = sut.getAll(); + + Map body = new HashMap<>(); + body.put("method", "GET"); + body.put("url", "https://api.example.com/charge"); + body.put("status_code", "404"); + assertThat(events, is(Collections.singletonList(new TelemetryEvent( + TelemetryType.NETWORK, Level.CRITICAL, 10L, Source.SERVER, body)))); + } + + @Test + public void shouldMergeAgentAndApplicationEventsInTimestampOrder() { + agentEvents.add(networkEvent(10L, "500")); + agentEvents.add(networkEvent(30L, "503")); + // The delegate timestamps its own events; the fake clock puts this one between the two above. + sut.recordManualEventFor(Level.INFO, Source.SERVER, "in between"); + + List events = sut.getAll(); + + assertThat(events.size(), is(3)); + assertThat(timestampOf(events.get(0)), is(10L)); + assertThat(timestampOf(events.get(1)), is(FixedTimestampProvider.TIMESTAMP)); + assertThat(timestampOf(events.get(2)), is(30L)); + } + + @Test + public void shouldKeepTheMostRecentEventsWhenOverCapacity() { + for (int i = 0; i < MAXIMUM_TELEMETRY_DATA + 3; i++) { + agentEvents.add(networkEvent(i, String.valueOf(500 + i))); + } + + List events = sut.getAll(); + + assertThat(events.size(), is(MAXIMUM_TELEMETRY_DATA)); + assertThat(timestampOf(events.get(0)), is(3L)); + } + + @Test + public void shouldIgnoreUnreadableAgentEvents() { + // The maps come from a separately versioned artifact across a classloader boundary, so a + // malformed one must not take the whole telemetry timeline with it. + Map unknownType = networkEvent(10L, "500"); + unknownType.put("type", "something-new"); + agentEvents.add(unknownType); + agentEvents.add(networkEvent(20L, "502")); + + List events = sut.getAll(); + + assertThat(events.size(), is(1)); + assertThat(timestampOf(events.get(0)), is(20L)); + } + + @Test + public void shouldBehaveAsTheDefaultTrackerWhenTheAgentIsNotAttached() { + sut.recordLogEventFor(Level.DEBUG, Source.SERVER, "a message"); + + assertThat(sut.getAll(), is(delegate.getAll())); + } + + private static long timestampOf(TelemetryEvent event) { + return (Long) event.asJson().get("timestamp_ms"); + } + + private static Map networkEvent(long timestamp, String statusCode) { + Map event = new HashMap<>(); + event.put("type", "network"); + event.put("level", "critical"); + event.put("source", "server"); + event.put("timestamp_ms", String.valueOf(timestamp)); + event.put("method", "GET"); + event.put("url", "https://api.example.com/charge"); + event.put("status_code", statusCode); + return event; + } + + private static class FakeAgentEventSource implements AgentTelemetryEventTracker.AgentEventSource { + + private final List> events = new ArrayList<>(); + + void add(Map event) { + events.add(event); + } + + @Override + public List> getAll() { + return new ArrayList<>(events); + } + } + + private static class FixedTimestampProvider + implements com.rollbar.notifier.provider.Provider { + + static final long TIMESTAMP = 20L; + + @Override + public Long provide() { + return TIMESTAMP; + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 73e10c5b..81d9d633 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -67,3 +67,18 @@ if (isJava8 || isJava11) { println("Java ${JavaVersion.current()} detected: including Android modules") include(":rollbar-android", ":examples:rollbar-android") } + +// The agent's own artifact targets Java 11+ (JavaCompile release = 11), but *building* it needs a +// Java 17+ JVM: it is packaged by com.gradleup.shadow 9.x, whose plugin marker declares a JVM 17 +// runtime requirement, so on an older JVM the build fails while resolving the plugin classpath — +// before any task runs. Shadow 9.x is not optional here (Byte Buddy 1.18 ships Java 24 class files +// under META-INF/versions/24, which earlier shadow releases cannot read), and a Java toolchain does +// not help because the plugin is resolved against the Gradle daemon's JVM, not the toolchain. +// +// So exclude the module on Java 8 and 11. The CI matrix still builds and tests it on 17, and the +// release job runs on 17. +if (JavaVersion.current() < JavaVersion.VERSION_17) { + println("Java ${JavaVersion.current()} detected: excluding :rollbar-java-agent (building it requires Java 17+)") +} else { + include(":rollbar-java-agent") +}