Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkgs/sdk/server/contract-tests/TestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public class Webapp
"anonymous-redaction",
"evaluation-hooks",
"client-prereq-events",
"fdv1-fallback"
"fdv1-fallback",
"instance-id"
};

public readonly Handler Handler;
Expand Down
16 changes: 16 additions & 0 deletions pkgs/sdk/server/src/Integrations/HttpConfigurationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ namespace LaunchDarkly.Sdk.Server.Integrations
/// </remarks>
public sealed class HttpConfigurationBuilder : IComponentConfigurer<HttpConfiguration>, IDiagnosticDescription
{
/// <summary>
/// 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.
/// </summary>
internal const string InstanceIdHeader = "X-LaunchDarkly-Instance-Id";

/// <summary>
/// The default value for <see cref="ConnectTimeout(TimeSpan)"/>: two seconds.
/// </summary>
Expand Down Expand Up @@ -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));
}
Expand Down
45 changes: 34 additions & 11 deletions pkgs/sdk/server/src/Subsystems/LdClientContext.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -86,6 +87,13 @@ public sealed class LdClientContext
/// </summary>
public string SdkKey { get; }

/// <summary>
/// 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.
/// </summary>
public string InstanceId { get; }

/// <summary>
/// Defines the base service URIs used by SDK components.
/// </summary>
Expand Down Expand Up @@ -157,7 +165,8 @@ internal LdClientContext(
TaskExecutor taskExecutor,
ApplicationInfo applicationInfo,
WrapperInfo wrapperInfo,
ISelectorSource selectorSource
ISelectorSource selectorSource,
string instanceId = null
)
{
SdkKey = sdkKey;
Expand All @@ -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) =>
Expand All @@ -187,7 +201,8 @@ internal LdClientContext WithDataSourceUpdates(IDataSourceUpdates newDataSourceU
TaskExecutor,
ApplicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithDataStoreUpdates(IDataStoreUpdates newDataStoreUpdates) =>
Expand All @@ -203,7 +218,8 @@ internal LdClientContext WithDataStoreUpdates(IDataStoreUpdates newDataStoreUpda
TaskExecutor,
ApplicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithDiagnosticStore(IDiagnosticStore newDiagnosticStore) =>
Expand All @@ -219,7 +235,8 @@ internal LdClientContext WithDiagnosticStore(IDiagnosticStore newDiagnosticStore
TaskExecutor,
ApplicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithHttp(HttpConfiguration newHttp) =>
Expand All @@ -235,7 +252,8 @@ internal LdClientContext WithHttp(HttpConfiguration newHttp) =>
TaskExecutor,
ApplicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithLogger(Logger newLogger) =>
Expand All @@ -251,7 +269,8 @@ internal LdClientContext WithLogger(Logger newLogger) =>
TaskExecutor,
ApplicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithTaskExecutor(TaskExecutor newTaskExecutor) =>
Expand All @@ -267,7 +286,8 @@ internal LdClientContext WithTaskExecutor(TaskExecutor newTaskExecutor) =>
newTaskExecutor,
ApplicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithApplicationInfo(ApplicationInfo applicationInfo) =>
Expand All @@ -283,7 +303,8 @@ internal LdClientContext WithApplicationInfo(ApplicationInfo applicationInfo) =>
TaskExecutor,
applicationInfo,
WrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithWrapperInfo(WrapperInfo wrapperInfo) =>
Expand All @@ -299,7 +320,8 @@ internal LdClientContext WithWrapperInfo(WrapperInfo wrapperInfo) =>
TaskExecutor,
ApplicationInfo,
wrapperInfo,
SelectorSource
SelectorSource,
InstanceId
);

internal LdClientContext WithSelectorSource(ISelectorSource selectorSource) =>
Expand All @@ -315,7 +337,8 @@ internal LdClientContext WithSelectorSource(ISelectorSource selectorSource) =>
TaskExecutor,
ApplicationInfo,
WrapperInfo,
selectorSource
selectorSource,
InstanceId
);

private static HttpConfiguration DefaultHttpConfiguration() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> HeadersAsMap(IEnumerable<KeyValuePair<string, string>> headers)
{
return headers.ToDictionary(kv => kv.Key.ToLower(), kv => kv.Value);
Expand Down
Loading