From 29503b1c4686db955ee4768c141bd8693dc40fa8 Mon Sep 17 00:00:00 2001 From: Chris M Date: Mon, 18 May 2026 17:17:10 -0700 Subject: [PATCH] fix: suppress OkHttp default User-Agent header on outbound requests Adds a UserAgentInterceptor (network interceptor) to ApiClient that removes the okhttp/ User-Agent header injected by OkHttp's BridgeInterceptor. This prevents mParticle from enriching device_info.http_header_user_agent with a meaningless SDK string. Caller-supplied User-Agent values are preserved. Wired in both createDefaultAdapter() and configureFromOkclient(). Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 ++ README.md | 19 ++++ build.gradle | 1 + src/main/java/com/mparticle/ApiClient.java | 21 ++++ .../client/ApiClientUserAgentTest.java | 97 +++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 src/test/java/com/mparticle/client/ApiClientUserAgentTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f051d..6b15782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.8.0] - Unreleased + +### Fixed + +- `ApiClient` no longer leaks OkHttp's default `User-Agent` header (`okhttp/`) on outbound requests. Requests now omit the `User-Agent` header entirely unless explicitly set by the caller. This resolves cases where mParticle was enriching `device_info.http_header_user_agent` with a meaningless library-identifier string. Caller-supplied values (set via a custom OkHttp interceptor) are preserved. See PR notes for version bump guidance. + ## [2.7.0] - 2026-03-17 ### Added @@ -69,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't remove attributes when value is set to null +[2.8.0]: https://github.com/mParticle/mparticle-java-events-sdk/compare/v2.7.0...v2.8.0 [2.7.0]: https://github.com/mParticle/mparticle-java-events-sdk/compare/v2.6.0...v2.7.0 [2.6.0]: https://github.com/mParticle/mparticle-java-events-sdk/compare/v2.5.4...v2.6.0 [2.5.4]: https://github.com/mParticle/mparticle-java-events-sdk/compare/v2.5.3...v2.5.4 diff --git a/README.md b/README.md index f2e2489..32d6a9a 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,25 @@ Call singleResult = api.uploadEvents(batch); Response singleResponse = singleResult.execute(); ``` +### User-Agent Header + +The SDK does not set a `User-Agent` header on outbound requests. OkHttp's default `User-Agent` (`okhttp/`) is explicitly suppressed to prevent mParticle from enriching `device_info.http_header_user_agent` with a meaningless library-identifier string. + +If you need to send a custom `User-Agent`, add an OkHttp interceptor that sets the header: + +```java +EventsApi api = new ApiClient("API KEY", "API-SECRET") {{ + getOkBuilder().addInterceptor(chain -> { + okhttp3.Request request = chain.request().newBuilder() + .header("User-Agent", "my-app/1.0") + .build(); + return chain.proceed(request); + }); +}}.createService(EventsApi.class); +``` + +The SDK's suppression only targets OkHttp's own default (`okhttp/`); any other value is passed through unchanged. + ### Logging By default, logging is ignored. Please implement your own LogHandler to handle log statements. diff --git a/build.gradle b/build.gradle index 0fc2239..4e715bc 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ dependencies { api "io.gsonfire:gson-fire:$json_fire_version" api "org.threeten:threetenbp:$threetenbp_version" testImplementation "junit:junit:$junit_version" + testImplementation "com.squareup.okhttp3:mockwebserver:3.14.9" testImplementation "ch.qos.logback:logback-core:$slf4j_core_version" testImplementation "ch.qos.logback:logback-classic:$slf4j_classic_version" testImplementation "org.slf4j:slf4j-api:$slf4j_version" diff --git a/src/main/java/com/mparticle/ApiClient.java b/src/main/java/com/mparticle/ApiClient.java index a9f8a42..36478c8 100644 --- a/src/main/java/com/mparticle/ApiClient.java +++ b/src/main/java/com/mparticle/ApiClient.java @@ -14,6 +14,7 @@ import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Protocol; +import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; @@ -47,6 +48,7 @@ public ApiClient(String apiKey, String apiSecret) { public void createDefaultAdapter() { json = new JSON(); okBuilder = new OkHttpClient.Builder(); + okBuilder.addNetworkInterceptor(new UserAgentInterceptor()); String baseUrl = "https://s2s.mparticle.com/v2"; if (!baseUrl.endsWith("/")) @@ -137,6 +139,25 @@ public void addAuthsToOkBuilder(OkHttpClient.Builder okBuilder) { public void configureFromOkclient(OkHttpClient okClient) { this.okBuilder = okClient.newBuilder(); addAuthsToOkBuilder(this.okBuilder); + this.okBuilder.addNetworkInterceptor(new UserAgentInterceptor()); + } + + // Applied as a network interceptor so it runs after OkHttp's BridgeInterceptor, + // which would otherwise inject "okhttp/" whenever User-Agent is absent. + // Caller-supplied values (not starting with "okhttp/") are passed through unchanged. + static class UserAgentInterceptor implements Interceptor { + @Override + public Response intercept(Chain chain) throws IOException { + Request original = chain.request(); + String ua = original.header("User-Agent"); + if (ua != null && ua.startsWith("okhttp/")) { + Request stripped = original.newBuilder() + .removeHeader("User-Agent") + .build(); + return chain.proceed(stripped); + } + return chain.proceed(original); + } } static class RateLimitInterceptor implements Interceptor { diff --git a/src/test/java/com/mparticle/client/ApiClientUserAgentTest.java b/src/test/java/com/mparticle/client/ApiClientUserAgentTest.java new file mode 100644 index 0000000..5b29fb6 --- /dev/null +++ b/src/test/java/com/mparticle/client/ApiClientUserAgentTest.java @@ -0,0 +1,97 @@ +package com.mparticle.client; + +import com.mparticle.ApiClient; +import com.mparticle.model.Batch; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import retrofit2.Call; + +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class ApiClientUserAgentTest { + + private MockWebServer server; + + @Before + public void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + } + + private EventsApi buildApi(ApiClient client) { + client.getAdapterBuilder().baseUrl(server.url("/")); + return client.createService(EventsApi.class); + } + + // Case 1: Default path — OkHttp's User-Agent must not appear on the wire. + @Test + public void defaultPath_noUserAgentHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202)); + + EventsApi api = buildApi(new ApiClient("key", "secret")); + Call call = api.uploadEvents(new Batch().environment(Batch.Environment.DEVELOPMENT)); + call.execute(); + + RecordedRequest recorded = server.takeRequest(); + assertNull("Expected no User-Agent header", recorded.getHeader("User-Agent")); + } + + // Case 2: Caller sets User-Agent via an interceptor — value must be preserved. + @Test + public void callerOverride_userAgentPreserved() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202)); + + ApiClient client = new ApiClient("key", "secret"); + // Add after construction: runs after UserAgentInterceptor in the chain, + // so its User-Agent value reaches the network. + client.getOkBuilder().addInterceptor(new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request modified = chain.request().newBuilder() + .header("User-Agent", "my-app/1.0") + .build(); + return chain.proceed(modified); + } + }); + + EventsApi api = buildApi(client); + Call call = api.uploadEvents(new Batch().environment(Batch.Environment.DEVELOPMENT)); + call.execute(); + + RecordedRequest recorded = server.takeRequest(); + assertEquals("my-app/1.0", recorded.getHeader("User-Agent")); + } + + // Case 3: configureFromOkclient path — no User-Agent should appear on the wire. + @Test + public void configureFromOkclient_noUserAgentHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202)); + + ApiClient client = new ApiClient("key", "secret"); + OkHttpClient customClient = new OkHttpClient.Builder().build(); + client.configureFromOkclient(customClient); + + EventsApi api = buildApi(client); + Call call = api.uploadEvents(new Batch().environment(Batch.Environment.DEVELOPMENT)); + call.execute(); + + RecordedRequest recorded = server.takeRequest(); + assertNull("Expected no User-Agent header via configureFromOkclient", recorded.getHeader("User-Agent")); + } +}