Skip to content

Commit 814dc0b

Browse files
authored
feat: add X-LaunchDarkly-Instance-Id header to server-node SDK (SDK-2358) (#1377)
1 parent 0bafcbf commit 814dc0b

8 files changed

Lines changed: 231 additions & 4 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { TestHttpHandlers, TestHttpServer } from 'launchdarkly-js-test-helpers';
2+
3+
import { basicLogger, LDLogger } from '../src';
4+
import LDClientNode from '../src/LDClientNode';
5+
6+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7+
8+
const allData = { flags: {}, segments: {} };
9+
10+
describe('LDClientNode X-LaunchDarkly-Instance-Id header', () => {
11+
let logger: LDLogger;
12+
let closeable: { close: () => void }[];
13+
14+
beforeEach(() => {
15+
closeable = [];
16+
logger = basicLogger({ destination: () => {} });
17+
});
18+
19+
afterEach(() => {
20+
closeable.forEach((c) => c.close());
21+
});
22+
23+
it('sends a v4 UUID X-LaunchDarkly-Instance-Id header on polling requests', async () => {
24+
const server = await TestHttpServer.start();
25+
server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData));
26+
27+
const client = new LDClientNode('sdk-key', {
28+
baseUri: server.url,
29+
stream: false,
30+
sendEvents: false,
31+
logger,
32+
});
33+
34+
closeable.push(server, client);
35+
36+
await client.waitForInitialization({ timeout: 10 });
37+
expect(client.initialized()).toBe(true);
38+
39+
const req = await server.nextRequest();
40+
const headerValue = req.headers['x-launchdarkly-instance-id'];
41+
expect(headerValue).toMatch(UUID_V4_RE);
42+
});
43+
44+
it('uses a different UUID for different SDK instances', async () => {
45+
const serverA = await TestHttpServer.start();
46+
const serverB = await TestHttpServer.start();
47+
serverA.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData));
48+
serverB.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData));
49+
50+
const clientA = new LDClientNode('sdk-key-a', {
51+
baseUri: serverA.url,
52+
stream: false,
53+
sendEvents: false,
54+
logger,
55+
});
56+
const clientB = new LDClientNode('sdk-key-b', {
57+
baseUri: serverB.url,
58+
stream: false,
59+
sendEvents: false,
60+
logger,
61+
});
62+
63+
closeable.push(serverA, serverB, clientA, clientB);
64+
65+
await clientA.waitForInitialization({ timeout: 10 });
66+
await clientB.waitForInitialization({ timeout: 10 });
67+
68+
const reqA = await serverA.nextRequest();
69+
const reqB = await serverB.nextRequest();
70+
71+
const headerA = reqA.headers['x-launchdarkly-instance-id'];
72+
const headerB = reqB.headers['x-launchdarkly-instance-id'];
73+
74+
expect(headerA).toMatch(UUID_V4_RE);
75+
expect(headerB).toMatch(UUID_V4_RE);
76+
expect(headerA).not.toEqual(headerB);
77+
});
78+
});

packages/sdk/server-node/contract-tests/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ app.get('/', (req: Request, res: Response) => {
4545
'optional-event-gzip',
4646
'flag-change-listeners',
4747
'fdv1-fallback',
48+
'instance-id',
4849
],
4950
});
5051
});

