Repository: Unleash/unleash-client-java
Pull request #263 (2025-01-20 15:37:58)

feat: client identification headers
<!-- Thanks for creating a PR! To make it easier for reviewers and everyone else to understand what your changes relate to, please add some relevant content to the headings below. Feel free to ignore or delete sections that you don't think are relevant. Thank you! ❤️ -->

## About the changes

This PR adds client identification headers to the feature and metrics calls that the client makes to Unleash. The headers are:
- `x-unleash-appname`: the name of the application that is using the client. `UNLEASH-APPNAME` will be deleted in another PR (expand/contract pattern)
- `x-unleash-connection-id`: an internal unique identifier for the current instance of the client generated by the built-in UUID lib
- `x-unleash-sdk`: sdk information in the format `unleash-java@<version>` following reference `unleash-lang@<version>` naming pattern

Reference implementation: https://github.com/Unleash/unleash-client-node/pull/690


### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally important. Where should a reviewer start looking to get an overview of the changes? Are any files particularly important?  -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged? Got any questions or doubts? -->
There's a failing test that I can't reproduce failing locally.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/71823411/badge)](https://coveralls.io/builds/71823411)

coverage: 88.643% (+2.1%) from 86.52%
when pulling **4774d26d1d11d1a2e9e53d889593311d6285bc99 on client-identification-headers**
into **f5c5399b6bc2407ce02aa987cc42b8d64df6da60 on main**.

sighphyre: So I see the tests are failing and I see it's our old friend `asynchronous_fetch_on_initialisation_fails_silently_and_retries`. This test was failing for me on the Yggdrasil swap out branch (I honestly assumed it was something I broke in that branch), but I see it's having a similar problem here. I had to change the end lines in the test:

```
        Unleash unleash = new DefaultUnleash(config);
        verify(fetcher, times(1)).fetchFeatures();
        Thread.sleep(1200);
        verify(fetcher, times(2)).fetchFeatures();
```

To this:

```
        Unleash unleash = new DefaultUnleash(config);
        Thread.sleep(1); // new sleep
        verify(fetcher, times(1)).fetchFeatures();
        Thread.sleep(1200);
        verify(fetcher, times(2)).fetchFeatures();
```
That's probably fine here if you can't get that test to pass

Patch:
diff --git a/src/main/java/io/getunleash/util/OkHttpClientConfigurer.java b/src/main/java/io/getunleash/util/OkHttpClientConfigurer.java
index afcc6188..43c59d13 100644
--- a/src/main/java/io/getunleash/util/OkHttpClientConfigurer.java
+++ b/src/main/java/io/getunleash/util/OkHttpClientConfigurer.java
@@ -1,7 +1,6 @@
 package io.getunleash.util;
 
-import static io.getunleash.util.UnleashConfig.UNLEASH_APP_NAME_HEADER;
-import static io.getunleash.util.UnleashConfig.UNLEASH_INSTANCE_ID_HEADER;
+import static io.getunleash.util.UnleashConfig.*;
 
 import java.util.Map;
 import okhttp3.OkHttpClient;
@@ -17,10 +16,17 @@ public class OkHttpClientConfigurer {
                                             .newBuilder()
                                             .addHeader("Content-Type", "application/json")
                                             .addHeader("Accept", "application/json")
+                                            .addHeader(
+                                                    LEGACY_UNLEASH_APP_NAME_HEADER,
+                                                    config.getAppName())
                                             .addHeader(UNLEASH_APP_NAME_HEADER, config.getAppName())
                                             .addHeader(
                                                     UNLEASH_INSTANCE_ID_HEADER,
                                                     config.getInstanceId())
+                                            .addHeader(
+                                                    UNLEASH_CONNECTION_ID_HEADER,
+                                                    config.getConnectionId())
+                                            .addHeader(UNLEASH_SDK_HEADER, config.getSdkVersion())
                                             .addHeader("User-Agent", config.getAppName())
                                             .addHeader(
                                                     "Unleash-Client-Spec",
diff --git a/src/main/java/io/getunleash/util/UnleashConfig.java b/src/main/java/io/getunleash/util/UnleashConfig.java
index 601200b8..485fdb8e 100644
--- a/src/main/java/io/getunleash/util/UnleashConfig.java
+++ b/src/main/java/io/getunleash/util/UnleashConfig.java
@@ -23,12 +23,16 @@ import java.time.Duration;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.function.Consumer;
 
 public class UnleashConfig {
 
-    public static final String UNLEASH_APP_NAME_HEADER = "UNLEASH-APPNAME";
+    public static final String LEGACY_UNLEASH_APP_NAME_HEADER = "UNLEASH-APPNAME";
     public static final String UNLEASH_INSTANCE_ID_HEADER = "UNLEASH-INSTANCEID";
+    public static final String UNLEASH_CONNECTION_ID_HEADER = "X-UNLEASH-CONNECTION-ID";
+    public static final String UNLEASH_APP_NAME_HEADER = "X-UNLEASH-APPNAME";
+    public static final String UNLEASH_SDK_HEADER = "X-UNLEASH-SDK";
 
     private final URI unleashAPI;
     private final UnleashURLs unleashURLs;
@@ -37,6 +41,7 @@ public class UnleashConfig {
     private final String appName;
     private final String environment;
     private final String instanceId;
+    private final String connectionId;
     private final String sdkVersion;
     private final String backupFile;
 
@@ -77,6 +82,7 @@ public class UnleashConfig {
             @Nullable String appName,
             String environment,
             @Nullable String instanceId,
+            String connectionId,
             String sdkVersion,
             String backupFile,
             @Nullable String projectName,
@@ -141,6 +147,7 @@ public class UnleashConfig {
         this.appName = appName;
         this.environment = environment;
         this.instanceId = instanceId;
+        this.connectionId = connectionId;
         this.sdkVersion = sdkVersion;
         this.backupFile = backupFile;
         this.projectName = projectName;
@@ -172,8 +179,11 @@ public class UnleashConfig {
     }
 
     public static void setRequestProperties(HttpURLConnection connection, UnleashConfig config) {
+        connection.setRequestProperty(LEGACY_UNLEASH_APP_NAME_HEADER, config.getAppName());
         connection.setRequestProperty(UNLEASH_APP_NAME_HEADER, config.getAppName());
         connection.setRequestProperty(UNLEASH_INSTANCE_ID_HEADER, config.getInstanceId());
+        connection.setRequestProperty(UNLEASH_CONNECTION_ID_HEADER, config.getConnectionId());
+        connection.setRequestProperty(UNLEASH_SDK_HEADER, config.getSdkVersion());
         connection.setRequestProperty("User-Agent", config.getAppName());
         connection.setRequestProperty(
                 "Unleash-Client-Spec", config.getClientSpecificationVersion());
@@ -211,6 +221,10 @@ public class UnleashConfig {
         return instanceId;
     }
 
+    String getConnectionId() {
+        return connectionId;
+    }
+
     public String getSdkVersion() {
         return sdkVersion;
     }
@@ -401,6 +415,7 @@ public class UnleashConfig {
         private @Nullable String appName;
         private String environment = "default";
         private String instanceId = getDefaultInstanceId();
+        private String connectionId = getDefaultConnectionId();
         private final String sdkVersion = getDefaultSdkVersion();
         private @Nullable String backupFile;
         private @Nullable String projectName;
@@ -449,6 +464,10 @@ public class UnleashConfig {
             return getHostname() + "generated-" + Math.round(Math.random() * 1000000.0D);
         }
 
+        static String getDefaultConnectionId() {
+            return UUID.randomUUID().toString();
+        }
+
         public Builder unleashAPI(URI unleashAPI) {
             this.unleashAPI = unleashAPI;
             return this;
@@ -685,6 +704,7 @@ public class UnleashConfig {
                     appName,
                     environment,
                     instanceId,
+                    connectionId,
                     sdkVersion,
                     getBackupFile(),
                     projectName,
@@ -716,7 +736,7 @@ public class UnleashConfig {
             String version =
                     Optional.ofNullable(getClass().getPackage().getImplementationVersion())
                             .orElse("development");
-            return "unleash-client-java:" + version;
+            return "unleash-java@" + version;
         }
     }
 }


Test patch:
diff --git a/src/test/java/io/getunleash/DefaultUnleashTest.java b/src/test/java/io/getunleash/DefaultUnleashTest.java
index beebaa19..e1f90896 100644
--- a/src/test/java/io/getunleash/DefaultUnleashTest.java
+++ b/src/test/java/io/getunleash/DefaultUnleashTest.java
@@ -324,6 +324,7 @@ class DefaultUnleashTest {
                         .build();
 
         Unleash unleash = new DefaultUnleash(config);
+        Thread.sleep(1);
         verify(fetcher, times(1)).fetchFeatures();
         Thread.sleep(1200);
         verify(fetcher, times(2)).fetchFeatures();
diff --git a/src/test/java/io/getunleash/metric/DefaultHttpMetricsSenderTest.java b/src/test/java/io/getunleash/metric/DefaultHttpMetricsSenderTest.java
index f4250a99..59e0c7f8 100644
--- a/src/test/java/io/getunleash/metric/DefaultHttpMetricsSenderTest.java
+++ b/src/test/java/io/getunleash/metric/DefaultHttpMetricsSenderTest.java
@@ -32,7 +32,7 @@ public class DefaultHttpMetricsSenderTest {
     public void should_send_client_registration() throws URISyntaxException {
         stubFor(
                 post(urlEqualTo("/client/register"))
-                        .withHeader("UNLEASH-APPNAME", matching("test-app"))
+                        .withHeader("X-UNLEASH-APPNAME", matching("test-app"))
                         .willReturn(aResponse().withStatus(200)));
 
         URI uri = new URI("http://localhost:" + serverMock.getPort());
@@ -46,14 +46,14 @@ public class DefaultHttpMetricsSenderTest {
                 postRequestedFor(urlMatching("/client/register"))
                         .withRequestBody(matching(".*appName.*"))
                         .withRequestBody(matching(".*strategies.*"))
-                        .withHeader("UNLEASH-APPNAME", matching("test-app")));
+                        .withHeader("X-UNLEASH-APPNAME", matching("test-app")));
     }
 
     @Test
     public void should_send_client_metrics() throws URISyntaxException {
         stubFor(
                 post(urlEqualTo("/client/metrics"))
-                        .withHeader("UNLEASH-APPNAME", matching("test-app"))
+                        .withHeader("X-UNLEASH-APPNAME", matching("test-app"))
                         .willReturn(aResponse().withStatus(200)));
 
         URI uri = new URI("http://localhost:" + serverMock.getPort());
@@ -68,14 +68,14 @@ public class DefaultHttpMetricsSenderTest {
                 postRequestedFor(urlMatching("/client/metrics"))
                         .withRequestBody(matching(".*appName.*"))
                         .withRequestBody(matching(".*bucket.*"))
-                        .withHeader("UNLEASH-APPNAME", matching("test-app")));
+                        .withHeader("X-UNLEASH-APPNAME", matching("test-app")));
     }
 
     @Test
     public void should_handle_service_failure_when_sending_metrics() throws URISyntaxException {
         stubFor(
                 post(urlEqualTo("/client/metrics"))
-                        .withHeader("UNLEASH-APPNAME", matching("test-app"))
+                        .withHeader("X-UNLEASH-APPNAME", matching("test-app"))
                         .willReturn(aResponse().withStatus(500)));
 
         URI uri = new URI("http://localhost:" + serverMock.getPort());
@@ -90,6 +90,6 @@ public class DefaultHttpMetricsSenderTest {
                 postRequestedFor(urlMatching("/client/metrics"))
                         .withRequestBody(matching(".*appName.*"))
                         .withRequestBody(matching(".*bucket.*"))
-                        .withHeader("UNLEASH-APPNAME", matching("test-app")));
+                        .withHeader("X-UNLEASH-APPNAME", matching("test-app")));
     }
 }
diff --git a/src/test/java/io/getunleash/util/UnleashConfigTest.java b/src/test/java/io/getunleash/util/UnleashConfigTest.java
index 7ada84dc..63fe6e2b 100644
--- a/src/test/java/io/getunleash/util/UnleashConfigTest.java
+++ b/src/test/java/io/getunleash/util/UnleashConfigTest.java
@@ -1,7 +1,6 @@
 package io.getunleash.util;
 
-import static io.getunleash.util.UnleashConfig.UNLEASH_APP_NAME_HEADER;
-import static io.getunleash.util.UnleashConfig.UNLEASH_INSTANCE_ID_HEADER;
+import static io.getunleash.util.UnleashConfig.*;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -114,7 +113,7 @@ public class UnleashConfigTest {
         UnleashConfig config =
                 UnleashConfig.builder().appName("my-app").unleashAPI("http://unleash.org").build();
 
-        assertThat(config.getSdkVersion()).isEqualTo("unleash-client-java:development");
+        assertThat(config.getSdkVersion()).isEqualTo("unleash-java@development");
     }
 
     @Test
@@ -139,8 +138,7 @@ public class UnleashConfigTest {
     }
 
     @Test
-    public void should_add_app_name_and_instance_id_and_user_agent_to_connection()
-            throws IOException {
+    public void should_add_client_identification_headers_to_connection() throws IOException {
         String appName = "my-app";
         String instanceId = "my-instance-1";
         String unleashAPI = "http://unleash.org";
@@ -156,8 +154,13 @@ public class UnleashConfigTest {
         HttpURLConnection connection = (HttpURLConnection) someUrl.openConnection();
 
         UnleashConfig.setRequestProperties(connection, unleashConfig);
+        assertThat(connection.getRequestProperty(LEGACY_UNLEASH_APP_NAME_HEADER))
+                .isEqualTo(appName);
         assertThat(connection.getRequestProperty(UNLEASH_APP_NAME_HEADER)).isEqualTo(appName);
         assertThat(connection.getRequestProperty(UNLEASH_INSTANCE_ID_HEADER)).isEqualTo(instanceId);
+        assertThat(connection.getRequestProperty(UNLEASH_CONNECTION_ID_HEADER)).hasSize(36);
+        assertThat(connection.getRequestProperty(UNLEASH_SDK_HEADER))
+                .isEqualTo("unleash-java@development");
         assertThat(connection.getRequestProperty("User-Agent")).isEqualTo(appName);
     }