Skip to content

Commit a363777

Browse files
authored
feat: add X-LaunchDarkly-Instance-Id header (SDK-2356) (#162)
1 parent c27bf26 commit a363777

9 files changed

Lines changed: 259 additions & 29 deletions

File tree

lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ public class TestService {
4242
"strongly-typed",
4343
"tags",
4444
"server-side-polling",
45-
"fdv1-fallback"
45+
"fdv1-fallback",
46+
"instance-id"
4647
};
4748

4849
static final Gson gson = new GsonBuilder().serializeNulls().create();

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.launchdarkly.sdk.server.subsystems.HttpConfiguration;
99
import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration;
1010

11+
import java.util.UUID;
1112
import java.util.concurrent.Executors;
1213
import java.util.concurrent.ScheduledExecutorService;
1314

@@ -37,7 +38,7 @@ private ClientContextImpl(
3738
) {
3839
super(baseContext.getSdkKey(), baseContext.getApplicationInfo(), baseContext.getHttp(),
3940
baseContext.getLogging(), baseContext.isOffline(), baseContext.getServiceEndpoints(),
40-
baseContext.getThreadPriority(), baseContext.getWrapperInfo());
41+
baseContext.getThreadPriority(), baseContext.getWrapperInfo(), baseContext.getInstanceId());
4142
this.sharedExecutor = sharedExecutor;
4243
this.diagnosticStore = diagnosticStore;
4344
this.dataSourceUpdateSink = null;
@@ -79,22 +80,29 @@ static ClientContextImpl fromConfig(
7980
LDConfig config,
8081
ScheduledExecutorService sharedExecutor
8182
) {
83+
// Generate the instance ID once and thread it through every ClientContext we build for this
84+
// LDClient. Subsystems built from any of these contexts will all observe the same value.
85+
String instanceId = UUID.randomUUID().toString();
86+
8287
ClientContext minimalContext = new ClientContext(sdkKey, config.applicationInfo, null,
83-
null, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo);
88+
null, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo,
89+
instanceId);
8490
LoggingConfiguration loggingConfig = config.logging.build(minimalContext);
85-
91+
8692
ClientContext contextWithLogging = new ClientContext(sdkKey, config.applicationInfo, null,
87-
loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo);
93+
loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority,
94+
config.wrapperInfo, instanceId);
8895
HttpConfiguration httpConfig = config.http.build(contextWithLogging);
89-
96+
9097
if (httpConfig.getProxy() != null) {
9198
contextWithLogging.getBaseLogger().info("Using proxy: {} {} authentication.",
9299
httpConfig.getProxy(),
93100
httpConfig.getProxyAuthentication() == null ? "without" : "with");
94101
}
95-
96-
ClientContext contextWithHttpAndLogging = new ClientContext(sdkKey, config.applicationInfo, httpConfig,
97-
loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo);
102+
103+
ClientContext contextWithHttpAndLogging = new ClientContext(sdkKey, config.applicationInfo,
104+
httpConfig, loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority,
105+
config.wrapperInfo, instanceId);
98106

99107
// Create a diagnostic store only if diagnostics are enabled. Diagnostics are enabled as long as 1. the
100108
// opt-out property was not set in the config, and 2. we are using the standard event processor.

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,15 @@ private Result transformResult(com.launchdarkly.sdk.server.subsystems.EventSende
304304
}
305305