packages/sdk/server-node/src/LDClientNode.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,16 @@ class LDClientNode extends LDClientImpl implements LDClient {
4848
const baseOptions = { ...options, logger };
4949
delete baseOptions.plugins;
5050

51+
const platform = new NodePlatform({ ...options, logger });
52+
// Per SCMP-server-connection-minutes-polling, generate one v4 GUID per SDK
53+
// instance and pass it through `internalOptions` so LDClientImpl can attach it as the
54+
// `X-LaunchDarkly-Instance-Id` default header. Generation happens here (not in
55+
// LDClientImpl) so edge SDKs that share LDClientImpl do not advertise instance-id.
56+
const instanceId = platform.crypto.randomUUID();
57+
5158
super(
5259
sdkKey,
53-
new NodePlatform({ ...options, logger }),
60+
platform,
5461
baseOptions,
5562
{
5663
onError: (err: Error) => {
@@ -81,6 +88,7 @@ class LDClientNode extends LDClientImpl implements LDClient {
8188
{
8289
getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) =>
8390
internal.safeGetHooks(logger, environmentMetadata, plugins),
91+
instanceId,
8492
},
8593
);
8694

packages/shared/common/src/utils/http.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ describe('defaultHeaders', () => {
6464
'x-launchdarkly-tags': 'application-id/test-application application-version/test-version',
6565
});
6666
});
67+
68+
it('does not include the instance-id header by default', () => {
69+
const h = defaultHeaders('my-sdk-key', makeInfo());
70+
expect(h['x-launchdarkly-instance-id']).toBeUndefined();
71+
});
72+
73+
it('sets the X-LaunchDarkly-Instance-Id header when an instance id is supplied', () => {
74+
const h = defaultHeaders(
75+
'my-sdk-key',
76+
makeInfo(),
77+
undefined,
78+
true,
79+
'user-agent',
80+
'd3135edb-6531-4874-8a38-f0c9e556e836',
81+
);
82+
expect(h).toMatchObject({
83+
'x-launchdarkly-instance-id': 'd3135edb-6531-4874-8a38-f0c9e556e836',
84+
});
85+
});
86+
87+
it('omits the X-LaunchDarkly-Instance-Id header when an empty instance id is supplied', () => {
88+
const h = defaultHeaders('my-sdk-key', makeInfo(), undefined, true, 'user-agent', '');
89+
expect(h['x-launchdarkly-instance-id']).toBeUndefined();
90+
});
6791
});
6892

