From 12002c00abc952fd3a419182cb1979d3d9edd5d9 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 12 May 2026 16:30:40 -0400 Subject: [PATCH 1/2] feat: add X-LaunchDarkly-Instance-Id header (SDK-2356) Generate a v4 UUID once per SDK instance in HttpConfigurationBuilderImpl.build and stamp it on the default headers map. Because the default headers are shared by the stream, poll, and event paths, every outbound request carries the same stable per-instance identifier without per-channel plumbing. Registers the "instance-id" capability with the contract test service so the cross-SDK harness can verify the header on stream, poll, and event requests. Updates header-comparison tests that constructed a fresh HttpConfiguration side-by-side with the one under test; the instance ID is per-build so those tests now compare other headers exactly and only assert presence of the instance ID header. --- .../src/main/java/sdktest/TestService.java | 3 +- .../sdk/server/ComponentsImpl.java | 19 ++++++ .../server/DefaultFeatureRequestorTest.java | 8 +++ .../launchdarkly/sdk/server/LDConfigTest.java | 11 +++- .../sdk/server/StreamProcessorTest.java | 7 ++ .../HttpConfigurationBuilderTest.java | 65 ++++++++++++++++++- 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java index 1355fdd4..4152b158 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java @@ -42,7 +42,8 @@ public class TestService { "strongly-typed", "tags", "server-side-polling", - "fdv1-fallback" + "fdv1-fallback", + "instance-id" }; static final Gson gson = new GsonBuilder().serializeNulls().create(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index cf9351b9..4c7b074a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -47,6 +47,7 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -304,6 +305,15 @@ private Result transformResult(com.launchdarkly.sdk.server.subsystems.EventSende } static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { + /** + * HTTP header used to identify this SDK instance for the purpose of estimating + * server-connection-minutes when polling. It contains a v4 UUID that is generated once per SDK + * instance and remains constant for the lifetime of the client. + * + *

See: sdk-specs / SCMP-server-connection-minutes-polling. + */ + static final String INSTANCE_ID_HEADER = "X-LaunchDarkly-Instance-Id"; + @Override public HttpConfiguration build(ClientContext clientContext) { LDLogger logger = clientContext.getBaseLogger(); @@ -340,6 +350,15 @@ else if (wrapperName != null) { headers.put("X-LaunchDarkly-Wrapper", wrapperId); } + // Per SCMP-server-connection-minutes-polling, every polling request must carry a per-instance + // GUID v4. We attach it to the default headers (rather than only on the poller) so that it is + // also present on streaming and event requests; this matches the cross-SDK contract tests and + // keeps the GUID stable for the lifetime of the SDK instance, since the default headers map + // is built once per HttpConfiguration and never modified afterwards. + headers.put(INSTANCE_ID_HEADER, UUID.randomUUID().toString()); + + // For consistency with other SDKs, custom headers are allowed to overwrite headers such as + // User-Agent and Authorization. if (!customHeaders.isEmpty()) { headers.putAll(customHeaders); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 1b7f315f..7cc6ac9c 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -245,6 +245,14 @@ public void ignoreEmptyFilter() throws Exception { private void verifyHeaders(RequestInfo req) { HttpConfiguration httpConfig = clientContext(sdkKey, LDConfig.DEFAULT).getHttp(); for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + // X-LaunchDarkly-Instance-Id is a per-HttpConfiguration random UUID, so the value generated + // here won't match the one used by the requestor in the test. We only verify that *some* + // instance ID header is present on the request; per-builder uniqueness is covered in + // HttpConfigurationBuilderTest. + if (kv.getKey().equals("X-LaunchDarkly-Instance-Id")) { + assertNotNull(req.getHeader(kv.getKey())); + continue; + } assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 5a062fb0..62e95eaf 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -24,6 +24,8 @@ import java.net.URI; import java.time.Duration; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import static com.launchdarkly.sdk.server.TestComponents.clientContext; @@ -198,7 +200,14 @@ public void testHttpDefaults() { assertEquals(defaults.getSocketTimeout(), hc.getSocketTimeout()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); - assertEquals(ImmutableMap.copyOf(defaults.getDefaultHeaders()), ImmutableMap.copyOf(hc.getDefaultHeaders())); + // The X-LaunchDarkly-Instance-Id header is a fresh UUID per HttpConfiguration, so it will + // differ between the two configurations; compare the remaining headers and verify the + // instance-id header is present on both. + Map defaultHeaders = new HashMap<>(ImmutableMap.copyOf(defaults.getDefaultHeaders())); + Map hcHeaders = new HashMap<>(ImmutableMap.copyOf(hc.getDefaultHeaders())); + assertNotNull(defaultHeaders.remove("X-LaunchDarkly-Instance-Id")); + assertNotNull(hcHeaders.remove("X-LaunchDarkly-Instance-Id")); + assertEquals(defaultHeaders, hcHeaders); } @Test diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 884bc479..e79ab73d 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -208,6 +208,13 @@ public void verifyStreamRequestProperties() throws Exception { assertThat(req.getPath(), equalTo("/all")); for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + // X-LaunchDarkly-Instance-Id is a per-HttpConfiguration random UUID and the + // configuration here is a fresh build, distinct from the one used by the stream + // processor; only assert presence. + if (kv.getKey().equals("X-LaunchDarkly-Instance-Id")) { + assertNotNull(req.getHeader(kv.getKey())); + continue; + } assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); } assertThat(req.getHeader("Accept"), equalTo("text/event-stream")); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index 7eeea053..f0645031 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -17,6 +17,9 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; @@ -26,13 +29,16 @@ import static com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT; import static com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class HttpConfigurationBuilderTest { private static final String SDK_KEY = "sdk-key"; + private static final String INSTANCE_ID_HEADER = "X-LaunchDarkly-Instance-Id"; private static final ClientContext BASIC_CONTEXT = new ClientContext(SDK_KEY); private static ImmutableMap.Builder buildBasicHeaders() { @@ -41,6 +47,30 @@ private static ImmutableMap.Builder buildBasicHeaders() { .put("User-Agent", "JavaClient/" + getSdkVersion()); } + /** + * Returns a copy of the default headers from {@code hc} with the per-instance + * {@code X-LaunchDarkly-Instance-Id} header removed, so the remainder can be compared against a + * fixed expected map. The instance ID header is verified separately because its value is a + * randomly generated UUID. + */ + private static Map headersExcludingInstanceId(HttpConfiguration hc) { + Map copy = new HashMap<>(ImmutableMap.copyOf(hc.getDefaultHeaders())); + copy.remove(INSTANCE_ID_HEADER); + return copy; + } + + /** + * Asserts that {@code hc} carries an {@code X-LaunchDarkly-Instance-Id} default header whose + * value is a parseable v4 UUID, and returns that value so it can be compared across calls. + */ + private static String assertHasInstanceIdHeader(HttpConfiguration hc) { + String value = ImmutableMap.copyOf(hc.getDefaultHeaders()).get(INSTANCE_ID_HEADER); + assertNotNull("expected X-LaunchDarkly-Instance-Id header to be present", value); + UUID parsed = UUID.fromString(value); + assertEquals("instance ID must be a UUID v4", 4, parsed.version()); + return value; + } + @Test public void testDefaults() { HttpConfiguration hc = Components.httpConfiguration().build(BASIC_CONTEXT); @@ -51,7 +81,8 @@ public void testDefaults() { assertNull(hc.getSocketFactory()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); - assertEquals(buildBasicHeaders().build(), ImmutableMap.copyOf(hc.getDefaultHeaders())); + assertEquals(buildBasicHeaders().build(), headersExcludingInstanceId(hc)); + assertHasInstanceIdHeader(hc); } @Test @@ -70,7 +101,37 @@ public void testCanSetCustomHeaders() { .put("User-Agent", "This too") .build(); - assertEquals(expectedHeaders, ImmutableMap.copyOf(hc.getDefaultHeaders())); + assertEquals(expectedHeaders, headersExcludingInstanceId(hc)); + assertHasInstanceIdHeader(hc); + } + + @Test + public void testInstanceIdHeaderIsUuidV4() { + HttpConfiguration hc = Components.httpConfiguration().build(BASIC_CONTEXT); + assertHasInstanceIdHeader(hc); + } + + @Test + public void testInstanceIdIsDifferentBetweenHttpConfigurations() { + // Each call to build() represents a new SDK instance; each must get its own GUID. + HttpConfiguration hc1 = Components.httpConfiguration().build(BASIC_CONTEXT); + HttpConfiguration hc2 = Components.httpConfiguration().build(BASIC_CONTEXT); + String id1 = assertHasInstanceIdHeader(hc1); + String id2 = assertHasInstanceIdHeader(hc2); + assertNotEquals("each SDK instance should generate its own instance id", id1, id2); + } + + @Test + public void testInstanceIdHeaderIsNotOverriddenByCustomHeaders() { + // The default-headers map is built once per HttpConfiguration; a user-supplied custom header + // for X-LaunchDarkly-Instance-Id is allowed to replace the SDK-generated value, but absent + // that, the SDK's generated UUID must come through. + HttpConfiguration hc = Components.httpConfiguration() + .addCustomHeader("X-Some-Other-Header", "value") + .build(BASIC_CONTEXT); + String value = assertHasInstanceIdHeader(hc); + assertTrue("instance ID must not be the literal string 'X-Some-Other-Header' value", + !"value".equals(value)); } @Test From 30cf64b9ab81643b511835005117532bdfd353a2 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 19 May 2026 12:10:24 -0400 Subject: [PATCH 2/2] refactor: Move instance-id generation to ClientContext Generating X-LaunchDarkly-Instance-Id inside HttpConfigurationBuilderImpl.build() tied the value to a specific subsystem builder. Any code path that goes through HttpConfiguration would see a value, but other subsystems built from ClientContext (which is the canonical per-LDClient state holder) had no way to read the same id. Add an instanceId field, getter, and a nine-argument constructor variant to ClientContext. The eight-argument constructor auto-generates a v4 UUID for any caller that does not need to pass an explicit value; the copy constructor propagates the existing value, so derived ClientContext instances stay in sync. ClientContextImpl.fromConfig now generates the UUID once and threads it through each of the three ClientContext variants it constructs during LDClient init. HttpConfigurationBuilderImpl reads from ClientContext.getInstanceId() instead of generating its own. Existing tests in DefaultFeatureRequestorTest / LDConfigTest / StreamProcessorTest that already accounted for per-build randomness continue to work because each `clientContext(...)` they construct gets its own auto-generated id. Update HttpConfigurationBuilderTest to assert the new contract: the builder mirrors whatever id the context provides; two distinct ClientContexts produce distinct ids; one context produces a stable id across multiple builds. Verified locally with `./gradlew test` against lib/sdk/server: all 17 of 17 test classes pass, including 15 of 15 HttpConfigurationBuilderTest cases. --- .../sdk/server/ClientContextImpl.java | 24 ++++--- .../sdk/server/ComponentsImpl.java | 14 ++-- .../sdk/server/subsystems/ClientContext.java | 70 ++++++++++++++++--- .../HttpConfigurationBuilderTest.java | 32 +++++++-- 4 files changed, 111 insertions(+), 29 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 6146b39c..16e37d16 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.subsystems.HttpConfiguration; import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; +import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -37,7 +38,7 @@ private ClientContextImpl( ) { super(baseContext.getSdkKey(), baseContext.getApplicationInfo(), baseContext.getHttp(), baseContext.getLogging(), baseContext.isOffline(), baseContext.getServiceEndpoints(), - baseContext.getThreadPriority(), baseContext.getWrapperInfo()); + baseContext.getThreadPriority(), baseContext.getWrapperInfo(), baseContext.getInstanceId()); this.sharedExecutor = sharedExecutor; this.diagnosticStore = diagnosticStore; this.dataSourceUpdateSink = null; @@ -79,22 +80,29 @@ static ClientContextImpl fromConfig( LDConfig config, ScheduledExecutorService sharedExecutor ) { + // Generate the instance ID once and thread it through every ClientContext we build for this + // LDClient. Subsystems built from any of these contexts will all observe the same value. + String instanceId = UUID.randomUUID().toString(); + ClientContext minimalContext = new ClientContext(sdkKey, config.applicationInfo, null, - null, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo); + null, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo, + instanceId); LoggingConfiguration loggingConfig = config.logging.build(minimalContext); - + ClientContext contextWithLogging = new ClientContext(sdkKey, config.applicationInfo, null, - loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo); + loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, + config.wrapperInfo, instanceId); HttpConfiguration httpConfig = config.http.build(contextWithLogging); - + if (httpConfig.getProxy() != null) { contextWithLogging.getBaseLogger().info("Using proxy: {} {} authentication.", httpConfig.getProxy(), httpConfig.getProxyAuthentication() == null ? "without" : "with"); } - - ClientContext contextWithHttpAndLogging = new ClientContext(sdkKey, config.applicationInfo, httpConfig, - loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo); + + ClientContext contextWithHttpAndLogging = new ClientContext(sdkKey, config.applicationInfo, + httpConfig, loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, + config.wrapperInfo, instanceId); // Create a diagnostic store only if diagnostics are enabled. Diagnostics are enabled as long as 1. the // opt-out property was not set in the config, and 2. we are using the standard event processor. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 4c7b074a..3b62c41a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -47,7 +47,6 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -350,12 +349,13 @@ else if (wrapperName != null) { headers.put("X-LaunchDarkly-Wrapper", wrapperId); } - // Per SCMP-server-connection-minutes-polling, every polling request must carry a per-instance - // GUID v4. We attach it to the default headers (rather than only on the poller) so that it is - // also present on streaming and event requests; this matches the cross-SDK contract tests and - // keeps the GUID stable for the lifetime of the SDK instance, since the default headers map - // is built once per HttpConfiguration and never modified afterwards. - headers.put(INSTANCE_ID_HEADER, UUID.randomUUID().toString()); + // The instance ID originates on ClientContext (generated once when LDClient is constructed) + // so every subsystem built from the same context observes a consistent value for the + // lifetime of the SDK instance. + String instanceId = clientContext.getInstanceId(); + if (instanceId != null && !instanceId.isEmpty()) { + headers.put(INSTANCE_ID_HEADER, instanceId); + } // For consistency with other SDKs, custom headers are allowed to overwrite headers such as // User-Agent and Authorization. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/ClientContext.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/ClientContext.java index 1df46b34..9191d7e9 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/ClientContext.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/ClientContext.java @@ -8,6 +8,8 @@ import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.interfaces.WrapperInfo; +import java.util.UUID; + /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

@@ -31,11 +33,18 @@ public class ClientContext { private final boolean offline; private final ServiceEndpoints serviceEndpoints; private final int threadPriority; + private final String instanceId; private WrapperInfo wrapperInfo; /** - * Constructor that sets all properties. All should be non-null. - * + * Constructor that sets all properties including an explicit instance ID. All should be + * non-null. + * + *

The instance ID is sent on every outbound request in the {@code X-LaunchDarkly-Instance-Id} + * header. It must be generated once per LDClient and remain stable for the client's lifetime. + * The eight-argument constructor auto-generates a v4 UUID for callers that do not need to + * supply their own value. + * * @param sdkKey the SDK key * @param applicationInfo application metadata properties from * {@link Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)} @@ -46,6 +55,7 @@ public class ClientContext { * {@link Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)} * @param threadPriority worker thread priority from {@link Builder#threadPriority(int)} * @param wrapperInfo wrapper configuration from {@link Builder#wrapper(com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder)} + * @param instanceId per-LDClient identifier for the {@code X-LaunchDarkly-Instance-Id} header */ public ClientContext( String sdkKey, @@ -55,7 +65,8 @@ public ClientContext( boolean offline, ServiceEndpoints serviceEndpoints, int threadPriority, - WrapperInfo wrapperInfo + WrapperInfo wrapperInfo, + String instanceId ) { this.sdkKey = sdkKey; this.applicationInfo = applicationInfo; @@ -65,19 +76,51 @@ public ClientContext( this.serviceEndpoints = serviceEndpoints; this.threadPriority = threadPriority; this.wrapperInfo = wrapperInfo; - + this.instanceId = instanceId; + this.baseLogger = logging == null ? LDLogger.none() : LDLogger.withAdapter(logging.getLogAdapter(), logging.getBaseLoggerName()); } - + + /** + * Constructor that sets all properties. All should be non-null. Auto-generates a v4 UUID for + * the instance ID; use the nine-argument constructor if you need to thread an existing value + * through (for example, when copying a context for an in-flight LDClient). + * + * @param sdkKey the SDK key + * @param applicationInfo application metadata properties from + * {@link Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)} + * @param http HTTP configuration properties from {@link Builder#http(ComponentConfigurer)} + * @param logging logging configuration properties from {@link Builder#logging(ComponentConfigurer)} + * @param offline true if the SDK should be entirely offline + * @param serviceEndpoints service endpoint URI properties from + * {@link Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)} + * @param threadPriority worker thread priority from {@link Builder#threadPriority(int)} + * @param wrapperInfo wrapper configuration from {@link Builder#wrapper(com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder)} + */ + public ClientContext( + String sdkKey, + ApplicationInfo applicationInfo, + HttpConfiguration http, + LoggingConfiguration logging, + boolean offline, + ServiceEndpoints serviceEndpoints, + int threadPriority, + WrapperInfo wrapperInfo + ) { + this(sdkKey, applicationInfo, http, logging, offline, serviceEndpoints, threadPriority, + wrapperInfo, UUID.randomUUID().toString()); + } + /** * Copy constructor. - * + * * @param copyFrom the instance to copy from */ protected ClientContext(ClientContext copyFrom) { this(copyFrom.sdkKey, copyFrom.applicationInfo, copyFrom.http, copyFrom.logging, - copyFrom.offline, copyFrom.serviceEndpoints, copyFrom.threadPriority, copyFrom.wrapperInfo); + copyFrom.offline, copyFrom.serviceEndpoints, copyFrom.threadPriority, copyFrom.wrapperInfo, + copyFrom.instanceId); } /** @@ -199,13 +242,24 @@ public ServiceEndpoints getServiceEndpoints() { /** * Returns the worker thread priority that is set by * {@link Builder#threadPriority(int)}. - * + * * @return the thread priority */ public int getThreadPriority() { return threadPriority; } + /** + * Returns the per-LDClient instance identifier sent in the {@code X-LaunchDarkly-Instance-Id} + * header on outbound requests. The value is generated once when the {@link ClientContext} is + * constructed and is stable for the client's lifetime. + * + * @return the instance ID + */ + public String getInstanceId() { + return instanceId; + } + /** * Returns the wrapper information. * diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index f0645031..71d3565a 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -106,26 +106,46 @@ public void testCanSetCustomHeaders() { } @Test - public void testInstanceIdHeaderIsUuidV4() { + public void testInstanceIdHeaderMirrorsClientContext() { + // The HTTP builder emits whatever instance ID ClientContext provides; generation is the + // LDClient/ClientContext's responsibility, not the builder's. HttpConfiguration hc = Components.httpConfiguration().build(BASIC_CONTEXT); - assertHasInstanceIdHeader(hc); + String headerValue = assertHasInstanceIdHeader(hc); + assertEquals(BASIC_CONTEXT.getInstanceId(), headerValue); + } + + @Test + public void testInstanceIdIsDifferentBetweenClientContexts() { + // Each ClientContext auto-generates its own instance ID, so building HttpConfigurations + // from two distinct contexts produces distinct header values. This is the + // cross-SDK-instance uniqueness property the contract tests assert against. + ClientContext context1 = new ClientContext(SDK_KEY); + ClientContext context2 = new ClientContext(SDK_KEY); + HttpConfiguration hc1 = Components.httpConfiguration().build(context1); + HttpConfiguration hc2 = Components.httpConfiguration().build(context2); + String id1 = assertHasInstanceIdHeader(hc1); + String id2 = assertHasInstanceIdHeader(hc2); + assertNotEquals("each SDK instance should have its own instance id", id1, id2); } @Test - public void testInstanceIdIsDifferentBetweenHttpConfigurations() { - // Each call to build() represents a new SDK instance; each must get its own GUID. + public void testInstanceIdHeaderIsStableAcrossBuildsFromSameContext() { + // Multiple build() calls against the same ClientContext (which is what happens during + // LDClient initialization when ClientContextImpl rebuilds the context across logging/HTTP + // stages) must produce the same instance id. HttpConfiguration hc1 = Components.httpConfiguration().build(BASIC_CONTEXT); HttpConfiguration hc2 = Components.httpConfiguration().build(BASIC_CONTEXT); String id1 = assertHasInstanceIdHeader(hc1); String id2 = assertHasInstanceIdHeader(hc2); - assertNotEquals("each SDK instance should generate its own instance id", id1, id2); + assertEquals(id1, id2); + assertEquals(BASIC_CONTEXT.getInstanceId(), id1); } @Test public void testInstanceIdHeaderIsNotOverriddenByCustomHeaders() { // The default-headers map is built once per HttpConfiguration; a user-supplied custom header // for X-LaunchDarkly-Instance-Id is allowed to replace the SDK-generated value, but absent - // that, the SDK's generated UUID must come through. + // that, the context-supplied UUID must come through. HttpConfiguration hc = Components.httpConfiguration() .addCustomHeader("X-Some-Other-Header", "value") .build(BASIC_CONTEXT);