306306
static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder {
307+
/**
308+
* HTTP header used to identify this SDK instance for the purpose of estimating
309+
* server-connection-minutes when polling. It contains a v4 UUID that is generated once per SDK
310+
* instance and remains constant for the lifetime of the client.
311+
*
312+
* <p>See: sdk-specs / SCMP-server-connection-minutes-polling.
313+
*/
314+
static final String INSTANCE_ID_HEADER = "X-LaunchDarkly-Instance-Id";
315+
307316
@Override
308317
public HttpConfiguration build(ClientContext clientContext) {
309318
LDLogger logger = clientContext.getBaseLogger();
@@ -340,6 +349,16 @@ else if (wrapperName != null) {
340349
headers.put("X-LaunchDarkly-Wrapper", wrapperId);
341350
}
342351

352+
// The instance ID originates on ClientContext (generated once when LDClient is constructed)
353+
// so every subsystem built from the same context observes a consistent value for the
354+
// lifetime of the SDK instance.
355+
String instanceId = clientContext.getInstanceId();
356+
if (instanceId != null && !instanceId.isEmpty()) {
357+
headers.put(INSTANCE_ID_HEADER, instanceId);
358+
}
359+
360+
// For consistency with other SDKs, custom headers are allowed to overwrite headers such as
361+
// User-Agent and Authorization.
343362
if (!customHeaders.isEmpty()) {
344363
headers.putAll(customHeaders);
345364
}

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/ClientContext.java

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints;
99
import com.launchdarkly.sdk.server.interfaces.WrapperInfo;
1010

11+
import java.util.UUID;
12+
1113
/**
1214
* Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components.
1315
* <p>
@@ -31,11 +33,18 @@ public class ClientContext {
3133
private final boolean offline;
3234
private final ServiceEndpoints serviceEndpoints;
3335
private final int threadPriority;
36+
private final String instanceId;
3437
private WrapperInfo wrapperInfo;
3538

3639
/**
37-
* Constructor that sets all properties. All should be non-null.
38-
*
40+
* Constructor that sets all properties including an explicit instance ID. All should be
41+
* non-null.
42+
*
43+
* <p>The instance ID is sent on every outbound request in the {@code X-LaunchDarkly-Instance-Id}
44+
* header. It must be generated once per LDClient and remain stable for the client's lifetime.
45+
* The eight-argument constructor auto-generates a v4 UUID for callers that do not need to
46+
* supply their own value.
47+
*
3948
* @param sdkKey the SDK key
4049
* @param applicationInfo application metadata properties from
4150
* {@link Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)}
@@ -46,6 +55,7 @@ public class ClientContext {
4655
* {@link Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)}
4756
* @param threadPriority worker thread priority from {@link Builder#threadPriority(int)}
4857
* @param wrapperInfo wrapper configuration from {@link Builder#wrapper(com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder)}
58+
* @param instanceId per-LDClient identifier for the {@code X-LaunchDarkly-Instance-Id} header
4959
*/
5060
public ClientContext(
5161
String sdkKey,
@@ -55,7 +65,8 @@ public ClientContext(
5565
boolean offline,
5666
ServiceEndpoints serviceEndpoints,
5767
int threadPriority,
58-
WrapperInfo wrapperInfo
68+
WrapperInfo wrapperInfo,
69+
String instanceId
5970
) {
6071
this.sdkKey = sdkKey;
6172
this.applicationInfo = applicationInfo;
@@ -65,19 +76,51 @@ public ClientContext(
6576
this.serviceEndpoints = serviceEndpoints;
6677
this.threadPriority = threadPriority;
6778
this.wrapperInfo = wrapperInfo;
68-
79+
this.instanceId = instanceId;
80+
6981
this.baseLogger = logging == null ? LDLogger.none() :
7082
LDLogger.withAdapter(logging.getLogAdapter(), logging.getBaseLoggerName());
7183
}
72-
84+
85+
/**
86+
* Constructor that sets all properties. All should be non-null. Auto-generates a v4 UUID for
87+
* the instance ID; use the nine-argument constructor if you need to thread an existing value
88+
* through (for example, when copying a context for an in-flight LDClient).
89+
*
90+
* @param sdkKey the SDK key
91+
* @param applicationInfo application metadata properties from
92+
* {@link Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)}
93+
* @param http HTTP configuration properties from {@link Builder#http(ComponentConfigurer)}
94+
* @param logging logging configuration properties from {@link Builder#logging(ComponentConfigurer)}
95+
* @param offline true if the SDK should be entirely offline
96+
* @param serviceEndpoints service endpoint URI properties from
97+
* {@link Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)}
98+
* @param threadPriority worker thread priority from {@link Builder#threadPriority(int)}
99+
* @param wrapperInfo wrapper configuration from {@link Builder#wrapper(com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder)}
100+
*/
101+
public ClientContext(
102+
String sdkKey,
103+
ApplicationInfo applicationInfo,
104+
HttpConfiguration http,
105+
LoggingConfiguration logging,
106+
boolean offline,
107+
ServiceEndpoints serviceEndpoints,
108+
int threadPriority,
109+
WrapperInfo wrapperInfo
110+
) {
111+
this(sdkKey, applicationInfo, http, logging, offline, serviceEndpoints, threadPriority,
112+
wrapperInfo, UUID.randomUUID().toString());
113+
}
114+
73115
/**
74116
* Copy constructor.
75-
*
117+
*
76118
* @param copyFrom the instance to copy from
77119
*/
78120
protected ClientContext(ClientContext copyFrom) {
79121
this(copyFrom.sdkKey, copyFrom.applicationInfo, copyFrom.http, copyFrom.logging,
80-
copyFrom.offline, copyFrom.serviceEndpoints, copyFrom.threadPriority, copyFrom.wrapperInfo);
122+
copyFrom.offline, copyFrom.serviceEndpoints, copyFrom.threadPriority, copyFrom.wrapperInfo,
123+
copyFrom.instanceId);
81124
}
82125

83126
/**
@@ -86,20 +129,32 @@ protected ClientContext(ClientContext copyFrom) {
86129
* @param sdkKey the SDK key
87130
*/
88131
public ClientContext(String sdkKey) {
132+
this(sdkKey, UUID.randomUUID().toString());
133+
}
134+
135+
// Private delegating constructor: generates a single instance id up front and threads it
136+
// both into the default HttpConfiguration (so the X-LaunchDarkly-Instance-Id default
137+
// header carries it) and into this.instanceId (so getInstanceId() returns the same value).
138+
// The earlier shape, where the public single-arg ctor called defaultHttp(sdkKey) and then
139+
// let the eight-arg ctor auto-generate a fresh UUID, produced two different ids -- one in
140+
// the headers and one returned by getInstanceId().
141+
private ClientContext(String sdkKey, String instanceId) {
89142
this(
90143
sdkKey,
91144
new ApplicationInfo(null, null),
92-
defaultHttp(sdkKey),
145+
defaultHttp(sdkKey, instanceId),
93146
defaultLogging(),
94147
false,
95148
Components.serviceEndpoints().createServiceEndpoints(),
96149
Thread.MIN_PRIORITY,
97-
null
150+
null,
151+
instanceId
98152
);
99153
}
100-
101-
private static HttpConfiguration defaultHttp(String sdkKey) {
102-
ClientContext minimalContext = new ClientContext(sdkKey, null, null, null, false, null, 0, null);
154+
155+
private static HttpConfiguration defaultHttp(String sdkKey, String instanceId) {
156+
ClientContext minimalContext = new ClientContext(sdkKey, null, null, null, false, null, 0, null,
157+
instanceId);
103158
return Components.httpConfiguration().build(minimalContext);
104159
}
105160

@@ -199,13 +254,24 @@ public ServiceEndpoints getServiceEndpoints() {
199254
/**
200255
* Returns the worker thread priority that is set by
201256
* {@link Builder#threadPriority(int)}.
202-
*
257+
*
203258
* @return the thread priority
204259
*/
205260
public int getThreadPriority() {
206261
return threadPriority;
207262
}
208263

264+
/**
265+
* Returns the per-LDClient instance identifier sent in the {@code X-LaunchDarkly-Instance-Id}
266+
* header on outbound requests. The value is generated once when the {@link ClientContext} is
267+
* constructed and is stable for the client's lifetime.
268+
*
269+
* @return the instance ID
270+
*/
271+
public String getInstanceId() {
272+
return instanceId;
273+
}
274+
209275
/**
210276
* Returns the wrapper information.
211277
*

lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,46 @@ public void packagePrivatePropertiesHaveDefaultsIfContextIsNotOurImplementation(
9696
// This covers a scenario where a user has created their own ClientContext and it has been
9797
// passed to one of our SDK components.
9898
ClientContext c = new ClientContext(SDK_KEY);
99-
99+
100100
ClientContextImpl impl = ClientContextImpl.get(c);
101-
101+
102102
assertNotNull(impl.sharedExecutor);
103103
assertNull(impl.diagnosticStore);
104-
104+
105105
ClientContextImpl impl2 = ClientContextImpl.get(c);
106-
106+
107107
assertNotNull(impl2.sharedExecutor);
108108
assertSame(impl.sharedExecutor, impl2.sharedExecutor);
109109
}
110+
111+
// Exercises the test-convenience constructor `new ClientContext(String)` that consumers of
112+
// the SDK use when wiring up custom data sources, stores, etc. Bugbot flagged that this
113+
// constructor produces an internally-inconsistent instance id: the headers carried by the
114+
// HttpConfiguration (built inside defaultHttp(sdkKey)) embed one UUID, while
115+
// getInstanceId() returns a different UUID generated by the delegated 8-arg constructor.
116+
@Test
117+
public void instanceIdIsConsistentBetweenAccessorAndDefaultHeaders() {
118+
ClientContext c = new ClientContext(SDK_KEY);
119+
120+
String accessorValue = c.getInstanceId();
121+
assertNotNull("ClientContext should expose a non-null instance id", accessorValue);
122+
123+
String headerValue = null;
124+
for (java.util.Map.Entry<String, String> entry : c.getHttp().getDefaultHeaders()) {
125+
if ("X-LaunchDarkly-Instance-Id".equalsIgnoreCase(entry.getKey())) {
126+
headerValue = entry.getValue();
127+
break;
128+
}
129+
}
130+
assertNotNull(
131+
"HttpConfiguration default headers should include X-LaunchDarkly-Instance-Id",
132+
headerValue);
133+
134+
assertEquals(
135+
"getInstanceId() should match the value attached to X-LaunchDarkly-Instance-Id on "
136+
+ "the default headers — otherwise outbound requests will not carry the same id "
137+
+ "that consumer code reads via getInstanceId().",
138+
accessorValue,
139+
headerValue);
140+
}
110141
}

lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,14 @@ public void ignoreEmptyFilter() throws Exception {
245245
private void verifyHeaders(RequestInfo req) {
246246
HttpConfiguration httpConfig = clientContext(sdkKey, LDConfig.DEFAULT).getHttp();
247247
for (Map.Entry<String, String> kv: httpConfig.getDefaultHeaders()) {
248+
// X-LaunchDarkly-Instance-Id is a per-HttpConfiguration random UUID, so the value generated
249+
// here won't match the one used by the requestor in the test. We only verify that *some*
250+
// instance ID header is present on the request; per-builder uniqueness is covered in
251+
// HttpConfigurationBuilderTest.
252+
if (kv.getKey().equals("X-LaunchDarkly-Instance-Id")) {
253+
assertNotNull(req.getHeader(kv.getKey()));
254+
continue;
255+
}
248256
assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue()));
249257
}
250258
}

lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import java.net.URI;
2525
import java.time.Duration;
2626
import java.util.Collections;
27+
import java.util.HashMap;
28+
import java.util.Map;
2729
import java.util.concurrent.atomic.AtomicBoolean;
2830

2931
import static com.launchdarkly.sdk.server.TestComponents.clientContext;
@@ -198,7 +200,14 @@ public void testHttpDefaults() {
198200
assertEquals(defaults.getSocketTimeout(), hc.getSocketTimeout());
199201
assertNull(hc.getSslSocketFactory());
200202
assertNull(hc.getTrustManager());
201-
assertEquals(ImmutableMap.copyOf(defaults.getDefaultHeaders()), ImmutableMap.copyOf(hc.getDefaultHeaders()));
203+
// The X-LaunchDarkly-Instance-Id header is a fresh UUID per HttpConfiguration, so it will
204+
// differ between the two configurations; compare the remaining headers and verify the
205+
// instance-id header is present on both.
206+
Map<String, String> defaultHeaders = new HashMap<>(ImmutableMap.copyOf(defaults.getDefaultHeaders()));
207+
Map<String, String> hcHeaders = new HashMap<>(ImmutableMap.copyOf(hc.getDefaultHeaders()));
208+
assertNotNull(defaultHeaders.remove("X-LaunchDarkly-Instance-Id"));
209+
assertNotNull(hcHeaders.remove("X-LaunchDarkly-Instance-Id"));
210+
assertEquals(defaultHeaders, hcHeaders);
202211
}
203212

204213
@Test

lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,13 @@ public void verifyStreamRequestProperties() throws Exception {
208208
assertThat(req.getPath(), equalTo("/all"));
209209

210210
for (Map.Entry<String, String> kv: httpConfig.getDefaultHeaders()) {
211+
// X-LaunchDarkly-Instance-Id is a per-HttpConfiguration random UUID and the
212+
// configuration here is a fresh build, distinct from the one used by the stream
213+
// processor; only assert presence.
214+
if (kv.getKey().equals("X-LaunchDarkly-Instance-Id")) {
215+
assertNotNull(req.getHeader(kv.getKey()));
216+
continue;
217+
}
211218
assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue()));
212219
}
213220
assertThat(req.getHeader("Accept"), equalTo("text/event-stream"));

0 commit comments

Comments
 (0)