From 5208297afae1cdc82a7750e6d5ebfc913a7b5b15 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:37:06 -0800 Subject: [PATCH 1/8] Progress --- .../server/src/LaunchDarkly.ServerSdk.csproj | 2 +- .../server/src/Subsystems/DataStoreTypes.cs | 21 ++- .../src/Subsystems/IDataStoreMetadata.cs | 37 +++++ .../Subsystems/IDataStoreUpdatesHeaders.cs | 22 +++ .../DataSources/PollingDataSourceTest.cs | 30 ++++ pkgs/sdk/server/test/LdClientHooksTest.cs | 142 +++++++++++++----- pkgs/sdk/server/test/MockComponents.cs | 50 ++++++ 7 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 pkgs/sdk/server/src/Subsystems/IDataStoreMetadata.cs create mode 100644 pkgs/sdk/server/src/Subsystems/IDataStoreUpdatesHeaders.cs diff --git a/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj b/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj index 278ed64a..2b98d6c1 100644 --- a/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj +++ b/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj @@ -40,7 +40,7 @@ - + diff --git a/pkgs/sdk/server/src/Subsystems/DataStoreTypes.cs b/pkgs/sdk/server/src/Subsystems/DataStoreTypes.cs index b1a9e35d..def684d3 100644 --- a/pkgs/sdk/server/src/Subsystems/DataStoreTypes.cs +++ b/pkgs/sdk/server/src/Subsystems/DataStoreTypes.cs @@ -155,7 +155,7 @@ public override string ToString() return "DataKind(" + _name + ")"; } } - + /// /// A versioned item (or placeholder) storeable in an . /// @@ -303,7 +303,7 @@ public FullDataSet(IEnumerable>> /// Shortcut for constructing an empty data set. /// /// an instance containing no data - public static FullDataSet Empty() => new FullDataSet(null); + public static FullDataSet Empty() => new FullDataSet(null); } /// @@ -341,5 +341,22 @@ public KeyedItems(IEnumerable> items) /// an instance containing no data public static KeyedItems Empty() => new KeyedItems(null); } + + /// + /// Meta-data associated with feature store initialization. + /// + public sealed class InitMetadata + { + internal InitMetadata() {} + internal InitMetadata(string environmentId) + { + EnvironmentId = environmentId; + } + + /// + /// The environment ID for the associated payload or null if the environment ID is not available. + /// + public string EnvironmentId { get; } + } } } diff --git a/pkgs/sdk/server/src/Subsystems/IDataStoreMetadata.cs b/pkgs/sdk/server/src/Subsystems/IDataStoreMetadata.cs new file mode 100644 index 00000000..1bddad74 --- /dev/null +++ b/pkgs/sdk/server/src/Subsystems/IDataStoreMetadata.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk.Server.Subsystems +{ + /// + /// This interface is to allow extending init without a major version. + /// This interface should be removed in the next major version of the SDK and headers + /// should be added in the IDataStore interface. + /// + public interface IDataStoreMetadata + { + /// + /// Overwrites the store's contents with a set of items for each collection. + /// + /// + /// + /// All previous data should be discarded, regardless of versioning. + /// + /// + /// The update should be done atomically. If it cannot be done atomically, then the store + /// must first add or update each item in the same order that they are given in the input + /// data, and then delete any previously stored items that were not in the input data. + /// + /// + /// a list of instances and their + /// corresponding data sets + /// metadata assciated with the payload + void InitWithMetadata(DataStoreTypes.FullDataSet allData, DataStoreTypes.InitMetadata metadata); + + /// + /// Metadata associated with the data store content. + /// + /// metadata associated with the store content + DataStoreTypes.InitMetadata GetMetadata(); + } +} diff --git a/pkgs/sdk/server/src/Subsystems/IDataStoreUpdatesHeaders.cs b/pkgs/sdk/server/src/Subsystems/IDataStoreUpdatesHeaders.cs new file mode 100644 index 00000000..3cde0bd4 --- /dev/null +++ b/pkgs/sdk/server/src/Subsystems/IDataStoreUpdatesHeaders.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Server.Subsystems +{ + /// + /// This interface is to allow extending init without a major version. + /// This interface should be removed in the next major version of the SDK and headers + /// should be added in the IDataStore interface. + /// + public interface IDataSourceUpdatesHeaders + { + /// + /// Completely overwrites the current contents of the data store with a set of items for each collection. + /// + /// a list of instances and their + /// corresponding data sets + /// response headers for the connection + /// true if the update succeeded, false if it failed + bool InitWithHeaders(DataStoreTypes.FullDataSet allData, + IEnumerable>> headers); + } +} diff --git a/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs b/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs index a5e7f69d..422c4ce8 100644 --- a/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs +++ b/pkgs/sdk/server/test/Internal/DataSources/PollingDataSourceTest.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using LaunchDarkly.Sdk.Server.Internal.Model; using LaunchDarkly.Sdk.Server.Subsystems; using LaunchDarkly.TestHelpers.HttpTest; @@ -74,6 +75,35 @@ public void SuccessfulRequestCausesDataToBeStoredAndDataSourceInitialized() } } + [Fact] + public void SuccessfulRequestCausesDataToBeStoredAndDataSourceInitializedMetadata() + { + CapturingDataSourceUpdatesWithHeaders updateSink = new CapturingDataSourceUpdatesWithHeaders(); + using (var server = HttpServer.Start(PollingResponse(AllData))) + { + var builder = BasicConfig() + .DataSource(Components.PollingDataSource()) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(server.Uri)); + var config = builder.Build(); + + using (var dataSource = config.DataSource.Build(ContextFrom(config).WithDataSourceUpdates(updateSink))) + { + var initTask = dataSource.Start(); + + var receivedData = updateSink.Inits.ExpectValue(); + AssertHelpers.DataSetsEqual(AllData, receivedData.Item1); + // There should be some headers from polling, but we don't want to depend on exact values from + // the http server as it isn't implemented in this package. + Assert.NotEmpty(receivedData.Item2); + + Assert.True(dataSource.Initialized); + + Assert.True(initTask.IsCompleted); + Assert.False(initTask.IsFaulted); + } + } + } + [Theory] [InlineData(401)] [InlineData(403)] diff --git a/pkgs/sdk/server/test/LdClientHooksTest.cs b/pkgs/sdk/server/test/LdClientHooksTest.cs index 9600bced..d142d5c6 100644 --- a/pkgs/sdk/server/test/LdClientHooksTest.cs +++ b/pkgs/sdk/server/test/LdClientHooksTest.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Linq; using LaunchDarkly.Sdk.Server.Hooks; +using LaunchDarkly.Sdk.Server.Subsystems; using Xunit; using Xunit.Abstractions; @@ -14,8 +15,6 @@ namespace LaunchDarkly.Sdk.Server public class LdClientHooksTest : BaseTest { - - private class TestHook : Hook { private readonly Callbacks _befores; @@ -62,10 +61,7 @@ private Dictionary GenerateVariationMethods(Context context, strin { Method.BoolVariation, new Call() { - Variation = (client, value) => - { - client.BoolVariation(flagKey, context, value.AsBool); - }, + Variation = (client, value) => { client.BoolVariation(flagKey, context, value.AsBool); }, Value = LdValue.Of(true) } }, @@ -73,20 +69,14 @@ private Dictionary GenerateVariationMethods(Context context, strin { Method.BoolVariationDetail, new Call() { - Variation = (client, value) => - { - client.BoolVariationDetail(flagKey, context, value.AsBool); - }, + Variation = (client, value) => { client.BoolVariationDetail(flagKey, context, value.AsBool); }, Value = LdValue.Of(true) } }, { Method.StringVariation, new Call() { - Variation = (client, value) => - { - client.StringVariation(flagKey, context, value.AsString); - }, + Variation = (client, value) => { client.StringVariation(flagKey, context, value.AsString); }, Value = LdValue.Of("default") } }, @@ -103,30 +93,21 @@ private Dictionary GenerateVariationMethods(Context context, strin { Method.IntVariation, new Call() { - Variation = (client, value) => - { - client.IntVariation(flagKey, context, value.AsInt); - }, + Variation = (client, value) => { client.IntVariation(flagKey, context, value.AsInt); }, Value = LdValue.Of(3) } }, { Method.IntVariationDetail, new Call() { - Variation = (client, value) => - { - client.IntVariationDetail(flagKey, context, value.AsInt); - }, + Variation = (client, value) => { client.IntVariationDetail(flagKey, context, value.AsInt); }, Value = LdValue.Of(3) } }, { Method.DoubleVariation, new Call() { - Variation = (client, value) => - { - client.DoubleVariation(flagKey, context, value.AsDouble); - }, + Variation = (client, value) => { client.DoubleVariation(flagKey, context, value.AsDouble); }, Value = LdValue.Of(3.14) } }, @@ -143,10 +124,7 @@ private Dictionary GenerateVariationMethods(Context context, strin { Method.FloatVariation, new Call() { - Variation = (client, value) => - { - client.FloatVariation(flagKey, context, value.AsFloat); - }, + Variation = (client, value) => { client.FloatVariation(flagKey, context, value.AsFloat); }, Value = LdValue.Of(3.14f) } }, @@ -163,20 +141,14 @@ private Dictionary GenerateVariationMethods(Context context, strin { Method.JsonVariation, new Call() { - Variation = (client, value) => - { - client.JsonVariation(flagKey, context, value); - }, + Variation = (client, value) => { client.JsonVariation(flagKey, context, value); }, Value = LdValue.ArrayFrom(new List() { LdValue.Of("foo"), LdValue.Of("bar") }) } }, { Method.JsonVariationDetail, new Call() { - Variation = (client, value) => - { - client.JsonVariationDetail(flagKey, context, value); - }, + Variation = (client, value) => { client.JsonVariationDetail(flagKey, context, value); }, Value = LdValue.ArrayFrom(new List() { LdValue.Of("foo"), LdValue.Of("bar") }) } } @@ -228,7 +200,8 @@ public void ClientExecutesConfiguredHooks() } } - foreach (var series in new Dictionary{{"beforeEvaluation", befores}, {"afterEvaluation", afters}}) + foreach (var series in new Dictionary + { { "beforeEvaluation", befores }, { "afterEvaluation", afters } }) { foreach (var kvp in series.Value) { @@ -251,6 +224,7 @@ public void ClientExecutesConfiguredHooks() // The hook framework passes empty SeriesData into the beforeEvaluation stage. Assert.True(seriesData.All(s => s.Equals(SeriesData.Empty))); } + if (series.Key == "afterEvaluation") { // The test hook's beforeEvaluation stage returns a new SeriesData, check that it is correct. @@ -259,5 +233,95 @@ public void ClientExecutesConfiguredHooks() } } } + + + private class TestHookWithEnvironmentId : Hook + { + public string BeforeEnvironmentId { get; set; } + public string AfterEnvironmentId { get; set; } + + public TestHookWithEnvironmentId(string name) : base(name) + { + } + + public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) + { + BeforeEnvironmentId = context.EnvironmentId; + return data; + } + + public override SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, + EvaluationDetail detail) + { + AfterEnvironmentId = context.EnvironmentId; + return data; + } + } + + private class MetadataDataStore : IDataStore, IDataStoreMetadata + { + public void Dispose() + { + } + + public bool StatusMonitoringEnabled { get; } + + public void Init(DataStoreTypes.FullDataSet allData) + { + } + + public DataStoreTypes.ItemDescriptor? Get(DataStoreTypes.DataKind kind, string key) + { + return null; + } + + public DataStoreTypes.KeyedItems GetAll(DataStoreTypes.DataKind kind) + { + return DataStoreTypes.KeyedItems.Empty(); + } + + public bool Upsert(DataStoreTypes.DataKind kind, string key, DataStoreTypes.ItemDescriptor item) + { + return true; + } + + public bool Initialized() + { + return true; + } + + public void InitWithMetadata(DataStoreTypes.FullDataSet allData, + DataStoreTypes.InitMetadata metadata) + { + } + + public DataStoreTypes.InitMetadata GetMetadata() + { + return new DataStoreTypes.InitMetadata("environment-id"); + } + } + + private class MetadataDataStoreConfigurer : IComponentConfigurer + { + public IDataStore Build(LdClientContext context) + { + return new MetadataDataStore(); + } + } + + [Fact] + public void EvaluationContextIncludesEnvironmentIdWhenAvailable() + { + var testHook = new TestHookWithEnvironmentId("test"); + var config = BasicConfig().DataStore(new MetadataDataStoreConfigurer()) + .Hooks(Components.Hooks().Add(testHook)).Build(); + using (var client = new LdClient(config)) + { + client.BoolVariation("toaster", Context.New("user-key")); + } + + Assert.Equal("environment-id", testHook.BeforeEnvironmentId); + Assert.Equal("environment-id", testHook.AfterEnvironmentId); + } } } diff --git a/pkgs/sdk/server/test/MockComponents.cs b/pkgs/sdk/server/test/MockComponents.cs index a581a552..ba60f95b 100644 --- a/pkgs/sdk/server/test/MockComponents.cs +++ b/pkgs/sdk/server/test/MockComponents.cs @@ -81,6 +81,56 @@ public bool Upsert(DataKind kind, string key, ItemDescriptor item) } } + public class CapturingDataSourceUpdatesWithHeaders : IDataSourceUpdates, IDataSourceUpdatesHeaders + { + internal readonly + EventSink, IEnumerable>>>> + Inits = + new EventSink, + IEnumerable>>>>(); + + internal readonly EventSink Upserts = new EventSink(); + internal readonly EventSink StatusUpdates = new EventSink(); + + public struct UpsertParams + { + public DataKind Kind; + public string Key; + public ItemDescriptor Item; + } + + internal MockDataStoreStatusProvider MockDataStoreStatusProvider = new MockDataStoreStatusProvider(); + + internal int InitsShouldFail = 0; + + internal int UpsertsShouldFail = 0; + + public IDataStoreStatusProvider DataStoreStatusProvider => MockDataStoreStatusProvider; + + public bool Init(FullDataSet allData) + { + return InitWithHeaders(allData, null); + } + + public void UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError) => + StatusUpdates.Enqueue(new DataSourceStatus() { State = newState, LastError = newError }); + + public bool Upsert(DataKind kind, string key, ItemDescriptor item) + { + Upserts.Enqueue(new UpsertParams { Kind = kind, Key = key, Item = item }); + return UpsertsShouldFail <= 0 || (--UpsertsShouldFail < 0); + } + + public bool InitWithHeaders(FullDataSet allData, + IEnumerable>> headers) + { + Inits.Enqueue( + new Tuple, IEnumerable>>>(allData, + headers)); + return InitsShouldFail <= 0 || (--InitsShouldFail < 0); + } + } + public class CapturingDataStoreFactory : IComponentConfigurer { private readonly IComponentConfigurer _factory; From 69005b426a080b4136086f42d76f499668708cde Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:39:41 -0800 Subject: [PATCH 2/8] Progress --- .../src/Hooks/EvaluationSeriesContext.cs | 10 +- .../DataSources/DataSourceUpdatesImpl.cs | 109 +++++++++++------- 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/pkgs/sdk/server/src/Hooks/EvaluationSeriesContext.cs b/pkgs/sdk/server/src/Hooks/EvaluationSeriesContext.cs index d49d5126..70714de8 100644 --- a/pkgs/sdk/server/src/Hooks/EvaluationSeriesContext.cs +++ b/pkgs/sdk/server/src/Hooks/EvaluationSeriesContext.cs @@ -25,6 +25,11 @@ public sealed class EvaluationSeriesContext { /// public string Method { get; } + /// + /// The environment ID for the evaluation, or null if not available. + /// + public string EnvironmentId { get; } + /// /// Constructs a new EvaluationSeriesContext. /// @@ -32,11 +37,14 @@ public sealed class EvaluationSeriesContext { /// the context /// the default value /// the variation method - public EvaluationSeriesContext(string flagKey, Context context, LdValue defaultValue, string method) { + /// the environment ID + public EvaluationSeriesContext(string flagKey, Context context, LdValue defaultValue, string method, + string environmentId = null) { FlagKey = flagKey; Context = context; DefaultValue = defaultValue; Method = method; + EnvironmentId = environmentId; } } } diff --git a/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs b/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs index b3015ede..13364f16 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/DataSourceUpdatesImpl.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Collections.Generic; using System.Collections.Immutable; using System.Threading.Tasks; @@ -22,7 +23,7 @@ namespace LaunchDarkly.Sdk.Server.Internal.DataSources /// This component is also responsible for receiving updates to the data source status, broadcasting /// them to any status listeners, and tracking the length of any period of sustained failure. /// - internal sealed class DataSourceUpdatesImpl : IDataSourceUpdates + internal sealed class DataSourceUpdatesImpl : IDataSourceUpdates, IDataSourceUpdatesHeaders { #region Private fields @@ -84,7 +85,7 @@ internal DataSourceUpdatesImpl( StateSince = DateTime.Now, LastError = null }; - _status = new StateMonitor(initialStatus, MaybeUpdateStatus, _log); + _status = new StateMonitor(initialStatus, MaybeUpdateStatus, _log); } #endregion @@ -93,47 +94,7 @@ internal DataSourceUpdatesImpl( public bool Init(FullDataSet allData) { - ImmutableDictionary> oldData = null; - - try - { - if (HasFlagChangeListeners()) - { - // Query the existing data if any, so that after the update we can send events for - // whatever was changed - var oldDataBuilder = ImmutableDictionary.CreateBuilder>(); - foreach (var kind in DataModel.AllDataKinds) - { - var items = _store.GetAll(kind); - oldDataBuilder.Add(kind, items.Items.ToImmutableDictionary()); - } - oldData = oldDataBuilder.ToImmutable(); - } - _store.Init(DataStoreSorter.SortAllCollections(allData)); - _lastStoreUpdateFailed = false; - } - catch (Exception e) - { - ReportStoreFailure(e); - return false; - } - - // Calling Init implies that the data source is now in a valid state. - UpdateStatus(DataSourceState.Valid, null); - - // We must always update the dependency graph even if we don't currently have any event listeners, because if - // listeners are added later, we don't want to have to reread the whole data store to compute the graph - UpdateDependencyTrackerFromFullDataSet(allData); - - // Now, if we previously queried the old data because someone is listening for flag change events, compare - // the versions of all items and generate events for those (and any other items that depend on them) - if (oldData != null) - { - SendChangeEvents(ComputeChangedItemsForFullDataSet(oldData, FullDataSetToMap(allData))); - } - - return true; + return InitWithHeaders(allData, null); } public bool Upsert(DataKind kind, string key, ItemDescriptor item) @@ -326,5 +287,67 @@ private void ReportStoreFailure(Exception e) } #endregion + + #region IDataSourceUpdatesHeaders methods + public bool InitWithHeaders(FullDataSet allData, IEnumerable>> headers) + { + ImmutableDictionary> oldData = null; + + try + { + if (HasFlagChangeListeners()) + { + // Query the existing data if any, so that after the update we can send events for + // whatever was changed + var oldDataBuilder = ImmutableDictionary.CreateBuilder>(); + foreach (var kind in DataModel.AllDataKinds) + { + var items = _store.GetAll(kind); + oldDataBuilder.Add(kind, items.Items.ToImmutableDictionary()); + } + oldData = oldDataBuilder.ToImmutable(); + } + + var sortedCollections = DataStoreSorter.SortAllCollections(allData); + + if (_store is IDataStoreMetadata storeMetadata) + { + var environmentId = headers?.FirstOrDefault((item) => + item.Key.ToLower() == HeaderConstants.EnvironmentId).Value + ?.FirstOrDefault(); + storeMetadata.InitWithMetadata(sortedCollections, new InitMetadata(environmentId)); + } + else + { + _store.Init(sortedCollections); + } + + _lastStoreUpdateFailed = false; + } + catch (Exception e) + { + ReportStoreFailure(e); + return false; + } + + // Calling Init implies that the data source is now in a valid state. + UpdateStatus(DataSourceState.Valid, null); + + // We must always update the dependency graph even if we don't currently have any event listeners, because if + // listeners are added later, we don't want to have to reread the whole data store to compute the graph + UpdateDependencyTrackerFromFullDataSet(allData); + + // Now, if we previously queried the old data because someone is listening for flag change events, compare + // the versions of all items and generate events for those (and any other items that depend on them) + if (oldData != null) + { + SendChangeEvents(ComputeChangedItemsForFullDataSet(oldData, FullDataSetToMap(allData))); + } + + return true; + } + #endregion + } } From d2ee70fcc41947be9de3b8d06808a551598ed619 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:42:52 -0800 Subject: [PATCH 3/8] Progress --- .../Internal/DataSources/FeatureRequestor.cs | 16 ++++++++------- .../Internal/DataSources/IFeatureRequestor.cs | 16 ++++++++++++++- .../Internal/DataSources/PollingDataSource.cs | 20 +++++++++++++++---- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/pkgs/sdk/server/src/Internal/DataSources/FeatureRequestor.cs b/pkgs/sdk/server/src/Internal/DataSources/FeatureRequestor.cs index 9d8506dd..f6b08645 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/FeatureRequestor.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/FeatureRequestor.cs @@ -17,6 +17,8 @@ namespace LaunchDarkly.Sdk.Server.Internal.DataSources { + using BytesWithHeaders = Tuple>>>; + internal class FeatureRequestor : IFeatureRequestor { private readonly Uri _allUri; @@ -51,19 +53,19 @@ private void Dispose(bool disposing) // Returns a data set of the latest flags and segments, or null if they have not been modified. Throws an // exception if there was a problem getting data. - public async Task?> GetAllDataAsync() + public async Task GetAllDataAsync() { - var json = await GetAsync(_allUri); - if (json is null) + var res = await GetAsync(_allUri); + if (res is null) { return null; } - var data = ParseAllData(json); + var data = ParseAllData(res.Item1); Func countItems = kind => data.Data.FirstOrDefault(kv => kv.Key == kind).Value.Items?.Count() ?? 0; _log.Debug("Get all returned {0} feature flags and {1} segments", countItems(DataModel.Features), countItems(DataModel.Segments)); - return data; + return new DataSetWithHeaders(data, res.Item2); } private FullDataSet ParseAllData(byte[] json) @@ -72,7 +74,7 @@ private FullDataSet ParseAllData(byte[] json) return StreamProcessorEvents.ParseFullDataset(ref r); } - private async Task GetAsync(Uri path) + private async Task GetAsync(Uri path) { _log.Debug("Getting flags with uri: {0}", path.AbsoluteUri); var request = new HttpRequestMessage(HttpMethod.Get, path); @@ -113,7 +115,7 @@ private async Task GetAsync(Uri path) } } var content = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); - return content.Length == 0 ? null : content; + return new BytesWithHeaders(content.Length == 0 ? null : content, response.Headers); } } catch (TaskCanceledException tce) diff --git a/pkgs/sdk/server/src/Internal/DataSources/IFeatureRequestor.cs b/pkgs/sdk/server/src/Internal/DataSources/IFeatureRequestor.cs index 573c0dca..adb82f0f 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/IFeatureRequestor.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/IFeatureRequestor.cs @@ -1,12 +1,26 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using static LaunchDarkly.Sdk.Server.Subsystems.DataStoreTypes; namespace LaunchDarkly.Sdk.Server.Internal.DataSources { + internal class DataSetWithHeaders + { + public readonly FullDataSet? DataSet; + public readonly IEnumerable>> Headers; + + public DataSetWithHeaders(FullDataSet? dataSet, + IEnumerable>> headers) + { + DataSet = dataSet; + Headers = headers; + } + } + internal interface IFeatureRequestor : IDisposable { - Task?> GetAllDataAsync(); + Task GetAllDataAsync(); } } diff --git a/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs b/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs index 3eb79aca..2540a763 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/PollingDataSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -60,15 +61,15 @@ private async Task UpdateTaskAsync() _log.Info("Polling LaunchDarkly for feature flag updates"); try { - var allData = await _featureRequestor.GetAllDataAsync(); - if (allData is null) + var dataAndHeaders = await _featureRequestor.GetAllDataAsync(); + if (dataAndHeaders.DataSet is null) { // This means it was cached, and alreadyInited was true _dataSourceUpdates.UpdateStatus(DataSourceState.Valid, null); } else { - if (_dataSourceUpdates.Init(allData.Value)) // this also automatically sets the state to Valid + if (InitWithHeaders(dataAndHeaders.DataSet.Value, dataAndHeaders.Headers)) // this also automatically sets the state to Valid { if (!_initialized.GetAndSet(true)) { @@ -137,5 +138,16 @@ private void Dispose(bool disposing) _featureRequestor.Dispose(); } } + + private bool InitWithHeaders(DataStoreTypes.FullDataSet allData, + IEnumerable>> headers) + { + if (_dataSourceUpdates is IDataSourceUpdatesHeaders dataSourceUpdatesHeaders) + { + return dataSourceUpdatesHeaders.InitWithHeaders(allData, headers); + } + + return _dataSourceUpdates.Init(allData); + } } -} \ No newline at end of file +} From f201653cbe10628a811250e5c63ce9efabb8276e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:45:59 -0800 Subject: [PATCH 4/8] Progress --- .../DataSources/StreamingDataSource.cs | 17 +++++- .../Internal/DataStores/InMemoryDataStore.cs | 59 +++++++++++-------- .../server/src/Internal/HeaderConstants.cs | 16 +++++ pkgs/sdk/server/src/LdClient.cs | 15 ++++- 4 files changed, 82 insertions(+), 25 deletions(-) create mode 100644 pkgs/sdk/server/src/Internal/HeaderConstants.cs diff --git a/pkgs/sdk/server/src/Internal/DataSources/StreamingDataSource.cs b/pkgs/sdk/server/src/Internal/DataSources/StreamingDataSource.cs index 527c23f1..6adb575c 100644 --- a/pkgs/sdk/server/src/Internal/DataSources/StreamingDataSource.cs +++ b/pkgs/sdk/server/src/Internal/DataSources/StreamingDataSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.Json; @@ -43,6 +44,8 @@ internal class StreamingDataSource : IDataSource private volatile bool _lastStoreUpdateFailed = false; internal DateTime _esStarted; // exposed for testing + private IEnumerable>> _headers; + internal delegate IEventSource EventSourceCreator(Uri streamUri, HttpConfiguration httpConfig); @@ -139,6 +142,7 @@ private void RecordStreamInit(bool failed) private void OnOpen(object sender, EventSource.StateChangedEventArgs e) { + _headers = e.Headers; _log.Debug("EventSource Opened"); RecordStreamInit(false); } @@ -238,13 +242,24 @@ private void OnError(object sender, EventSource.ExceptionEventArgs e) } } + private bool InitWithHeaders(FullDataSet allData, + IEnumerable>> headers) + { + if (_dataSourceUpdates is IDataSourceUpdatesHeaders dataSourceUpdatesHeaders) + { + return dataSourceUpdatesHeaders.InitWithHeaders(allData, headers); + } + + return _dataSourceUpdates.Init(allData); + } + private void HandleMessage(string messageType, byte[] messageData) { switch (messageType) { case PUT: var putData = ParsePutData(messageData); - if (!_dataSourceUpdates.Init(putData.Data)) // this also automatically sets the state to Valid + if (!InitWithHeaders(putData.Data, _headers)) // this also automatically sets the state to Valid { throw new StreamStoreException("failed to write full data set to data store"); } diff --git a/pkgs/sdk/server/src/Internal/DataStores/InMemoryDataStore.cs b/pkgs/sdk/server/src/Internal/DataStores/InMemoryDataStore.cs index ebc86be0..fdbf68f3 100644 --- a/pkgs/sdk/server/src/Internal/DataStores/InMemoryDataStore.cs +++ b/pkgs/sdk/server/src/Internal/DataStores/InMemoryDataStore.cs @@ -12,39 +12,22 @@ namespace LaunchDarkly.Sdk.Server.Internal.DataStores /// Application code cannot see this implementation class and uses /// instead. /// - internal class InMemoryDataStore : IDataStore + internal class InMemoryDataStore : IDataStore, IDataStoreMetadata { private readonly object WriterLock = new object(); private volatile ImmutableDictionary> Items = ImmutableDictionary>.Empty; private volatile bool _initialized = false; + private volatile InitMetadata _metadata; + internal InMemoryDataStore() { } public bool StatusMonitoringEnabled => false; public void Init(FullDataSet data) { - var itemsBuilder = ImmutableDictionary.CreateBuilder>(); - - foreach (var kindEntry in data.Data) - { - var kindItemsBuilder = ImmutableDictionary.CreateBuilder(); - foreach (var e1 in kindEntry.Value.Items) - { - kindItemsBuilder.Add(e1.Key, e1.Value); - } - - itemsBuilder.Add(kindEntry.Key, kindItemsBuilder.ToImmutable()); - } - - var newItems = itemsBuilder.ToImmutable(); - - lock (WriterLock) - { - Items = newItems; - _initialized = true; - } + InitWithMetadata(data, new InitMetadata()); } public ItemDescriptor? Get(DataKind kind, string key) @@ -68,7 +51,7 @@ public KeyedItems GetAll(DataKind kind) } return KeyedItems.Empty(); } - + public bool Upsert(DataKind kind, string key, ItemDescriptor item) { lock (WriterLock) @@ -93,5 +76,35 @@ public bool Initialized() } public void Dispose() { } + + public void InitWithMetadata(FullDataSet data, InitMetadata metadata) + { + var itemsBuilder = ImmutableDictionary.CreateBuilder>(); + + foreach (var kindEntry in data.Data) + { + var kindItemsBuilder = ImmutableDictionary.CreateBuilder(); + foreach (var e1 in kindEntry.Value.Items) + { + kindItemsBuilder.Add(e1.Key, e1.Value); + } + + itemsBuilder.Add(kindEntry.Key, kindItemsBuilder.ToImmutable()); + } + + var newItems = itemsBuilder.ToImmutable(); + + lock (WriterLock) + { + Items = newItems; + _metadata = metadata; + _initialized = true; + } + } + + public InitMetadata GetMetadata() + { + return _metadata; + } } -} \ No newline at end of file +} diff --git a/pkgs/sdk/server/src/Internal/HeaderConstants.cs b/pkgs/sdk/server/src/Internal/HeaderConstants.cs new file mode 100644 index 00000000..b6dfca8a --- /dev/null +++ b/pkgs/sdk/server/src/Internal/HeaderConstants.cs @@ -0,0 +1,16 @@ +namespace LaunchDarkly.Sdk.Server.Internal +{ + /// + /// Constants for headers returned from, or sent to, LaunchDarkly. + /// + /// All header keys should be lowercase. + /// + /// + public static class HeaderConstants + { + /// + /// The LaunchDarkly environment ID. This is included in responses from streaming and polling endpoints. + /// + public static string EnvironmentId = "x-ld-envid"; + } +} diff --git a/pkgs/sdk/server/src/LdClient.cs b/pkgs/sdk/server/src/LdClient.cs index ecc32f28..46f783df 100644 --- a/pkgs/sdk/server/src/LdClient.cs +++ b/pkgs/sdk/server/src/LdClient.cs @@ -428,7 +428,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ private (EvaluationDetail, FeatureFlag) EvaluateWithHooks(string method, string key, Context context, LdValue defaultValue, LdValue.Converter converter, bool checkType, EventFactory eventFactory) { - var evalSeriesContext = new EvaluationSeriesContext(key, context, defaultValue, method); + var evalSeriesContext = new EvaluationSeriesContext(key, context, defaultValue, method, GetEnvironmentId()); return _hookExecutor.EvaluationSeries( evalSeriesContext, converter, @@ -681,6 +681,19 @@ private void Dispose(bool disposing) } } + /// + /// Get the environment ID. + /// + /// The environment ID, or null if one is not available + private string GetEnvironmentId() + { + if (_dataStore is IDataStoreMetadata dataStoreMetadata) + { + return dataStoreMetadata.GetMetadata()?.EnvironmentId; + } + return null; + } + #endregion } } From 83a7fe069d81fc9c0667ac10325bb094bbe1094f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 4 Mar 2025 10:03:50 -0800 Subject: [PATCH 5/8] feat: Add environment id support for OTEL hook. --- pkgs/telemetry/src/TracingHook.cs | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/pkgs/telemetry/src/TracingHook.cs b/pkgs/telemetry/src/TracingHook.cs index f33acd4c..742f7f6d 100644 --- a/pkgs/telemetry/src/TracingHook.cs +++ b/pkgs/telemetry/src/TracingHook.cs @@ -16,11 +16,13 @@ public class TracingHookBuilder { private bool _createActivities; private bool _includeVariant; + private string _environmentId; internal TracingHookBuilder() { _createActivities = false; _includeVariant = false; + _environmentId = null; } /// @@ -51,6 +53,22 @@ public TracingHookBuilder IncludeVariant(bool includeVariant = true) return this; } + /// + /// The environment ID associated with the SDK configuration. In typical usage the environment ID should not be + /// specified. The environment ID only needs to be manually specified if it cannot be retrieved from the SDK. + /// + /// This is not the same as the SDK key. The environment ID is equivalent to the client-side ID in the + /// LaunchDarkly UI and documentation. + /// + /// + /// The environment the SDK is configured to connect to. + /// this builder + public TracingHookBuilder EnvironmentId(string environmentId) + { + _environmentId = environmentId; + return this; + } + /// /// Builds the with the configured options. /// @@ -59,7 +77,7 @@ public TracingHookBuilder IncludeVariant(bool includeVariant = true) /// the new hook public TracingHook Build() { - return new TracingHook(new TracingHook.Options(_createActivities, _includeVariant)); + return new TracingHook(new TracingHook.Options(_createActivities, _includeVariant, _environmentId)); } } @@ -92,6 +110,7 @@ private static class SemanticAttributes public const string FeatureFlagProviderName = "feature_flag.provider_name"; public const string FeatureFlagVariant = "feature_flag.variant"; public const string FeatureFlagContextKeyAttributeName = "feature_flag.context.key"; + public const string FeatureFlagSetId = "feature_flag.set_id"; } internal struct Options @@ -99,10 +118,13 @@ internal struct Options public bool CreateActivities { get; } public bool IncludeVariant { get; } - public Options(bool createActivities, bool includeVariant) + public string EnvironmentId { get; } + + public Options(bool createActivities, bool includeVariant, string environmentId = null) { CreateActivities = createActivities; IncludeVariant = includeVariant; + EnvironmentId = environmentId; } } @@ -185,6 +207,15 @@ public override SeriesData AfterEvaluation(EvaluationSeriesContext context, Seri {SemanticAttributes.FeatureFlagContextKeyAttributeName, context.Context.FullyQualifiedKey}, }; + if (_options.EnvironmentId != null) + { + attributes[SemanticAttributes.FeatureFlagSetId] = _options.EnvironmentId; + } + else if (context.EnvironmentId != null) + { + attributes[SemanticAttributes.FeatureFlagSetId] = context.EnvironmentId; + } + if (_options.IncludeVariant) { attributes.Add(SemanticAttributes.FeatureFlagVariant, detail.Value.ToJsonString()); From 9cafbcf71833972ef3d9851eea637392523a0d99 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 21 Mar 2025 09:22:16 -0700 Subject: [PATCH 6/8] Update version constraint. --- pkgs/telemetry/src/LaunchDarkly.ServerSdk.Telemetry.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/telemetry/src/LaunchDarkly.ServerSdk.Telemetry.csproj b/pkgs/telemetry/src/LaunchDarkly.ServerSdk.Telemetry.csproj index db6c783f..95ef789a 100644 --- a/pkgs/telemetry/src/LaunchDarkly.ServerSdk.Telemetry.csproj +++ b/pkgs/telemetry/src/LaunchDarkly.ServerSdk.Telemetry.csproj @@ -37,7 +37,7 @@ - + From 32da9191485cca4a734c09124767ee5d95ab5e06 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 21 Mar 2025 10:55:35 -0700 Subject: [PATCH 7/8] Correct flag set id. --- pkgs/telemetry/src/TracingHook.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/telemetry/src/TracingHook.cs b/pkgs/telemetry/src/TracingHook.cs index 742f7f6d..81323cf0 100644 --- a/pkgs/telemetry/src/TracingHook.cs +++ b/pkgs/telemetry/src/TracingHook.cs @@ -110,7 +110,7 @@ private static class SemanticAttributes public const string FeatureFlagProviderName = "feature_flag.provider_name"; public const string FeatureFlagVariant = "feature_flag.variant"; public const string FeatureFlagContextKeyAttributeName = "feature_flag.context.key"; - public const string FeatureFlagSetId = "feature_flag.set_id"; + public const string FeatureFlagSetId = "feature_flag.set.id"; } internal struct Options From 09fef25f39d3f018a779c262cfa85b529070b4a7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:33:55 -0700 Subject: [PATCH 8/8] Add unit tests. --- pkgs/telemetry/test/TracingHookTests.cs | 143 +++++++++++++++++++++++- 1 file changed, 137 insertions(+), 6 deletions(-) diff --git a/pkgs/telemetry/test/TracingHookTests.cs b/pkgs/telemetry/test/TracingHookTests.cs index 912429a0..70c2402c 100644 --- a/pkgs/telemetry/test/TracingHookTests.cs +++ b/pkgs/telemetry/test/TracingHookTests.cs @@ -19,6 +19,13 @@ public void CanConstructTracingHook() Assert.Equal("LaunchDarkly Tracing Hook", hook.Metadata.Name); } + [Fact] + public void CanConstructTracingHookWithEnvironmentId() + { + var hook = TracingHook.Builder().EnvironmentId("env-123").Build(); + Assert.NotNull(hook); + } + [Fact] public void CanRetrieveActivitySourceName() { @@ -64,12 +71,14 @@ public void TracingHookCreatesRootSpans(bool createSpans) var featureKey = "feature-key"; var context = Context.New("foo"); - var evalContext1 = new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); + var evalContext1 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); var data1 = hookUnderTest.BeforeEvaluation(evalContext1, new SeriesDataBuilder().Build()); hookUnderTest.AfterEvaluation(evalContext1, data1, new EvaluationDetail(LdValue.Of(true), 0, EvaluationReason.FallthroughReason)); - var evalContext2 = new EvaluationSeriesContext(featureKey, context, LdValue.Of("default"), "LdClient.StringVariation"); + var evalContext2 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of("default"), "LdClient.StringVariation"); var data2 = hookUnderTest.BeforeEvaluation(evalContext2, new SeriesDataBuilder().Build()); hookUnderTest.AfterEvaluation(evalContext2, data2, new EvaluationDetail(LdValue.Of("default"), 0, EvaluationReason.FallthroughReason)); @@ -117,12 +126,14 @@ public void TracingHookCreatesChildSpans(bool createSpans) var rootActivity = testSource.StartActivity("root-activity"); - var evalContext1 = new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); + var evalContext1 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); var data1 = hookUnderTest.BeforeEvaluation(evalContext1, new SeriesDataBuilder().Build()); hookUnderTest.AfterEvaluation(evalContext1, data1, new EvaluationDetail(LdValue.Of(true), 0, EvaluationReason.FallthroughReason)); - var evalContext2 = new EvaluationSeriesContext(featureKey, context, LdValue.Of("default"), "LdClient.StringVariation"); + var evalContext2 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of("default"), "LdClient.StringVariation"); var data2 = hookUnderTest.BeforeEvaluation(evalContext2, new SeriesDataBuilder().Build()); hookUnderTest.AfterEvaluation(evalContext2, data2, new EvaluationDetail(LdValue.Of("default"), 0, EvaluationReason.FallthroughReason)); @@ -173,12 +184,14 @@ public void TracingHookIncludesVariant(bool includeVariant) var rootActivity = testSource.StartActivity("root-activity"); - var evalContext1 = new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); + var evalContext1 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); var data1 = hookUnderTest.BeforeEvaluation(evalContext1, new SeriesDataBuilder().Build()); hookUnderTest.AfterEvaluation(evalContext1, data1, new EvaluationDetail(LdValue.Of(true), 0, EvaluationReason.FallthroughReason)); - var evalContext2 = new EvaluationSeriesContext(featureKey, context, LdValue.Of("default"), "LdClient.StringVariation"); + var evalContext2 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of("default"), "LdClient.StringVariation"); var data2 = hookUnderTest.BeforeEvaluation(evalContext2, new SeriesDataBuilder().Build()); hookUnderTest.AfterEvaluation(evalContext2, data2, new EvaluationDetail(LdValue.Of("default"), 0, EvaluationReason.FallthroughReason)); @@ -207,5 +220,123 @@ public void TracingHookIncludesVariant(bool includeVariant) Assert.All(items, i => i.Events.All(e => e.Tags.All(kvp => kvp.Key != "feature_flag.variant"))); } } + + + [Fact] + public void TracingHookIncludesEnvironmentIdWhenSpecified() + { + ICollection exportedItems = new Collection(); + + _ = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("test-source") + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService(serviceName: "test-source", serviceVersion: "1.0.0")) + .AddInMemoryExporter(exportedItems) + .Build(); + + var testSource = new ActivitySource("test-source", "1.0.0"); + + var hookUnderTest = TracingHook.Builder().EnvironmentId("env-123").Build(); + var featureKey = "feature-key"; + var context = Context.New("foo"); + + var rootActivity = testSource.StartActivity("root-activity"); + + var evalContext1 = + new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), "LdClient.BoolVariation"); + var data1 = hookUnderTest.BeforeEvaluation(evalContext1, new SeriesDataBuilder().Build()); + hookUnderTest.AfterEvaluation(evalContext1, data1, + new EvaluationDetail(LdValue.Of(true), 0, EvaluationReason.FallthroughReason)); + + rootActivity.Stop(); + + var items = exportedItems.ToList(); + + Assert.Single(items); + Assert.Equal("root-activity", items[0].OperationName); + + var events = items[0].Events; + Assert.Single(events.Where(e => + e.Tags.Contains(new KeyValuePair("feature_flag.set.id", "env-123")))); + } + + [Fact] + public void TracingHookUsesEnvironmentIdFromContext() + { + ICollection exportedItems = new Collection(); + + _ = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("test-source") + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService(serviceName: "test-source", serviceVersion: "1.0.0")) + .AddInMemoryExporter(exportedItems) + .Build(); + + var testSource = new ActivitySource("test-source", "1.0.0"); + + var hookUnderTest = TracingHook.Builder().Build(); + var featureKey = "feature-key"; + var context = Context.New("foo"); + + var rootActivity = testSource.StartActivity("root-activity"); + + var evalContext1 = new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), + "LdClient.BoolVariation", "env-456"); + var data1 = hookUnderTest.BeforeEvaluation(evalContext1, new SeriesDataBuilder().Build()); + hookUnderTest.AfterEvaluation(evalContext1, data1, + new EvaluationDetail(LdValue.Of(true), 0, EvaluationReason.FallthroughReason)); + + rootActivity.Stop(); + + var items = exportedItems.ToList(); + + Assert.Single(items); + Assert.Equal("root-activity", items[0].OperationName); + + var events = items[0].Events; + Assert.Single(events.Where(e => + e.Tags.Contains(new KeyValuePair("feature_flag.set.id", "env-456")))); + } + + [Fact] + public void TracingHookPrioritizesEnvironmentIdFromOptions() + { + ICollection exportedItems = new Collection(); + + _ = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("test-source") + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService(serviceName: "test-source", serviceVersion: "1.0.0")) + .AddInMemoryExporter(exportedItems) + .Build(); + + var testSource = new ActivitySource("test-source", "1.0.0"); + + var hookUnderTest = TracingHook.Builder().EnvironmentId("env-123").Build(); + var featureKey = "feature-key"; + var context = Context.New("foo"); + + var rootActivity = testSource.StartActivity("root-activity"); + + var evalContext1 = new EvaluationSeriesContext(featureKey, context, LdValue.Of(true), + "LdClient.BoolVariation", "env-456"); + var data1 = hookUnderTest.BeforeEvaluation(evalContext1, new SeriesDataBuilder().Build()); + hookUnderTest.AfterEvaluation(evalContext1, data1, + new EvaluationDetail(LdValue.Of(true), 0, EvaluationReason.FallthroughReason)); + + rootActivity.Stop(); + + var items = exportedItems.ToList(); + + Assert.Single(items); + Assert.Equal("root-activity", items[0].OperationName); + + var events = items[0].Events; + Assert.Single(events.Where(e => + e.Tags.Contains(new KeyValuePair("feature_flag.set.id", "env-123")))); + } } }