diff --git a/rollbar-java-agent/README.md b/rollbar-java-agent/README.md new file mode 100644 index 00000000..9948d587 --- /dev/null +++ b/rollbar-java-agent/README.md @@ -0,0 +1,202 @@ +# Rollbar Java Agent + +A zero-code-change Java instrumentation agent that automatically captures HTTP network errors (4xx and 5xx responses) as Rollbar telemetry events. + +It works by attaching to the JVM at startup via `-javaagent:` and using ByteBuddy to intercept HTTP calls across all major clients — no library dependencies or code changes are needed in your application. + +## 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 +- `rollbar-java` on the application classpath (for `Rollbar.init(...)`) + +## Installation + +### 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. So the JAR you pass to `-javaagent:` and the JAR you put on the classpath (steps 2 and 3) are always the same file. + +### 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. Also add the JAR to your classpath + +The agent JAR must also be available on the regular application classpath so that your code can call `RollbarAgent.getTelemetryTracker()`: + +**Gradle:** +```kotlin +dependencies { + implementation(files("/path/to/rollbar-java-agent-.jar")) +} +``` + +**Maven:** +```xml + + com.rollbar + rollbar-java-agent + ${rollbar.version} + +``` + +### 4. Wire into your Rollbar configuration + +```java +import com.rollbar.agent.RollbarAgent; +import com.rollbar.notifier.Rollbar; + +import static com.rollbar.notifier.config.ConfigBuilder.withAccessToken; + +Rollbar rollbar = Rollbar.init( + withAccessToken("your-access-token") + .environment("production") + .telemetryEventTracker(RollbarAgent.getTelemetryTracker()) + .build() +); +``` + +That's it. All HTTP calls your application makes from that point on will automatically produce telemetry events in the Rollbar error report for any 4xx or 5xx response. + +## 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 | +| No Rollbar config wired | Events accumulate in the agent store (capacity 100); nothing is sent | + +The agent never throws into your application: every advice body swallows all errors, so a failure inside the instrumentation cannot break an HTTP call. + +## 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 + +Two methods exist for tests only. Do not call them in production code — use `RollbarAgent.getTelemetryTracker()` as shown above. + +- `AgentTelemetryStore.initForTesting(Provider timestampProvider)` — replaces the internal tracker with one backed by the given timestamp provider, so tests can assert on event timestamps. +- `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.agent.RollbarAgent; + import com.rollbar.notifier.Rollbar; + + 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(RollbarAgent.getTelemetryTracker()) + .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 "rollbar-java-agent/build/libs/rollbar-java-agent-.jar: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..bee58efe --- /dev/null +++ b/rollbar-java-agent/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + `java-library` + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +dependencies { + implementation("net.bytebuddy:byte-buddy:1.14.18") + implementation("net.bytebuddy:byte-buddy-agent:1.14.18") + api(project(":rollbar-api")) + implementation(project(":rollbar-java")) + 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") + 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("") + 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) + 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..3406cee2 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/AgentTelemetryStore.java @@ -0,0 +1,28 @@ +package com.rollbar.agent; + +import com.rollbar.api.payload.data.TelemetryEvent; +import com.rollbar.notifier.provider.Provider; +import com.rollbar.notifier.telemetry.RollbarTelemetryEventTracker; +import com.rollbar.notifier.telemetry.TelemetryEventTracker; + +import java.util.List; + +public final class AgentTelemetryStore { + + private static volatile TelemetryEventTracker INSTANCE = + new RollbarTelemetryEventTracker(System::currentTimeMillis, 100); + + private AgentTelemetryStore() {} + + public static TelemetryEventTracker getInstance() { + return INSTANCE; + } + + public static List getAll() { + return INSTANCE.getAll(); + } + + public static void initForTesting(Provider timestampProvider) { + INSTANCE = new RollbarTelemetryEventTracker(timestampProvider, 100); + } +} 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..2023f587 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/NetworkEventBridge.java @@ -0,0 +1,169 @@ +package com.rollbar.agent; + +import com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Source; + +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 application-classloader + * classes. Advice code uses {@code Thread.currentThread().getContextClassLoader().loadClass(...)} + * to reach this class and delegates all Rollbar-specific logic here. + */ +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.getInstance().recordNetworkEventFor( + Level.CRITICAL, + Source.SERVER, + 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 app classloader) so it can reference Rollbar types + * directly, avoiding the reflection overhead that advice code needs to cross the classloader gap. + */ + public static java.util.function.BiConsumer createAsyncCallback( + Object request) { + return (response, thrown) -> { + try { + if (thrown != null) { + if (markAsRecorded(thrown)) { + String message = thrown.getMessage() != null + ? thrown.getMessage() : thrown.getClass().getName(); + recordError(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) { + Object uri = httpRequestIface.getMethod("uri").invoke(request); + String method = (String) httpRequestIface.getMethod("method").invoke(request); + recordNetworkEvent(response, method, 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 (requestUri.contains("://")) { + return requestUri; + } + if (baseUri == null) { + return requestUri; + } + if (requestUri.startsWith("/")) { + return baseUri.concat(requestUri); + } + return baseUri.concat("/").concat(requestUri); + } + + /** + * 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.getInstance().recordManualEventFor( + Level.CRITICAL, + Source.SERVER, + "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..19b17855 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/RollbarAgent.java @@ -0,0 +1,91 @@ +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 com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Source; +import com.rollbar.api.payload.data.TelemetryEvent; +import com.rollbar.notifier.telemetry.TelemetryEventTracker; +import java.lang.instrument.Instrumentation; +import java.util.List; +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Java agent entry point. Attach with {@code -javaagent:/path/to/rollbar-java-agent.jar}. + * + *

