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/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 cf9351b9..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 @@ -304,6 +304,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 +349,16 @@ else if (wrapperName != null) { headers.put("X-LaunchDarkly-Wrapper", wrapperId); } + // 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. if (!customHeaders.isEmpty()) { headers.putAll(customHeaders); } 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/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..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 @@ -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,57 @@ public void testCanSetCustomHeaders() { .put("User-Agent", "This too") .build(); - assertEquals(expectedHeaders, ImmutableMap.copyOf(hc.getDefaultHeaders())); + assertEquals(expectedHeaders, headersExcludingInstanceId(hc)); + assertHasInstanceIdHeader(hc); + } + + @Test + 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); + 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 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); + 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 context-supplied 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