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..474fbf2d 100644 --- a/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs +++ b/pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs @@ -33,6 +33,13 @@ namespace LaunchDarkly.Sdk.Server.Integrations /// public sealed class HttpConfigurationBuilder : IComponentConfigurer, IDiagnosticDescription { + /// + /// 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. + /// + internal const string InstanceIdHeader = "X-LaunchDarkly-Instance-Id"; + /// /// The default value for : two seconds. /// @@ -258,6 +265,15 @@ private HttpProperties MakeHttpProperties(LdClientContext context) .WithApplicationTags(context.ApplicationInfo) .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. return _customHeaders.Aggregate(httpProperties, (current, kv) => current.WithHeader(kv.Key, kv.Value)); } 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 d7f1c2fd..2582886b 100644 --- a/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs +++ b/pkgs/sdk/server/test/Integrations/HttpConfigurationBuilderTest.cs @@ -158,6 +158,66 @@ public void WrapperInfoOverwritesHttpConfiguration() Assert.Equal("my-wrapper/3.14", HeadersAsMap(config.DefaultHeaders)["x-launchdarkly-wrapper"]); } + [Fact] + 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 _), + $"instance id '{raw}' must be a parseable GUID"); + var groups = raw.Split('-'); + Assert.Equal(5, groups.Length); + Assert.Equal('4', groups[2][0]); + } + + [Fact] + public void InstanceIdHeaderIsUniquePerClientContext() + { + // 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 InstanceIdHeaderIsStableAcrossBuildsFromSameContext() + { + // 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] + 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);