From ace209625c28d4be347ed81d931d13ed8ecc00ce Mon Sep 17 00:00:00 2001 From: Christian Bush Date: Sun, 21 Jun 2026 20:35:04 -0700 Subject: [PATCH] Send client version via User-Agent header The OpenHouse client sets User-Agent: openhouse-java-client/ on every outbound request, set once in WebClientFactory.getWebClient() (tables/jobs/housetables). Version resolves: explicit setClientVersion() override, then the secureclient jar manifest Implementation-Version, then "unknown"; build.gradle stamps the manifest. OpenHouseCatalog reads a "client-version" catalog property and calls setClientVersion() on the ApiClient factory -- mirroring how it already handles "client-name" -- so callers set the version via a property rather than touching the factory directly (which is compileOnly/relocated downstream). --- client/secureclient/build.gradle | 8 ++++ .../client/ssl/WebClientFactory.java | 43 +++++++++++++++++++ .../ssl/TablesApiClientFactoryTest.java | 12 ++++++ .../javaclient/OpenHouseCatalog.java | 4 ++ 4 files changed, 67 insertions(+) diff --git a/client/secureclient/build.gradle b/client/secureclient/build.gradle index 93b0f256f..82d3b81e2 100644 --- a/client/secureclient/build.gradle +++ b/client/secureclient/build.gradle @@ -17,6 +17,14 @@ dependencies { } +// Stamp the project version into the jar manifest so WebClientFactory can advertise the client +// version in the User-Agent header by reading Implementation-Version at runtime. +jar { + manifest { + attributes('Implementation-Version': project.version) + } +} + test { useJUnitPlatform() } diff --git a/client/secureclient/src/main/java/com/linkedin/openhouse/client/ssl/WebClientFactory.java b/client/secureclient/src/main/java/com/linkedin/openhouse/client/ssl/WebClientFactory.java index 79bb2dc19..16e603f74 100644 --- a/client/secureclient/src/main/java/com/linkedin/openhouse/client/ssl/WebClientFactory.java +++ b/client/secureclient/src/main/java/com/linkedin/openhouse/client/ssl/WebClientFactory.java @@ -12,6 +12,7 @@ import lombok.NonNull; import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; @@ -26,6 +27,12 @@ public abstract class WebClientFactory { private static final String SESSION_ID = "session-id"; public static final String HTTP_HEADER_CLIENT_NAME = "X-Client-Name"; + // Product token advertised in the User-Agent header so the server can observe the client version. + // The resulting header looks like "openhouse-java-client/1.5.2". + public static final String USER_AGENT_CLIENT_PRODUCT = "openhouse-java-client"; + // Fallback when the client jar manifest carries no Implementation-Version (e.g. running from + // classes during local/dev/tests) and no explicit version was set. + private static final String CLIENT_VERSION_UNKNOWN = "unknown"; private static final int IN_MEMORY_BUFFER_SIZE = 20 * 1024 * 1024; // The maximum number of connections per connection pool private static final int MAX_CONNECTION_POOL_SIZE = 500; @@ -45,6 +52,10 @@ public abstract class WebClientFactory { @Setter private String clientName = null; + // When null, the version is resolved from the client jar manifest's Implementation-Version, + // falling back to {@link #CLIENT_VERSION_UNKNOWN}. Callers may set an explicit value to override. + @Setter private String clientVersion = null; + protected WebClientFactory() { setStrategy(); } @@ -159,6 +170,7 @@ private WebClient getWebClient(String baseUrl, HttpClient httpClient) { WebClient.Builder webClientBuilder = createWebClientBuilder(); setSessionIdInWebClientHeader(webClientBuilder); setClientNameInWebClientHeader(webClientBuilder); + setUserAgentInWebClientHeader(webClientBuilder); return webClientBuilder .baseUrl(baseUrl) .clientConnector(new ReactorClientHttpConnector(httpClient)) @@ -209,6 +221,37 @@ private void setClientNameInWebClientHeader(WebClient.Builder webClientBuilder) log.info("Client name: {}", clientName); } + /** + * Set the User-Agent header on the webClient so the server can observe the client version on + * every request, e.g. "openhouse-java-client/1.5.2". The version is resolved via {@link + * #resolveClientVersion()}. Setting our own default User-Agent replaces the transport's default + * (e.g. "ReactorNetty/x.y.z"). + * + * @param webClientBuilder + */ + private void setUserAgentInWebClientHeader(WebClient.Builder webClientBuilder) { + String userAgent = USER_AGENT_CLIENT_PRODUCT + "/" + resolveClientVersion(); + webClientBuilder.defaultHeaders(h -> h.set(HttpHeaders.USER_AGENT, userAgent)); + log.info("Client User-Agent: {}", userAgent); + } + + /** + * Resolve the client version to advertise: an explicitly set {@link #clientVersion} takes + * precedence, otherwise the Implementation-Version stamped into this jar's manifest, otherwise + * {@link #CLIENT_VERSION_UNKNOWN}. + * + * @return the resolved client version, never null + */ + private String resolveClientVersion() { + if (!StringUtil.isNullOrEmpty(clientVersion)) { + return clientVersion; + } + String implementationVersion = WebClientFactory.class.getPackage().getImplementationVersion(); + return StringUtil.isNullOrEmpty(implementationVersion) + ? CLIENT_VERSION_UNKNOWN + : implementationVersion; + } + /** * Returns custom connection provider * diff --git a/client/secureclient/src/test/java/com/linkedin/openhouse/client/ssl/TablesApiClientFactoryTest.java b/client/secureclient/src/test/java/com/linkedin/openhouse/client/ssl/TablesApiClientFactoryTest.java index 6aacdaee0..64a678502 100644 --- a/client/secureclient/src/test/java/com/linkedin/openhouse/client/ssl/TablesApiClientFactoryTest.java +++ b/client/secureclient/src/test/java/com/linkedin/openhouse/client/ssl/TablesApiClientFactoryTest.java @@ -99,4 +99,16 @@ public void testSetClientNameCalled() throws Exception { .setClientName(clientNameCapture.capture()); assertEquals("trino", clientNameCapture.getValue()); } + + @Test + public void testSetClientVersionCalled() throws Exception { + ArgumentCaptor clientVersionCapture = ArgumentCaptor.forClass(String.class); + + tablesApiClientFactorySpy.setClientVersion("1.2.3"); + tablesApiClientFactorySpy.createApiClient( + "https://test.openhouse.com", "", tmpCert.getAbsolutePath()); + Mockito.verify(tablesApiClientFactorySpy, Mockito.times(1)) + .setClientVersion(clientVersionCapture.capture()); + assertEquals("1.2.3", clientVersionCapture.getValue()); + } } diff --git a/integrations/java/iceberg-1.2/openhouse-java-runtime/src/main/java/com/linkedin/openhouse/javaclient/OpenHouseCatalog.java b/integrations/java/iceberg-1.2/openhouse-java-runtime/src/main/java/com/linkedin/openhouse/javaclient/OpenHouseCatalog.java index de6e3d43a..29f846eac 100644 --- a/integrations/java/iceberg-1.2/openhouse-java-runtime/src/main/java/com/linkedin/openhouse/javaclient/OpenHouseCatalog.java +++ b/integrations/java/iceberg-1.2/openhouse-java-runtime/src/main/java/com/linkedin/openhouse/javaclient/OpenHouseCatalog.java @@ -102,6 +102,8 @@ public class OpenHouseCatalog extends BaseMetastoreCatalog public static final String CLIENT_NAME = "client-name"; + public static final String CLIENT_VERSION = "client-version"; + @Override public void initialize(String name, Map properties) { this.name = name; @@ -113,10 +115,12 @@ public void initialize(String name, Map properties) { String token = properties.getOrDefault(AUTH_TOKEN, null); String httpConnectionStrategy = properties.getOrDefault(HTTP_CONNECTION_STRATEGY, null); String clientName = properties.getOrDefault(CLIENT_NAME, null); + String clientVersion = properties.getOrDefault(CLIENT_VERSION, null); try { TablesApiClientFactory tablesApiClientFactory = TablesApiClientFactory.getInstance(); tablesApiClientFactory.setStrategy(HttpConnectionStrategy.fromString(httpConnectionStrategy)); tablesApiClientFactory.setClientName(clientName); + tablesApiClientFactory.setClientVersion(clientVersion); if (properties.containsKey(CatalogProperties.APP_ID)) { tablesApiClientFactory.setSessionId(properties.get(CatalogProperties.APP_ID)); }