From 528b9e0aa7af2da10d1311195914c74023289f8f Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 12 May 2026 16:20:38 -0400 Subject: [PATCH 1/3] feat: add X-LaunchDarkly-Instance-Id header (SDK-2351) Generate a v4 UUID once per SDK instance in HttpConfigurationBuilder.Build and stamp it on the default headers. Because those headers are shared by the stream, poll, and event clients, every outbound request carries the same stable per-instance identifier without per-channel plumbing. The instance id is applied before user-supplied custom headers so custom headers can override it, matching the convention used for the User-Agent and Authorization headers (and the other LaunchDarkly SDKs). Registers the "instance-id" capability with the contract test service so the cross-SDK harness can verify the header on stream, poll, and event requests. --- pkgs/sdk/server/contract-tests/TestService.cs | 3 +- .../Integrations/HttpConfigurationBuilder.cs | 21 ++++++++- .../HttpConfigurationBuilderTest.cs | 44 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pkgs/sdk/server/contract-tests/TestService.cs b/pkgs/sdk/server/contract-tests/TestService.cs index 13f8df7a..77bd2576 100644 --- a/pkgs/sdk/server/contract-tests/TestService.cs +++ b/pkgs/sdk/server/contract-tests/TestService.cs @@ -39,7 +39,8 @@ public class Webapp "anonymous-redaction", "evaluation-hooks", "client-prereq-events", - "fdv1-fallback" + "fdv1-fallback", + "instance-id" }; public readonly Handler Handler; diff --git a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs index 1ba2e319..554cb5b1 100644 --- a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs +++ b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs @@ -33,6 +33,16 @@ namespace LaunchDarkly.Sdk.Server.Integrations /// public sealed class HttpConfigurationBuilder : IComponentConfigurer, IDiagnosticDescription { + /// + /// The 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. + /// + internal const string InstanceIdHeader = "X-LaunchDarkly-Instance-Id"; + /// /// The default value for : two seconds. /// @@ -256,8 +266,17 @@ private HttpProperties MakeHttpProperties(LdClientContext context) .WithReadTimeout(_readTimeout) .WithUserAgent("DotNetClient/" + AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient))) .WithApplicationTags(context.ApplicationInfo) - .WithWrapper(wrapperName, wrapperVersion); + .WithWrapper(wrapperName, wrapperVersion) + // Per SCMP-server-connection-minutes-polling, every polling request must carry a + // per-instance GUID v4. We attach it to the default headers (rather than only on + // the poller) so that it is also present on streaming and event requests; this + // matches the cross-SDK contract tests and keeps the GUID stable for the lifetime + // of the SDK instance, since the headers are built once and never modified after + // construction. Guid.NewGuid() produces a v4 (random) UUID on .NET. + .WithHeader(InstanceIdHeader, Guid.NewGuid().ToString()); + // For consistency with other SDKs, custom headers are allowed to overwrite headers + // such as User-Agent, Authorization, and the instance id. return _customHeaders.Aggregate(httpProperties, (current, kv) => current.WithHeader(kv.Key, kv.Value)); } diff --git a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs index d7f1c2fd..d4d2d002 100644 --- a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs +++ b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs @@ -158,6 +158,50 @@ public void WrapperInfoOverwritesHttpConfiguration() Assert.Equal("my-wrapper/3.14", HeadersAsMap(config.DefaultHeaders)["x-launchdarkly-wrapper"]); } + [Fact] + public void InstanceIdHeaderIsPresentAndIsUuidV4() + { + var config = Components.HttpConfiguration().Build(basicConfig); + var headers = HeadersAsMap(config.DefaultHeaders); + Assert.True(headers.ContainsKey("x-launchdarkly-instance-id"), + "X-LaunchDarkly-Instance-Id header must be set"); + + var raw = headers["x-launchdarkly-instance-id"]; + Assert.True(Guid.TryParse(raw, out var parsed), + $"instance id '{raw}' must be a parseable GUID"); + + // The "M" (version) nibble of a v4 UUID is 0x4. In the canonical 8-4-4-4-12 form, + // that is the first character of the third group. + var groups = raw.Split('-'); + Assert.Equal(5, groups.Length); + Assert.Equal('4', groups[2][0]); + } + + [Fact] + public void InstanceIdHeaderIsUniquePerSdkInstance() + { + // Each call to Build represents a new SDK instance; each must get its own GUID. + var config1 = Components.HttpConfiguration().Build(basicConfig); + var config2 = Components.HttpConfiguration().Build(basicConfig); + var id1 = HeadersAsMap(config1.DefaultHeaders)["x-launchdarkly-instance-id"]; + var id2 = HeadersAsMap(config2.DefaultHeaders)["x-launchdarkly-instance-id"]; + Assert.False(string.IsNullOrEmpty(id1)); + Assert.False(string.IsNullOrEmpty(id2)); + Assert.NotEqual(id1, id2); + } + + [Fact] + public void CustomHeaderCanOverrideInstanceIdHeader() + { + // Consistent with User-Agent / Authorization: a user-supplied custom header for the + // same name takes precedence. This mirrors the behavior in other SDKs. + var config = Components.HttpConfiguration() + .CustomHeader("X-LaunchDarkly-Instance-Id", "custom-override") + .Build(basicConfig); + Assert.Equal("custom-override", + HeadersAsMap(config.DefaultHeaders)["x-launchdarkly-instance-id"]); + } + private Dictionary HeadersAsMap(IEnumerable> headers) { return headers.ToDictionary(kv => kv.Key.ToLower(), kv => kv.Value); From ea77df63a2789940245393856fcf1719a7e9417d Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 13 May 2026 15:59:30 -0400 Subject: [PATCH 2/3] fix: Stabilize instance id across builder calls Generate the X-LaunchDarkly-Instance-Id GUID once at builder construction instead of regenerating it on each MakeHttpProperties call, so that Build() and DescribeConfiguration() (both invoked per SDK instance) see the same value. --- .../Integrations/HttpConfigurationBuilder.cs | 22 ++++++++----------- .../HttpConfigurationBuilderTest.cs | 12 ++++++++++ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs index 554cb5b1..e52eb60f 100644 --- a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs +++ b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs @@ -34,13 +34,10 @@ namespace LaunchDarkly.Sdk.Server.Integrations public sealed class HttpConfigurationBuilder : IComponentConfigurer, IDiagnosticDescription { /// - /// The 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. + /// The HTTP header used to identify this SDK instance. Its value is a v4 UUID that is + /// generated once per SDK instance and remains constant for the lifetime of the client, + /// and is sent on polling, streaming, and event requests. /// - /// - /// See: sdk-specs / SCMP-server-connection-minutes-polling. - /// internal const string InstanceIdHeader = "X-LaunchDarkly-Instance-Id"; /// @@ -67,6 +64,11 @@ public sealed class HttpConfigurationBuilder : IComponentConfigurer /// Sets the network connection timeout. /// @@ -267,13 +269,7 @@ private HttpProperties MakeHttpProperties(LdClientContext context) .WithUserAgent("DotNetClient/" + AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient))) .WithApplicationTags(context.ApplicationInfo) .WithWrapper(wrapperName, wrapperVersion) - // Per SCMP-server-connection-minutes-polling, every polling request must carry a - // per-instance GUID v4. We attach it to the default headers (rather than only on - // the poller) so that it is also present on streaming and event requests; this - // matches the cross-SDK contract tests and keeps the GUID stable for the lifetime - // of the SDK instance, since the headers are built once and never modified after - // construction. Guid.NewGuid() produces a v4 (random) UUID on .NET. - .WithHeader(InstanceIdHeader, Guid.NewGuid().ToString()); + .WithHeader(InstanceIdHeader, _instanceId); // For consistency with other SDKs, custom headers are allowed to overwrite headers // such as User-Agent, Authorization, and the instance id. diff --git a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs index d4d2d002..a0746b82 100644 --- a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs +++ b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs @@ -190,6 +190,18 @@ public void InstanceIdHeaderIsUniquePerSdkInstance() Assert.NotEqual(id1, id2); } + [Fact] + public void InstanceIdHeaderIsStableAcrossBuildsOnSameBuilder() + { + // A single builder is reused by both Build() and DescribeConfiguration() against the + // same SDK instance, so the generated GUID must be fixed at construction rather than + // regenerated on each call. + var builder = Components.HttpConfiguration(); + var id1 = HeadersAsMap(builder.Build(basicConfig).DefaultHeaders)["x-launchdarkly-instance-id"]; + var id2 = HeadersAsMap(builder.Build(basicConfig).DefaultHeaders)["x-launchdarkly-instance-id"]; + Assert.Equal(id1, id2); + } + [Fact] public void CustomHeaderCanOverrideInstanceIdHeader() { From 24a2bdf35c79a0903b1a36b1bc8faa932280bdb8 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 19 May 2026 12:01:00 -0400 Subject: [PATCH 3/3] refactor: Move instance-id generation to LdClientContext Storing the X-LaunchDarkly-Instance-Id GUID as a builder field meant that two LDClient instances built from the same HttpConfigurationBuilder would share the same instance id, defeating the cross-instance uniqueness property. The previous fix stabilized the value across multiple Build() calls on one builder but did not address the builder-shared-across-clients case. Add an InstanceId property to LdClientContext that auto-generates a v4 GUID at construction when none is supplied. Every With*() copy threads the existing value forward, so subsystems built from one LDClient's context observe the same id for the client's lifetime. HttpConfigurationBuilder now reads the value from the context instead of generating its own, which makes the HTTP layer one of several possible consumers rather than the source of truth. Update HttpConfigurationBuilderTest to assert against basicConfig.InstanceId rather than recomputing properties of the GUID shape on each call. The uniqueness test now stands up two distinct LdClientContext instances, which is the property the SDK contract is actually asserting against -- distinct clients have distinct ids. Local dotnet toolchain is not available on this machine; verified by reading; CI will validate. --- .../Integrations/HttpConfigurationBuilder.cs | 15 ++++--- .../server/src/Subsystems/LdClientContext.cs | 45 ++++++++++++++----- .../HttpConfigurationBuilderTest.cs | 34 +++++++------- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs index e52eb60f..474fbf2d 100644 --- a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs +++ b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs @@ -64,11 +64,6 @@ public sealed class HttpConfigurationBuilder : IComponentConfigurer /// Sets the network connection timeout. /// @@ -268,8 +263,14 @@ private HttpProperties MakeHttpProperties(LdClientContext context) .WithReadTimeout(_readTimeout) .WithUserAgent("DotNetClient/" + AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient))) .WithApplicationTags(context.ApplicationInfo) - .WithWrapper(wrapperName, wrapperVersion) - .WithHeader(InstanceIdHeader, _instanceId); + .WithWrapper(wrapperName, wrapperVersion); + + // The instance id originates on LdClientContext (generated once per LDClient), so + // every subsystem built from the same context emits a consistent value. + if (!string.IsNullOrEmpty(context.InstanceId)) + { + httpProperties = httpProperties.WithHeader(InstanceIdHeader, context.InstanceId); + } // For consistency with other SDKs, custom headers are allowed to overwrite headers // such as User-Agent, Authorization, and the instance id. diff --git a/pkgs/sdk/server/src/Subsystems/LdClientContext.cs b/pkgs/sdk/server/src/Subsystems/LdClientContext.cs index 5ec94502..5dfbbfe3 100644 --- a/pkgs/sdk/server/src/Subsystems/LdClientContext.cs +++ b/pkgs/sdk/server/src/Subsystems/LdClientContext.cs @@ -1,4 +1,5 @@ -using LaunchDarkly.Logging; +using System; +using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Internal; using LaunchDarkly.Sdk.Internal.Events; using LaunchDarkly.Sdk.Server.Integrations; @@ -86,6 +87,13 @@ public sealed class LdClientContext /// public string SdkKey { get; } + /// + /// A v4 UUID that uniquely identifies this SDK client instance, sent on every outbound + /// request in the X-LaunchDarkly-Instance-Id header (per SCMP-server-connection-minutes-polling). + /// Generated once at LdClientContext construction and stable for the lifetime of the client. + /// + public string InstanceId { get; } + /// /// Defines the base service URIs used by SDK components. /// @@ -157,7 +165,8 @@ internal LdClientContext( TaskExecutor taskExecutor, ApplicationInfo applicationInfo, WrapperInfo wrapperInfo, - ISelectorSource selectorSource + ISelectorSource selectorSource, + string instanceId = null ) { SdkKey = sdkKey; @@ -172,6 +181,11 @@ ISelectorSource selectorSource ApplicationInfo = applicationInfo; WrapperInfo = wrapperInfo; SelectorSource = selectorSource; + // Generate a fresh instance id when the first LdClientContext for an LDClient is + // constructed; downstream With*() copies thread it through unchanged so every + // subsystem built from this context observes the same value for the client's + // lifetime. Direct callers (LdClient and tests) may pass an explicit id. + InstanceId = instanceId ?? Guid.NewGuid().ToString(); } internal LdClientContext WithDataSourceUpdates(IDataSourceUpdates newDataSourceUpdates) => @@ -187,7 +201,8 @@ internal LdClientContext WithDataSourceUpdates(IDataSourceUpdates newDataSourceU TaskExecutor, ApplicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithDataStoreUpdates(IDataStoreUpdates newDataStoreUpdates) => @@ -203,7 +218,8 @@ internal LdClientContext WithDataStoreUpdates(IDataStoreUpdates newDataStoreUpda TaskExecutor, ApplicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithDiagnosticStore(IDiagnosticStore newDiagnosticStore) => @@ -219,7 +235,8 @@ internal LdClientContext WithDiagnosticStore(IDiagnosticStore newDiagnosticStore TaskExecutor, ApplicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithHttp(HttpConfiguration newHttp) => @@ -235,7 +252,8 @@ internal LdClientContext WithHttp(HttpConfiguration newHttp) => TaskExecutor, ApplicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithLogger(Logger newLogger) => @@ -251,7 +269,8 @@ internal LdClientContext WithLogger(Logger newLogger) => TaskExecutor, ApplicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithTaskExecutor(TaskExecutor newTaskExecutor) => @@ -267,7 +286,8 @@ internal LdClientContext WithTaskExecutor(TaskExecutor newTaskExecutor) => newTaskExecutor, ApplicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithApplicationInfo(ApplicationInfo applicationInfo) => @@ -283,7 +303,8 @@ internal LdClientContext WithApplicationInfo(ApplicationInfo applicationInfo) => TaskExecutor, applicationInfo, WrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithWrapperInfo(WrapperInfo wrapperInfo) => @@ -299,7 +320,8 @@ internal LdClientContext WithWrapperInfo(WrapperInfo wrapperInfo) => TaskExecutor, ApplicationInfo, wrapperInfo, - SelectorSource + SelectorSource, + InstanceId ); internal LdClientContext WithSelectorSource(ISelectorSource selectorSource) => @@ -315,7 +337,8 @@ internal LdClientContext WithSelectorSource(ISelectorSource selectorSource) => TaskExecutor, ApplicationInfo, WrapperInfo, - selectorSource + selectorSource, + InstanceId ); private static HttpConfiguration DefaultHttpConfiguration() => diff --git a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs index a0746b82..2582886b 100644 --- a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs +++ b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs @@ -159,47 +159,51 @@ public void WrapperInfoOverwritesHttpConfiguration() } [Fact] - public void InstanceIdHeaderIsPresentAndIsUuidV4() + public void InstanceIdHeaderMirrorsClientContext() { + // The HTTP builder emits whatever instance id LdClientContext provides; generation + // is the LDClient/LdClientContext's responsibility, not the builder's. var config = Components.HttpConfiguration().Build(basicConfig); var headers = HeadersAsMap(config.DefaultHeaders); Assert.True(headers.ContainsKey("x-launchdarkly-instance-id"), "X-LaunchDarkly-Instance-Id header must be set"); + Assert.Equal(basicConfig.InstanceId, headers["x-launchdarkly-instance-id"]); + // The value LdClientContext generates is a v4 GUID -- verify the wire shape. var raw = headers["x-launchdarkly-instance-id"]; - Assert.True(Guid.TryParse(raw, out var parsed), + Assert.True(Guid.TryParse(raw, out _), $"instance id '{raw}' must be a parseable GUID"); - - // The "M" (version) nibble of a v4 UUID is 0x4. In the canonical 8-4-4-4-12 form, - // that is the first character of the third group. var groups = raw.Split('-'); Assert.Equal(5, groups.Length); Assert.Equal('4', groups[2][0]); } [Fact] - public void InstanceIdHeaderIsUniquePerSdkInstance() + public void InstanceIdHeaderIsUniquePerClientContext() { - // Each call to Build represents a new SDK instance; each must get its own GUID. - var config1 = Components.HttpConfiguration().Build(basicConfig); - var config2 = Components.HttpConfiguration().Build(basicConfig); - var id1 = HeadersAsMap(config1.DefaultHeaders)["x-launchdarkly-instance-id"]; - var id2 = HeadersAsMap(config2.DefaultHeaders)["x-launchdarkly-instance-id"]; + // Each LdClientContext auto-generates its own instance id, so building + // HttpConfiguration from two distinct contexts produces distinct header values. + // This is the cross-SDK-instance uniqueness property the contract tests assert. + var context1 = new LdClientContext("sdk-key"); + var context2 = new LdClientContext("sdk-key"); + var id1 = HeadersAsMap(Components.HttpConfiguration().Build(context1).DefaultHeaders)["x-launchdarkly-instance-id"]; + var id2 = HeadersAsMap(Components.HttpConfiguration().Build(context2).DefaultHeaders)["x-launchdarkly-instance-id"]; Assert.False(string.IsNullOrEmpty(id1)); Assert.False(string.IsNullOrEmpty(id2)); Assert.NotEqual(id1, id2); } [Fact] - public void InstanceIdHeaderIsStableAcrossBuildsOnSameBuilder() + public void InstanceIdHeaderIsStableAcrossBuildsFromSameContext() { - // A single builder is reused by both Build() and DescribeConfiguration() against the - // same SDK instance, so the generated GUID must be fixed at construction rather than - // regenerated on each call. + // Build() and DescribeConfiguration() are both called against the same + // LdClientContext for one SDK instance; the value they see must agree, and must + // equal the context's InstanceId. var builder = Components.HttpConfiguration(); var id1 = HeadersAsMap(builder.Build(basicConfig).DefaultHeaders)["x-launchdarkly-instance-id"]; var id2 = HeadersAsMap(builder.Build(basicConfig).DefaultHeaders)["x-launchdarkly-instance-id"]; Assert.Equal(id1, id2); + Assert.Equal(basicConfig.InstanceId, id1); } [Fact]