diff --git a/packages/sdk/server-node/__tests__/LDClientNode.instanceId.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.instanceId.test.ts new file mode 100644 index 0000000000..11e61f24de --- /dev/null +++ b/packages/sdk/server-node/__tests__/LDClientNode.instanceId.test.ts @@ -0,0 +1,78 @@ +import { TestHttpHandlers, TestHttpServer } from 'launchdarkly-js-test-helpers'; + +import { basicLogger, LDLogger } from '../src'; +import LDClientNode from '../src/LDClientNode'; + +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; + +const allData = { flags: {}, segments: {} }; + +describe('LDClientNode X-LaunchDarkly-Instance-Id header', () => { + let logger: LDLogger; + let closeable: { close: () => void }[]; + + beforeEach(() => { + closeable = []; + logger = basicLogger({ destination: () => {} }); + }); + + afterEach(() => { + closeable.forEach((c) => c.close()); + }); + + it('sends a v4 UUID X-LaunchDarkly-Instance-Id header on polling requests', async () => { + const server = await TestHttpServer.start(); + server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData)); + + const client = new LDClientNode('sdk-key', { + baseUri: server.url, + stream: false, + sendEvents: false, + logger, + }); + + closeable.push(server, client); + + await client.waitForInitialization({ timeout: 10 }); + expect(client.initialized()).toBe(true); + + const req = await server.nextRequest(); + const headerValue = req.headers['x-launchdarkly-instance-id']; + expect(headerValue).toMatch(UUID_V4_RE); + }); + + it('uses a different UUID for different SDK instances', async () => { + const serverA = await TestHttpServer.start(); + const serverB = await TestHttpServer.start(); + serverA.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData)); + serverB.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData)); + + const clientA = new LDClientNode('sdk-key-a', { + baseUri: serverA.url, + stream: false, + sendEvents: false, + logger, + }); + const clientB = new LDClientNode('sdk-key-b', { + baseUri: serverB.url, + stream: false, + sendEvents: false, + logger, + }); + + closeable.push(serverA, serverB, clientA, clientB); + + await clientA.waitForInitialization({ timeout: 10 }); + await clientB.waitForInitialization({ timeout: 10 }); + + const reqA = await serverA.nextRequest(); + const reqB = await serverB.nextRequest(); + + const headerA = reqA.headers['x-launchdarkly-instance-id']; + const headerB = reqB.headers['x-launchdarkly-instance-id']; + + expect(headerA).toMatch(UUID_V4_RE); + expect(headerB).toMatch(UUID_V4_RE); + expect(headerA).not.toEqual(headerB); + }); +}); diff --git a/packages/sdk/server-node/contract-tests/src/index.ts b/packages/sdk/server-node/contract-tests/src/index.ts index 64bc1d813d..4f05b285ce 100644 --- a/packages/sdk/server-node/contract-tests/src/index.ts +++ b/packages/sdk/server-node/contract-tests/src/index.ts @@ -45,6 +45,7 @@ app.get('/', (req: Request, res: Response) => { 'optional-event-gzip', 'flag-change-listeners', 'fdv1-fallback', + 'instance-id', ], }); }); diff --git a/packages/sdk/server-node/src/LDClientNode.ts b/packages/sdk/server-node/src/LDClientNode.ts index b48ec85ee4..9e8297a566 100644 --- a/packages/sdk/server-node/src/LDClientNode.ts +++ b/packages/sdk/server-node/src/LDClientNode.ts @@ -48,9 +48,16 @@ class LDClientNode extends LDClientImpl implements LDClient { const baseOptions = { ...options, logger }; delete baseOptions.plugins; + const platform = new NodePlatform({ ...options, logger }); + // Per SCMP-server-connection-minutes-polling, generate one v4 GUID per SDK + // instance and pass it through `internalOptions` so LDClientImpl can attach it as the + // `X-LaunchDarkly-Instance-Id` default header. Generation happens here (not in + // LDClientImpl) so edge SDKs that share LDClientImpl do not advertise instance-id. + const instanceId = platform.crypto.randomUUID(); + super( sdkKey, - new NodePlatform({ ...options, logger }), + platform, baseOptions, { onError: (err: Error) => { @@ -81,6 +88,7 @@ class LDClientNode extends LDClientImpl implements LDClient { { getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => internal.safeGetHooks(logger, environmentMetadata, plugins), + instanceId, }, ); diff --git a/packages/shared/common/src/utils/http.test.ts b/packages/shared/common/src/utils/http.test.ts index fde1cabafd..de8e1e5dba 100644 --- a/packages/shared/common/src/utils/http.test.ts +++ b/packages/shared/common/src/utils/http.test.ts @@ -64,6 +64,30 @@ describe('defaultHeaders', () => { 'x-launchdarkly-tags': 'application-id/test-application application-version/test-version', }); }); + + it('does not include the instance-id header by default', () => { + const h = defaultHeaders('my-sdk-key', makeInfo()); + expect(h['x-launchdarkly-instance-id']).toBeUndefined(); + }); + + it('sets the X-LaunchDarkly-Instance-Id header when an instance id is supplied', () => { + const h = defaultHeaders( + 'my-sdk-key', + makeInfo(), + undefined, + true, + 'user-agent', + 'd3135edb-6531-4874-8a38-f0c9e556e836', + ); + expect(h).toMatchObject({ + 'x-launchdarkly-instance-id': 'd3135edb-6531-4874-8a38-f0c9e556e836', + }); + }); + + it('omits the X-LaunchDarkly-Instance-Id header when an empty instance id is supplied', () => { + const h = defaultHeaders('my-sdk-key', makeInfo(), undefined, true, 'user-agent', ''); + expect(h['x-launchdarkly-instance-id']).toBeUndefined(); + }); }); describe('httpErrorMessage', () => { diff --git a/packages/shared/common/src/utils/http.ts b/packages/shared/common/src/utils/http.ts index 347c63f4eb..57530bf0e2 100644 --- a/packages/shared/common/src/utils/http.ts +++ b/packages/shared/common/src/utils/http.ts @@ -8,6 +8,7 @@ export type LDHeaders = { 'x-launchdarkly-user-agent'?: string; 'x-launchdarkly-wrapper'?: string; 'x-launchdarkly-tags'?: string; + 'x-launchdarkly-instance-id'?: string; }; export function defaultHeaders( @@ -16,6 +17,7 @@ export function defaultHeaders( tags?: ApplicationTags, includeAuthorizationHeader: boolean = true, userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent' = 'user-agent', + instanceId?: string, ): LDHeaders { const { userAgentBase, version, wrapperName, wrapperVersion } = info.sdkData(); @@ -39,6 +41,15 @@ export function defaultHeaders( headers['x-launchdarkly-tags'] = tags.value; } + // Per SCMP-server-connection-minutes-polling (spec section 1.1), server SDKs include a + // per-instance v4 GUID on every polling request. The caller (a server SDK) is responsible + // for generating the GUID once per SDK instance and passing it here, so that it rides on + // every outbound request via this shared default-headers map. Client/edge SDKs do not pass + // this value, so the header is omitted for them. + if (instanceId) { + headers['x-launchdarkly-instance-id'] = instanceId; + } + return headers; } diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts new file mode 100644 index 0000000000..1b6b228ac3 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts @@ -0,0 +1,72 @@ +import { LDClientImpl } from '../src'; +import { createBasicPlatform } from './createBasicPlatform'; +import TestLogger from './Logger'; +import makeCallbacks from './makeCallbacks'; + +// When `internalOptions.instanceId` is supplied, the SDK must attach +// `X-LaunchDarkly-Instance-Id` to every outbound request using that value. When it is +// omitted, the header must not be attached. The platform SDK that owns instance-id +// generation (e.g. the Node server SDK) is responsible for producing the GUID and +// passing it through `internalOptions`. +describe('LDClientImpl instance-id header', () => { + const fixedUuid = 'd3135edb-6531-4874-8a38-f0c9e556e836'; + + it('attaches the X-LaunchDarkly-Instance-Id header when internalOptions.instanceId is set', async () => { + const platform = createBasicPlatform(); + platform.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 200, headers: new Headers() }), + ); + + const client = new LDClientImpl( + 'sdk-key-instance-id-1', + platform, + { logger: new TestLogger(), stream: false }, + makeCallbacks(false), + { instanceId: fixedUuid }, + ); + + client.identify({ key: 'user' }); + client.variation('dev-test-flag', { key: 'user' }, false); + await client.flush(); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/bulk', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-launchdarkly-instance-id': fixedUuid, + }), + }), + ); + + client.close(); + }); + + it('omits the X-LaunchDarkly-Instance-Id header when no instanceId is supplied', async () => { + const platform = createBasicPlatform(); + platform.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 200, headers: new Headers() }), + ); + + const client = new LDClientImpl( + 'sdk-key-instance-id-2', + platform, + { logger: new TestLogger(), stream: false }, + makeCallbacks(false), + ); + + client.identify({ key: 'user' }); + client.variation('dev-test-flag', { key: 'user' }, false); + await client.flush(); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/bulk', + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'x-launchdarkly-instance-id': expect.anything(), + }), + }), + ); + + client.close(); + }); +}); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index f797e99c9b..fbbc15b2b8 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -116,6 +116,7 @@ function constructFDv1( initSuccess: () => void, dataSourceErrorHandler: (e: any) => void, hooks: Hook[], + instanceId: string | undefined, ): { config: Configuration; logger: LDLogger | undefined; @@ -137,7 +138,14 @@ function constructFDv1( throw new Error('You must configure the client with an SDK key'); } const { logger } = config; - const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags); + const baseHeaders = defaultHeaders( + sdkKey, + platform.info, + config.tags, + true, + 'user-agent', + instanceId, + ); const clientContext = new ClientContext(sdkKey, config, platform); const featureStore = config.featureStoreFactory(clientContext); @@ -245,6 +253,7 @@ function constructFDv2( callbacks: LDClientCallbacks, initSuccess: () => void, hooks: Hook[], + instanceId: string | undefined, ): { config: Configuration; logger: LDLogger | undefined; @@ -268,7 +277,14 @@ function constructFDv2( } const { logger } = config; - const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags); + const baseHeaders = defaultHeaders( + sdkKey, + platform.info, + config.tags, + true, + 'user-agent', + instanceId, + ); const clientContext = new ClientContext(sdkKey, config, platform); const dataSystem = config.dataSystem!; // dataSystem must be defined to get into this construct function @@ -604,6 +620,7 @@ export default class LDClientImpl implements LDClient { () => this._initSuccess(), (e) => this._dataSourceErrorHandler(e), hooks, + internalOptions?.instanceId, )); this.bigSegmentStatusProviderInternal = this._bigSegmentsManager @@ -633,7 +650,15 @@ export default class LDClientImpl implements LDClient { onError: this._onError, onFailed: this._onFailed, onReady: this._onReady, - } = constructFDv2(_sdkKey, _platform, config, callbacks, () => this._initSuccess(), hooks)); + } = constructFDv2( + _sdkKey, + _platform, + config, + callbacks, + () => this._initSuccess(), + hooks, + internalOptions?.instanceId, + )); this._featureStore = transactionalStore; this.bigSegmentStatusProviderInternal = this._bigSegmentsManager .statusProvider as BigSegmentStoreStatusProvider; diff --git a/packages/shared/sdk-server/src/options/ServerInternalOptions.ts b/packages/shared/sdk-server/src/options/ServerInternalOptions.ts index dd1de40357..9c571f8672 100644 --- a/packages/shared/sdk-server/src/options/ServerInternalOptions.ts +++ b/packages/shared/sdk-server/src/options/ServerInternalOptions.ts @@ -4,4 +4,12 @@ import { Hook } from '../integrations'; export interface ServerInternalOptions extends internal.LDInternalOptions { getImplementationHooks?: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[]; + + /** + * Per-SDK-instance identifier sent as the `X-LaunchDarkly-Instance-Id` header on every + * outbound request. The SDK that owns instance-id generation (e.g. the Node server SDK) + * supplies this; SDKs that do not advertise instance-id support (edge SDKs) leave it + * undefined and the header is omitted. + */ + instanceId?: string; }