Wire into your Rollbar configuration with: + *

+ *   Rollbar.init(withAccessToken("...")
+ *       .telemetryEventTracker(RollbarAgent.getTelemetryTracker())
+ *       .build());
+ * 
+ */ +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 which ignores all java.* and javax.* classes, + // so we can instrument JDK HTTP clients (HttpURLConnection, HttpClient). + // We still ignore ByteBuddy's own classes to avoid instrumentation loops. + AgentBuilder builder = new AgentBuilder.Default() + .ignore(ElementMatchers.nameStartsWith("net.bytebuddy.") + .or(ElementMatchers.nameStartsWith("com.rollbar.agent.shaded."))) + .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); + } + + public static TelemetryEventTracker getTelemetryTracker() { + return DelegatingTracker.INSTANCE; + } + + private static final class DelegatingTracker implements TelemetryEventTracker { + + static final DelegatingTracker INSTANCE = new DelegatingTracker(); + + private DelegatingTracker() {} + + @Override + public List getAll() { + return AgentTelemetryStore.getInstance().getAll(); + } + + @Override + public void recordLogEventFor(Level level, Source source, String message) { + AgentTelemetryStore.getInstance().recordLogEventFor(level, source, message); + } + + @Override + public void recordManualEventFor(Level level, Source source, String message) { + AgentTelemetryStore.getInstance().recordManualEventFor(level, source, message); + } + + @Override + public void recordNavigationEventFor(Level level, Source source, String from, String to) { + AgentTelemetryStore.getInstance().recordNavigationEventFor(level, source, from, to); + } + + @Override + public void recordNetworkEventFor( + Level level, Source source, String method, String url, String statusCode) { + AgentTelemetryStore.getInstance().recordNetworkEventFor( + level, source, method, url, statusCode); + } + } +} 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..d5a793d5 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/UrlSanitizer.java @@ -0,0 +1,74 @@ +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 getAuthority() 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. + String authority = uri.getAuthority(); + if (authority != null) { + int at = authority.indexOf('@'); + if (at >= 0) { + authority = authority.substring(at + 1); + } + } + return new URI(uri.getScheme(), authority, uri.getPath(), null, null).toString(); + } 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 schemeEnd = result.indexOf("://"); + if (schemeEnd >= 0) { + int authorityStart = schemeEnd + 3; + int authorityEnd = result.length(); + for (int i = authorityStart; i < result.length(); i++) { + char c = result.charAt(i); + if (c == '/' || c == '?' || c == '#') { + authorityEnd = i; + break; + } + } + int atSign = result.indexOf('@', authorityStart); + if (atSign >= 0 && atSign < authorityEnd) { + result = result.substring(0, authorityStart) + result.substring(atSign + 1); + } + } + return result; + } +} 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..6a6207b1 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient4Instrumentation.java @@ -0,0 +1,123 @@ +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; +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; + +/** + * 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); + } + + /** + * Apache HC 4.x runs in the application classloader, so we can reference Rollbar classes + * directly without the TCCL reflection bridge. + * + *

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) HttpHost target, + @Advice.Argument(1) HttpRequest request, + @Advice.Return HttpResponse 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) { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode >= 400) { + // 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 ? target.toURI() : null; + String requestUri = request.getRequestLine() != null + ? request.getRequestLine().getUri() : null; + NetworkEventBridge.recordNetworkEvent( + response, + request.getRequestLine().getMethod(), + NetworkEventBridge.composeUrl(base, requestUri), + String.valueOf(statusCode) + ); + } + } + } 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..8f21ceac --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/ApacheHttpClient5Instrumentation.java @@ -0,0 +1,127 @@ +package com.rollbar.agent.instrumentation; + +import com.rollbar.agent.NetworkEventBridge; +import java.lang.instrument.Instrumentation; +import java.net.URI; +import java.net.URISyntaxException; + +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.matcher.ElementMatchers; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpHost; + +/** + * 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); + } + + /** + * Apache HC 5.x runs in the application classloader, so we can reference Rollbar classes + * directly without the TCCL reflection bridge. + */ + 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) HttpHost target, + @Advice.Argument(1) ClassicHttpRequest request, + @Advice.Return ClassicHttpResponse 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) { + int statusCode = response.getCode(); + if (statusCode >= 400) { + String requestUri; + try { + URI uri = request.getUri(); + requestUri = uri != null ? uri.toString() : null; + } catch (URISyntaxException ignored) { + requestUri = request.getRequestUri(); + } + // 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 ? target.toURI() : null; + NetworkEventBridge.recordNetworkEvent( + response, + request.getMethod(), + NetworkEventBridge.composeUrl(base, requestUri), + String.valueOf(statusCode) + ); + } + } + } 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..41316bc6 --- /dev/null +++ b/rollbar-java-agent/src/main/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentation.java @@ -0,0 +1,185 @@ +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 since {@code getResponseCode()} is + * called re-entrantly up to 3 times per request internally. + */ + @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, thrown); + 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/NetworkEventBridgeTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/NetworkEventBridgeTest.java new file mode 100644 index 00000000..5f4c9212 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/NetworkEventBridgeTest.java @@ -0,0 +1,51 @@ +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_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/RollbarAgentTest.java b/rollbar-java-agent/src/test/java/com/rollbar/agent/RollbarAgentTest.java new file mode 100644 index 00000000..b1a9366a --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/RollbarAgentTest.java @@ -0,0 +1,59 @@ +package com.rollbar.agent; + +import com.rollbar.api.payload.data.TelemetryEvent; +import com.rollbar.notifier.telemetry.TelemetryEventTracker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + + +public class RollbarAgentTest { + + @BeforeEach + public void setUp() { + AgentTelemetryStore.initForTesting(System::currentTimeMillis); + } + + @Test + public void getTelemetryTracker_returnsSingletonInstance() { + TelemetryEventTracker first = RollbarAgent.getTelemetryTracker(); + TelemetryEventTracker second = RollbarAgent.getTelemetryTracker(); + assertSame(first, second); + } + + @Test + public void init_withCustomTimestamp_usesProvidedTimestamp() { + long fixedTime = 1_000_000L; + AgentTelemetryStore.initForTesting(() -> fixedTime); + + AgentTelemetryStore.getInstance().recordManualEventFor( + com.rollbar.api.payload.data.Level.WARNING, + com.rollbar.api.payload.data.Source.CLIENT, + "test" + ); + + List events = AgentTelemetryStore.getInstance().getAll(); + assertEquals(1, events.size()); + assertEquals(fixedTime, events.get(0).asJson().get("timestamp_ms")); + } + + @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..2d656312 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/UrlSanitizerTest.java @@ -0,0 +1,85 @@ +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 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..5d2784c8 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient4InstrumentationTest.java @@ -0,0 +1,145 @@ +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 com.rollbar.api.payload.data.TelemetryEvent; +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.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.initForTesting(System::currentTimeMillis); + 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.getInstance().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map json = events.get(0).asJson(); + assertEquals("network", json.get("type")); + Map body = (Map) json.get("body"); + assertEquals("404", body.get("status_code")); + assertEquals("GET", body.get("method")); + assertTrue(body.get("url").toString().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.get("status_code")); + assertEquals("POST", body.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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("404", body.get("status_code").toString()); + assertTrue(body.get("url").toString().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.get("status_code").toString()); + assertEquals(server.baseUrl() + "/charge", body.get("url").toString()); + } + + @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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("503", body.get("status_code").toString()); + assertEquals(server.baseUrl() + "/charge", body.get("url").toString()); + } + + @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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + String url = body.get("url").toString(); + assertTrue(url.contains("/path")); + assertFalse(url.contains("secret")); + } +} 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..c49d6401 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/ApacheHttpClient5InstrumentationTest.java @@ -0,0 +1,165 @@ +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 com.rollbar.api.payload.data.TelemetryEvent; +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.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.initForTesting(System::currentTimeMillis); + 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.getInstance().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map json = events.get(0).asJson(); + assertEquals("network", json.get("type")); + Map body = (Map) json.get("body"); + assertEquals("404", body.get("status_code")); + assertEquals("GET", body.get("method")); + String url = body.get("url").toString(); + 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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.get("status_code")); + assertEquals("POST", body.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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("404", body.get("status_code").toString()); + assertTrue(body.get("url").toString().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.get("status_code").toString()); + assertEquals(server.baseUrl() + "/charge", body.get("url").toString()); + } + + @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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("503", body.get("status_code").toString()); + assertEquals(server.baseUrl() + "/charge", body.get("url").toString()); + } + + @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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + String url = body.get("url").toString(); + 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); + } +} 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..7254b872 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/HttpUrlConnectionInstrumentationTest.java @@ -0,0 +1,198 @@ +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 com.rollbar.api.payload.data.TelemetryEvent; +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.initForTesting(System::currentTimeMillis); + 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.getInstance().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map json = events.get(0).asJson(); + assertEquals("network", json.get("type")); + Map body = (Map) json.get("body"); + assertEquals("404", body.get("status_code")); + assertEquals("GET", body.get("method")); + assertTrue(body.get("url").toString().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.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.getInstance().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + String url = body.get("url").toString(); + 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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("404", body.get("status_code")); + assertEquals("GET", body.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.getInstance().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.getInstance().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.getInstance().getAll(); + assertEquals(1, events.size(), "connection failure should record exactly one event"); + Map json = events.get(0).asJson(); + assertEquals("manual", json.get("type")); + Map body = (Map) json.get("body"); + assertTrue(body.get("message").toString().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..de0874d1 --- /dev/null +++ b/rollbar-java-agent/src/test/java/com/rollbar/agent/instrumentation/JavaHttpClientInstrumentationTest.java @@ -0,0 +1,179 @@ +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 com.rollbar.api.payload.data.TelemetryEvent; +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.initForTesting(System::currentTimeMillis); + 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.getInstance().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map json = events.get(0).asJson(); + assertEquals("network", json.get("type")); + Map body = (Map) json.get("body"); + assertEquals("404", body.get("status_code")); + assertEquals("GET", body.get("method")); + assertTrue(body.get("url").toString().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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.get("status_code")); + assertEquals("POST", body.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.getInstance().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.getInstance().getAll(), 1, 1000); + assertEquals(1, events.size()); + Map json = events.get(0).asJson(); + assertEquals("network", json.get("type")); + Map body = (Map) json.get("body"); + assertEquals("404", body.get("status_code")); + assertEquals("GET", body.get("method")); + assertTrue(body.get("url").toString().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.getInstance().getAll(), 1, 1000); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + assertEquals("500", body.get("status_code")); + assertEquals("POST", body.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, int minCount, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + List events; + do { + events = supplier.get(); + if (events.size() >= minCount) { + 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.getInstance().getAll(); + assertEquals(1, events.size()); + Map body = (Map) events.get(0).asJson().get("body"); + String url = body.get("url").toString(); + assertTrue(url.contains("/path")); + assertFalse(url.contains("secret")); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 73e10c5b..93e158bf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -67,3 +67,9 @@ if (isJava8 || isJava11) { println("Java ${JavaVersion.current()} detected: including Android modules") include(":rollbar-android", ":examples:rollbar-android") } + +if (isJava8) { + println("Java 8 detected: excluding :rollbar-java-agent (requires Java 11+)") +} else { + include(":rollbar-java-agent") +}