From 3996e7e742d723869adc1d6c36ebf1a1d2a514cd Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 12 May 2026 16:31:11 -0400 Subject: [PATCH 1/3] feat: add X-LaunchDarkly-Instance-Id header to Node.js Server SDK (SDK-2358) Per SCMP-server-connection-minutes-polling section 1.1, server SDKs must send a per-instance v4 GUID on every polling request to enable Server Connection Minutes estimation. The GUID is generated once per SDK instance and stable for that instance's lifetime. We attach the GUID to the shared base headers in LDClientImpl (constructFDv1 and constructFDv2), so it rides on every outbound request -- polling, streaming, and events -- matching the cross-SDK contract tests. The shared defaultHeaders helper now accepts an optional instanceId parameter so client / edge SDKs are not affected. Section 1.3 (polling-interval header) is out of scope for this ticket. Changes: - packages/shared/common/src/utils/http.ts: extend LDHeaders and defaultHeaders to support an optional instanceId - packages/shared/sdk-server/src/LDClientImpl.ts: generate one UUID per client (via platform.crypto.randomUUID) and pass it to defaultHeaders - packages/sdk/server-node/contract-tests: register "instance-id" capability - Unit tests for both layers; updated bigSegments test mock crypto --- .../server-node/contract-tests/src/index.ts | 1 + packages/shared/common/src/utils/http.test.ts | 24 +++++ packages/shared/common/src/utils/http.ts | 11 +++ .../LDClientImpl.bigSegments.test.ts | 6 +- .../__tests__/LDClientImpl.instanceId.test.ts | 98 +++++++++++++++++++ .../shared/sdk-server/src/LDClientImpl.ts | 27 ++++- 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts 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/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.bigSegments.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts index e5ef9bc520..64174f9240 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts @@ -47,8 +47,10 @@ const crypto: Crypto = { throw new Error(`Function not implemented.${algorithm}${key}`); }, randomUUID(): string { - // Not used for this test. - throw new Error(`Function not implemented.`); + // Used by LDClientImpl to generate the per-instance X-LaunchDarkly-Instance-Id header + // (see SCMP-server-connection-minutes-polling). Big-segments tests don't assert on the + // value, so a stable stub is fine. + return '00000000-0000-4000-8000-000000000000'; }, }; 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..4b4091e19c --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts @@ -0,0 +1,98 @@ +import { LDClientImpl } from '../src'; +import { createBasicPlatform } from './createBasicPlatform'; +import TestLogger from './Logger'; +import makeCallbacks from './makeCallbacks'; + +// Per SCMP-server-connection-minutes-polling (spec section 1.1), the SDK must send +// `X-LaunchDarkly-Instance-Id: ` on every polling request, with one GUID per SDK +// instance and stable for that instance's lifetime. The server SDK attaches the GUID to the +// shared default-headers map so that it rides on every outbound request (streaming, polling, +// and events). +describe('LDClientImpl instance-id header', () => { + const fixedUuid = 'd3135edb-6531-4874-8a38-f0c9e556e836'; + + it('generates exactly one UUID per SDK instance during construction', () => { + 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, sendEvents: false }, + makeCallbacks(false), + ); + + // Exactly one randomUUID call is expected -- a single GUID per SDK instance, + // captured during construction. + expect(platform.crypto.randomUUID).toHaveBeenCalledTimes(1); + + client.close(); + }); + + it('attaches the X-LaunchDarkly-Instance-Id header to event requests', async () => { + const platform = createBasicPlatform(); + platform.crypto.randomUUID.mockReturnValueOnce(fixedUuid); + 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.objectContaining({ + 'x-launchdarkly-instance-id': fixedUuid, + }), + }), + ); + + client.close(); + }); + + it('uses a different GUID for different SDK instances', () => { + const platformA = createBasicPlatform(); + const platformB = createBasicPlatform(); + platformA.crypto.randomUUID.mockReturnValueOnce('aaaa1111-aaaa-4aaa-aaaa-aaaaaaaaaaaa'); + platformB.crypto.randomUUID.mockReturnValueOnce('bbbb2222-bbbb-4bbb-bbbb-bbbbbbbbbbbb'); + platformA.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 200, headers: new Headers() }), + ); + platformB.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 200, headers: new Headers() }), + ); + + const clientA = new LDClientImpl( + 'sdk-key-instance-id-a', + platformA, + { logger: new TestLogger(), stream: false, sendEvents: false }, + makeCallbacks(false), + ); + const clientB = new LDClientImpl( + 'sdk-key-instance-id-b', + platformB, + { logger: new TestLogger(), stream: false, sendEvents: false }, + makeCallbacks(false), + ); + + expect(platformA.crypto.randomUUID).toHaveBeenCalledTimes(1); + expect(platformB.crypto.randomUUID).toHaveBeenCalledTimes(1); + expect(platformA.crypto.randomUUID.mock.results[0].value).not.toEqual( + platformB.crypto.randomUUID.mock.results[0].value, + ); + + clientA.close(); + clientB.close(); + }); +}); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index f797e99c9b..30801ba12d 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -137,7 +137,20 @@ 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); + // Per SCMP-server-connection-minutes-polling (spec section 1.1), generate a single v4 GUID + // per SDK instance. We attach it to the default headers (rather than only on the poller) so + // that it is also present on streaming and event requests; baseHeaders is built once and + // shared across all subsystems for the lifetime of the client, which gives us the required + // "constant for the lifetime of the SDK instance" property for free. + const instanceId = platform.crypto.randomUUID(); + const baseHeaders = defaultHeaders( + sdkKey, + platform.info, + config.tags, + true, + 'user-agent', + instanceId, + ); const clientContext = new ClientContext(sdkKey, config, platform); const featureStore = config.featureStoreFactory(clientContext); @@ -268,7 +281,17 @@ function constructFDv2( } const { logger } = config; - const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags); + // Per SCMP-server-connection-minutes-polling (spec section 1.1), generate a single v4 GUID + // per SDK instance. See the matching comment in constructFDv1 above. + const instanceId = platform.crypto.randomUUID(); + 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 From 8fa11d7b34121bbe078583efdbadf1a6fb65ddd9 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 12 May 2026 17:26:27 -0400 Subject: [PATCH 2/3] fix: scope instance-id generation to Node server SDK only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (3996e7e7) called platform.crypto.randomUUID() from LDClientImpl.constructFDv1/constructFDv2, but LDClientImpl is also the base class for the Akamai, Cloudflare, Fastly, Vercel, and Shopify Oxygen edge SDKs. Those edge platforms either don't provide a working randomUUID() in their test mocks (Akamai) or shouldn't be advertising instance-id at all. This commit moves UUID generation to LDClientNode, which uses the Node platform's always-available crypto.randomUUID, and threads the generated value through ServerInternalOptions.instanceId. LDClientImpl attaches the header only when instanceId is supplied, leaving edge SDKs unaffected. Also reverts the workaround in the big-segments test crypto mock — no longer needed now that LDClientImpl doesn't call randomUUID on its own. Tests: - @launchdarkly/js-server-sdk-common: 989 passing (5 skipped) - @launchdarkly/node-server-sdk: 65 passing (+2 new instance-id tests) - @launchdarkly/akamai-server-base-sdk: 6 passing (previously 6 failing) - Lint clean --- .../__tests__/LDClientNode.instanceId.test.ts | 78 +++++++++++++++++++ packages/sdk/server-node/src/LDClientNode.ts | 10 ++- .../LDClientImpl.bigSegments.test.ts | 6 +- .../__tests__/LDClientImpl.instanceId.test.ts | 72 ++++++----------- .../shared/sdk-server/src/LDClientImpl.ts | 22 +++--- .../src/options/ServerInternalOptions.ts | 8 ++ 6 files changed, 132 insertions(+), 64 deletions(-) create mode 100644 packages/sdk/server-node/__tests__/LDClientNode.instanceId.test.ts 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/src/LDClientNode.ts b/packages/sdk/server-node/src/LDClientNode.ts index b48ec85ee4..4dcde3b0d0 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 section 1.1, 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/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts index 64174f9240..e5ef9bc520 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts @@ -47,10 +47,8 @@ const crypto: Crypto = { throw new Error(`Function not implemented.${algorithm}${key}`); }, randomUUID(): string { - // Used by LDClientImpl to generate the per-instance X-LaunchDarkly-Instance-Id header - // (see SCMP-server-connection-minutes-polling). Big-segments tests don't assert on the - // value, so a stable stub is fine. - return '00000000-0000-4000-8000-000000000000'; + // Not used for this test. + throw new Error(`Function not implemented.`); }, }; diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts index 4b4091e19c..1b6b228ac3 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.instanceId.test.ts @@ -3,15 +3,15 @@ import { createBasicPlatform } from './createBasicPlatform'; import TestLogger from './Logger'; import makeCallbacks from './makeCallbacks'; -// Per SCMP-server-connection-minutes-polling (spec section 1.1), the SDK must send -// `X-LaunchDarkly-Instance-Id: ` on every polling request, with one GUID per SDK -// instance and stable for that instance's lifetime. The server SDK attaches the GUID to the -// shared default-headers map so that it rides on every outbound request (streaming, polling, -// and events). +// 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('generates exactly one UUID per SDK instance during construction', () => { + 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() }), @@ -20,20 +20,29 @@ describe('LDClientImpl instance-id header', () => { const client = new LDClientImpl( 'sdk-key-instance-id-1', platform, - { logger: new TestLogger(), stream: false, sendEvents: false }, + { logger: new TestLogger(), stream: false }, makeCallbacks(false), + { instanceId: fixedUuid }, ); - // Exactly one randomUUID call is expected -- a single GUID per SDK instance, - // captured during construction. - expect(platform.crypto.randomUUID).toHaveBeenCalledTimes(1); + 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('attaches the X-LaunchDarkly-Instance-Id header to event requests', async () => { + it('omits the X-LaunchDarkly-Instance-Id header when no instanceId is supplied', async () => { const platform = createBasicPlatform(); - platform.crypto.randomUUID.mockReturnValueOnce(fixedUuid); platform.requests.fetch.mockImplementation(() => Promise.resolve({ status: 200, headers: new Headers() }), ); @@ -52,47 +61,12 @@ describe('LDClientImpl instance-id header', () => { expect(platform.requests.fetch).toHaveBeenCalledWith( 'https://events.launchdarkly.com/bulk', expect.objectContaining({ - headers: expect.objectContaining({ - 'x-launchdarkly-instance-id': fixedUuid, + headers: expect.not.objectContaining({ + 'x-launchdarkly-instance-id': expect.anything(), }), }), ); client.close(); }); - - it('uses a different GUID for different SDK instances', () => { - const platformA = createBasicPlatform(); - const platformB = createBasicPlatform(); - platformA.crypto.randomUUID.mockReturnValueOnce('aaaa1111-aaaa-4aaa-aaaa-aaaaaaaaaaaa'); - platformB.crypto.randomUUID.mockReturnValueOnce('bbbb2222-bbbb-4bbb-bbbb-bbbbbbbbbbbb'); - platformA.requests.fetch.mockImplementation(() => - Promise.resolve({ status: 200, headers: new Headers() }), - ); - platformB.requests.fetch.mockImplementation(() => - Promise.resolve({ status: 200, headers: new Headers() }), - ); - - const clientA = new LDClientImpl( - 'sdk-key-instance-id-a', - platformA, - { logger: new TestLogger(), stream: false, sendEvents: false }, - makeCallbacks(false), - ); - const clientB = new LDClientImpl( - 'sdk-key-instance-id-b', - platformB, - { logger: new TestLogger(), stream: false, sendEvents: false }, - makeCallbacks(false), - ); - - expect(platformA.crypto.randomUUID).toHaveBeenCalledTimes(1); - expect(platformB.crypto.randomUUID).toHaveBeenCalledTimes(1); - expect(platformA.crypto.randomUUID.mock.results[0].value).not.toEqual( - platformB.crypto.randomUUID.mock.results[0].value, - ); - - clientA.close(); - clientB.close(); - }); }); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 30801ba12d..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,12 +138,6 @@ function constructFDv1( throw new Error('You must configure the client with an SDK key'); } const { logger } = config; - // Per SCMP-server-connection-minutes-polling (spec section 1.1), generate a single v4 GUID - // per SDK instance. We attach it to the default headers (rather than only on the poller) so - // that it is also present on streaming and event requests; baseHeaders is built once and - // shared across all subsystems for the lifetime of the client, which gives us the required - // "constant for the lifetime of the SDK instance" property for free. - const instanceId = platform.crypto.randomUUID(); const baseHeaders = defaultHeaders( sdkKey, platform.info, @@ -258,6 +253,7 @@ function constructFDv2( callbacks: LDClientCallbacks, initSuccess: () => void, hooks: Hook[], + instanceId: string | undefined, ): { config: Configuration; logger: LDLogger | undefined; @@ -281,9 +277,6 @@ function constructFDv2( } const { logger } = config; - // Per SCMP-server-connection-minutes-polling (spec section 1.1), generate a single v4 GUID - // per SDK instance. See the matching comment in constructFDv1 above. - const instanceId = platform.crypto.randomUUID(); const baseHeaders = defaultHeaders( sdkKey, platform.info, @@ -627,6 +620,7 @@ export default class LDClientImpl implements LDClient { () => this._initSuccess(), (e) => this._dataSourceErrorHandler(e), hooks, + internalOptions?.instanceId, )); this.bigSegmentStatusProviderInternal = this._bigSegmentsManager @@ -656,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; } From 3f90b3a1ee65de6765567edbee7cafa4d542a8bd Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 13 May 2026 16:31:12 -0400 Subject: [PATCH 3/3] drop spec number --- packages/sdk/server-node/src/LDClientNode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/server-node/src/LDClientNode.ts b/packages/sdk/server-node/src/LDClientNode.ts index 4dcde3b0d0..9e8297a566 100644 --- a/packages/sdk/server-node/src/LDClientNode.ts +++ b/packages/sdk/server-node/src/LDClientNode.ts @@ -49,7 +49,7 @@ class LDClientNode extends LDClientImpl implements LDClient { delete baseOptions.plugins; const platform = new NodePlatform({ ...options, logger }); - // Per SCMP-server-connection-minutes-polling section 1.1, generate one v4 GUID per SDK + // 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.