6993
describe('httpErrorMessage', () => {

packages/shared/common/src/utils/http.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type LDHeaders = {
88
'x-launchdarkly-user-agent'?: string;
99
'x-launchdarkly-wrapper'?: string;
1010
'x-launchdarkly-tags'?: string;
11+
'x-launchdarkly-instance-id'?: string;
1112
};
1213

1314
export function defaultHeaders(
@@ -16,6 +17,7 @@ export function defaultHeaders(
1617
tags?: ApplicationTags,
1718
includeAuthorizationHeader: boolean = true,
1819
userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent' = 'user-agent',
20+
instanceId?: string,
1921
): LDHeaders {
2022
const { userAgentBase, version, wrapperName, wrapperVersion } = info.sdkData();
2123

@@ -39,6 +41,15 @@ export function defaultHeaders(
3941
headers['x-launchdarkly-tags'] = tags.value;
4042
}
4143

44+
// Per SCMP-server-connection-minutes-polling (spec section 1.1), server SDKs include a
45+
// per-instance v4 GUID on every polling request. The caller (a server SDK) is responsible
46+
// for generating the GUID once per SDK instance and passing it here, so that it rides on
47+
// every outbound request via this shared default-headers map. Client/edge SDKs do not pass
48+
// this value, so the header is omitted for them.
49+
if (instanceId) {
50+
headers['x-launchdarkly-instance-id'] = instanceId;
51+
}
52+
4253
return headers;
4354
}
4455

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { LDClientImpl } from '../src';
2+
import { createBasicPlatform } from './createBasicPlatform';
3+
import TestLogger from './Logger';
4+
import makeCallbacks from './makeCallbacks';
5+
6+
// When `internalOptions.instanceId` is supplied, the SDK must attach
7+
// `X-LaunchDarkly-Instance-Id` to every outbound request using that value. When it is
8+
// omitted, the header must not be attached. The platform SDK that owns instance-id
9+
// generation (e.g. the Node server SDK) is responsible for producing the GUID and
10+
// passing it through `internalOptions`.
11+
describe('LDClientImpl instance-id header', () => {
12+
const fixedUuid = 'd3135edb-6531-4874-8a38-f0c9e556e836';
13+
14+
it('attaches the X-LaunchDarkly-Instance-Id header when internalOptions.instanceId is set', async () => {
15+
const platform = createBasicPlatform();
16+
platform.requests.fetch.mockImplementation(() =>
17+
Promise.resolve({ status: 200, headers: new Headers() }),
18+
);
19+
20+
const client = new LDClientImpl(
21+
'sdk-key-instance-id-1',
22+
platform,
23+
{ logger: new TestLogger(), stream: false },
24+
makeCallbacks(false),
25+
{ instanceId: fixedUuid },
26+
);
27+
28+
client.identify({ key: 'user' });
29+
client.variation('dev-test-flag', { key: 'user' }, false);
30+
await client.flush();
31+
32+
expect(platform.requests.fetch).toHaveBeenCalledWith(
33+
'https://events.launchdarkly.com/bulk',
34+
expect.objectContaining({
35+
headers: expect.objectContaining({
36+
'x-launchdarkly-instance-id': fixedUuid,
37+
}),
38+
}),
39+
);
40+
41+
client.close();
42+
});
43+
44+
it('omits the X-LaunchDarkly-Instance-Id header when no instanceId is supplied', async () => {
45+
const platform = createBasicPlatform();
46+
platform.requests.fetch.mockImplementation(() =>
47+
Promise.resolve({ status: 200, headers: new Headers() }),
48+
);
49+
50+
const client = new LDClientImpl(
51+
'sdk-key-instance-id-2',
52+
platform,
53+
{ logger: new TestLogger(), stream: false },
54+
makeCallbacks(false),
55+
);
56+
57+
client.identify({ key: 'user' });
58+
client.variation('dev-test-flag', { key: 'user' }, false);
59+
await client.flush();
60+
61+
expect(platform.requests.fetch).toHaveBeenCalledWith(
62+
'https://events.launchdarkly.com/bulk',
63+
expect.objectContaining({
64+
headers: expect.not.objectContaining({
65+
'x-launchdarkly-instance-id': expect.anything(),
66+
}),
67+
}),
68+
);
69+
70+
client.close();
71+
});
72+
});

packages/shared/sdk-server/src/LDClientImpl.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ function constructFDv1(
116116
initSuccess: () => void,
117117
dataSourceErrorHandler: (e: any) => void,
118118
hooks: Hook[],
119+
instanceId: string | undefined,
119120
): {
120121
config: Configuration;
121122
logger: LDLogger | undefined;
@@ -137,7 +138,14 @@ function constructFDv1(
137138
throw new Error('You must configure the client with an SDK key');
138139
}
139140
const { logger } = config;
140-
const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags);
141+
const baseHeaders = defaultHeaders(
142+
sdkKey,
143+
platform.info,
144+
config.tags,
145+
true,
146+
'user-agent',
147+
instanceId,
148+
);
141149

142150
const clientContext = new ClientContext(sdkKey, config, platform);
143151
const featureStore = config.featureStoreFactory(clientContext);
@@ -245,6 +253,7 @@ function constructFDv2(
245253
callbacks: LDClientCallbacks,
246254
initSuccess: () => void,
247255
hooks: Hook[],
256+
instanceId: string | undefined,
248257
): {
249258
config: Configuration;
250259
logger: LDLogger | undefined;
@@ -268,7 +277,14 @@ function constructFDv2(
268277
}
269278

270279
const { logger } = config;
271-
const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags);
280+
const baseHeaders = defaultHeaders(
281+
sdkKey,
282+
platform.info,
283+
config.tags,
284+
true,
285+
'user-agent',
286+
instanceId,
287+
);
272288

273289
const clientContext = new ClientContext(sdkKey, config, platform);
274290
const dataSystem = config.dataSystem!; // dataSystem must be defined to get into this construct function
@@ -604,6 +620,7 @@ export default class LDClientImpl implements LDClient {
604620
() => this._initSuccess(),
605621
(e) => this._dataSourceErrorHandler(e),
606622
hooks,
623+
internalOptions?.instanceId,
607624
));
608625

609626
this.bigSegmentStatusProviderInternal = this._bigSegmentsManager
@@ -633,7 +650,15 @@ export default class LDClientImpl implements LDClient {
633650
onError: this._onError,
634651
onFailed: this._onFailed,
635652
onReady: this._onReady,
636-
} = constructFDv2(_sdkKey, _platform, config, callbacks, () => this._initSuccess(), hooks));
653+
} = constructFDv2(
654+
_sdkKey,
655+
_platform,
656+
config,
657+
callbacks,
658+
() => this._initSuccess(),
659+
hooks,
660+
internalOptions?.instanceId,
661+
));
637662
this._featureStore = transactionalStore;
638663
this.bigSegmentStatusProviderInternal = this._bigSegmentsManager
639664
.statusProvider as BigSegmentStoreStatusProvider;

packages/shared/sdk-server/src/options/ServerInternalOptions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,12 @@ import { Hook } from '../integrations';
44

55
export interface ServerInternalOptions extends internal.LDInternalOptions {
66
getImplementationHooks?: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[];
7+
8+
/**
9+
* Per-SDK-instance identifier sent as the `X-LaunchDarkly-Instance-Id` header on every
10+
* outbound request. The SDK that owns instance-id generation (e.g. the Node server SDK)
11+
* supplies this; SDKs that do not advertise instance-id support (edge SDKs) leave it
12+
* undefined and the header is omitted.
13+
*/
14+
instanceId?: string;
715
}

0 commit comments

Comments
 (0)