From 75e8225f8ab2126505885247483878567f033bb8 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:08:21 +0100 Subject: [PATCH 1/2] Add TraceEnricherHookOptions to enrich feature_flag.evaluation event * Add unit tests * Update README * Add XML comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 18 ++++ src/OpenFeature/Hooks/TraceEnricherHook.cs | 35 +++++++ .../Hooks/TraceEnricherHookOptions.cs | 91 +++++++++++++++++++ .../Hooks/TraceEnricherHookOptionsTests.cs | 84 +++++++++++++++++ .../Hooks/TraceEnricherHookTests.cs | 78 ++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 src/OpenFeature/Hooks/TraceEnricherHookOptions.cs create mode 100644 test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs diff --git a/README.md b/README.md index e79fd544a..f2fb0e81e 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,24 @@ namespace OpenFeatureTestApp After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI. +You can specify custom dimensions on spans created by the `TraceEnricherHook` by providing `TraceEnricherHookOptions` when adding the hook: + +```csharp +var options = TraceEnricherHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + +OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook(options)); +``` + +You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. + +```csharp +var options = TraceEnricherHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) + .Build(); +``` + ### Metrics Hook For this hook to function correctly a global `MeterProvider` must be set. diff --git a/src/OpenFeature/Hooks/TraceEnricherHook.cs b/src/OpenFeature/Hooks/TraceEnricherHook.cs index 08914b1ca..db97e1db5 100644 --- a/src/OpenFeature/Hooks/TraceEnricherHook.cs +++ b/src/OpenFeature/Hooks/TraceEnricherHook.cs @@ -12,6 +12,17 @@ namespace OpenFeature.Hooks; /// This is still experimental and subject to change. public class TraceEnricherHook : Hook { + private readonly TraceEnricherHookOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Optional configuration for the traces hook. + public TraceEnricherHook(TraceEnricherHookOptions? options = null) + { + _options = options ?? TraceEnricherHookOptions.Default; + } + /// /// Adds tags and events to the current for tracing purposes. /// @@ -31,8 +42,32 @@ public override ValueTask FinallyAsync(HookContext context, FlagEvaluation tags[kvp.Key] = kvp.Value; } + this.AddCustomDimensions(tags); + this.AddFlagMetadataDimensions(details.FlagMetadata, tags); + Activity.Current?.AddEvent(new ActivityEvent(evaluationEvent.Name, tags: tags)); return base.FinallyAsync(context, details, hints, cancellationToken); } + + private void AddCustomDimensions(ActivityTagsCollection tagList) + { + foreach (var customDimension in this._options.CustomDimensions) + { + tagList.Add(customDimension.Key, customDimension.Value); + } + } + + private void AddFlagMetadataDimensions(ImmutableMetadata? flagMetadata, ActivityTagsCollection tagList) + { + flagMetadata ??= new ImmutableMetadata(); + + foreach (var item in this._options.FlagMetadataCallbacks) + { + var flagMetadataCallback = item.Value; + var value = flagMetadataCallback(flagMetadata); + + tagList.Add(item.Key, value); + } + } } diff --git a/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs b/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs new file mode 100644 index 000000000..4278d7ccd --- /dev/null +++ b/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs @@ -0,0 +1,91 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hooks; + +/// +/// Configuration options for the . +/// +public sealed class TraceEnricherHookOptions +{ + /// + /// The default options for the . + /// + public static TraceEnricherHookOptions Default { get; } = new TraceEnricherHookOptions(); + + /// + /// Custom dimensions or tags to be associated with current in . + /// + public IReadOnlyCollection> CustomDimensions { get; } + + /// + /// Flag metadata callbacks to be associated with current . + /// + internal IReadOnlyCollection>> FlagMetadataCallbacks { get; } + + /// + /// Initializes a new instance of the class with default values. + /// + private TraceEnricherHookOptions() : this(null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Optional custom dimensions to tag Counter increments with. + /// Optional flag metadata callbacks to be associated with current . + internal TraceEnricherHookOptions(IReadOnlyCollection>? customDimensions = null, + IReadOnlyCollection>>? flagMetadataSelectors = null) + { + this.CustomDimensions = customDimensions ?? []; + this.FlagMetadataCallbacks = flagMetadataSelectors ?? []; + } + + /// + /// Creates a new builder for . + /// + public static TraceEnricherHookOptionsBuilder CreateBuilder() => new TraceEnricherHookOptionsBuilder(); + + /// + /// A builder for constructing instances. + /// + public sealed class TraceEnricherHookOptionsBuilder + { + private readonly List> _customDimensions = new List>(); + private readonly List>> _flagMetadataExpressions = new List>>(); + + /// + /// Adds a custom dimension. + /// + /// The key for the custom dimension. + /// The value for the custom dimension. + public TraceEnricherHookOptionsBuilder WithCustomDimension(string key, object? value) + { + this._customDimensions.Add(new KeyValuePair(key, value)); + return this; + } + + /// + /// Provide a callback to evaluate flag metadata and add it as a custom dimension on the current . + /// + /// The key for the custom dimension. + /// The callback to retrieve the value to tag successful flag evaluations. + /// + public TraceEnricherHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func flagMetadataCallback) + { + var kvp = new KeyValuePair>(key, flagMetadataCallback); + + this._flagMetadataExpressions.Add(kvp); + + return this; + } + + /// + /// Builds the instance. + /// + public TraceEnricherHookOptions Build() + { + return new TraceEnricherHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); + } + } +} diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs new file mode 100644 index 000000000..8f252db98 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs @@ -0,0 +1,84 @@ +using OpenFeature.Hooks; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Hooks; + +public class TraceEnricherHookOptionsTests +{ + [Fact] + public void Default_Options_Should_Be_Initialized_Correctly() + { + // Arrange & Act + var options = TraceEnricherHookOptions.Default; + + // Assert + Assert.NotNull(options); + Assert.Empty(options.CustomDimensions); + Assert.Empty(options.FlagMetadataCallbacks); + } + + [Fact] + public void CreateBuilder_Should_Return_New_Builder_Instance() + { + // Arrange & Act + var builder = TraceEnricherHookOptions.CreateBuilder(); + + // Assert + Assert.NotNull(builder); + Assert.IsType(builder); + } + + [Fact] + public void Build_Should_Return_Options() + { + // Arrange + var builder = TraceEnricherHookOptions.CreateBuilder(); + + // Act + var options = builder.Build(); + + // Assert + Assert.NotNull(options); + Assert.IsType(options); + } + + [Theory] + [InlineData("custom_dimension_value")] + [InlineData(1.0)] + [InlineData(2025)] + [InlineData(null)] + [InlineData(true)] + public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value) + { + // Arrange + var builder = TraceEnricherHookOptions.CreateBuilder(); + var key = "custom_dimension_key"; + + // Act + builder.WithCustomDimension(key, value); + var options = builder.Build(); + + // Assert + Assert.Single(options.CustomDimensions); + Assert.Equal(key, options.CustomDimensions.First().Key); + Assert.Equal(value, options.CustomDimensions.First().Value); + } + + [Fact] + public void Builder_Should_Allow_Adding_Flag_Metadata_Expressions() + { + // Arrange + var builder = TraceEnricherHookOptions.CreateBuilder(); + var key = "flag_metadata_key"; + static object? expression(ImmutableMetadata m) => m.GetString("flag_metadata_key"); + + // Act + builder.WithFlagEvaluationMetadata(key, expression); + var options = builder.Build(); + + // Assert + Assert.Single(options.FlagMetadataCallbacks); + Assert.Equal(key, options.FlagMetadataCallbacks.First().Key); + Assert.Equal(expression, options.FlagMetadataCallbacks.First().Value); + } +} diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs index f73d36200..2477b26a5 100644 --- a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs @@ -69,6 +69,84 @@ await traceEnricherHook.FinallyAsync(ctx, Assert.Contains(new KeyValuePair("feature_flag.result.value", "foo"), ev.Tags); } + [Fact] + public async Task TestFinally_WithCustomDimension() + { + // Arrange + var traceHookOptions = TraceEnricherHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + var traceEnricherHook = new TraceEnricherHook(traceHookOptions); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + var span = this._tracer.StartActiveSpan("my-span"); + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + ActivityEvent ev = rootSpan.Events.First(); + Assert.Equal("feature_flag.evaluation", ev.Name); + + Assert.Contains(new KeyValuePair("custom_dimension_key", "custom_dimension_value"), ev.Tags); + } + + [Fact] + public async Task TestFinally_WithFlagEvaluationMetadata() + { + // Arrange + var traceHookOptions = TraceEnricherHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("double", metadata => metadata.GetDouble("double")) + .WithFlagEvaluationMetadata("int", metadata => metadata.GetInt("int")) + .WithFlagEvaluationMetadata("bool", metadata => metadata.GetBool("bool")) + .WithFlagEvaluationMetadata("string", metadata => metadata.GetString("string")) + .Build(); + var traceEnricherHook = new TraceEnricherHook(traceHookOptions); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + var flagMetadata = new ImmutableMetadata(new Dictionary + { + { "double", 1.0 }, + { "int", 2025 }, + { "bool", true }, + { "string", "foo" } + }); + + // Act + var span = this._tracer.StartActiveSpan("my-span"); + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", flagMetadata: flagMetadata), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + ActivityEvent ev = rootSpan.Events.First(); + Assert.Equal("feature_flag.evaluation", ev.Name); + + Assert.Contains(new KeyValuePair("double", 1.0), ev.Tags); + Assert.Contains(new KeyValuePair("int", 2025), ev.Tags); + Assert.Contains(new KeyValuePair("bool", true), ev.Tags); + Assert.Contains(new KeyValuePair("string", "foo"), ev.Tags); + } + [Fact] public async Task TestFinally_NoSpan() { From 6ba5ea6657a34f54944e45c77b95a1953d80ef8c Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:38:28 +0100 Subject: [PATCH 2/2] Update Method name to be consistent with Tags found in Activity Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 6 ++--- src/OpenFeature/Hooks/TraceEnricherHook.cs | 10 ++++---- .../Hooks/TraceEnricherHookOptions.cs | 24 +++++++++---------- .../Hooks/TraceEnricherHookOptionsTests.cs | 10 ++++---- .../Hooks/TraceEnricherHookTests.cs | 2 +- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f2fb0e81e..4caa736d3 100644 --- a/README.md +++ b/README.md @@ -604,17 +604,17 @@ namespace OpenFeatureTestApp After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI. -You can specify custom dimensions on spans created by the `TraceEnricherHook` by providing `TraceEnricherHookOptions` when adding the hook: +You can specify custom tags on spans created by the `TraceEnricherHook` by providing `TraceEnricherHookOptions` when adding the hook: ```csharp var options = TraceEnricherHookOptions.CreateBuilder() - .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .WithTag("custom_dimension_key", "custom_dimension_value") .Build(); OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook(options)); ``` -You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. +You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. The below example will add a tag to the span with the key `boolean` and a value specified by the callback. ```csharp var options = TraceEnricherHookOptions.CreateBuilder() diff --git a/src/OpenFeature/Hooks/TraceEnricherHook.cs b/src/OpenFeature/Hooks/TraceEnricherHook.cs index db97e1db5..26a4f91ba 100644 --- a/src/OpenFeature/Hooks/TraceEnricherHook.cs +++ b/src/OpenFeature/Hooks/TraceEnricherHook.cs @@ -42,23 +42,23 @@ public override ValueTask FinallyAsync(HookContext context, FlagEvaluation tags[kvp.Key] = kvp.Value; } - this.AddCustomDimensions(tags); - this.AddFlagMetadataDimensions(details.FlagMetadata, tags); + this.AddCustomTags(tags); + this.AddFlagMetadataTags(details.FlagMetadata, tags); Activity.Current?.AddEvent(new ActivityEvent(evaluationEvent.Name, tags: tags)); return base.FinallyAsync(context, details, hints, cancellationToken); } - private void AddCustomDimensions(ActivityTagsCollection tagList) + private void AddCustomTags(ActivityTagsCollection tagList) { - foreach (var customDimension in this._options.CustomDimensions) + foreach (var customDimension in this._options.Tags) { tagList.Add(customDimension.Key, customDimension.Value); } } - private void AddFlagMetadataDimensions(ImmutableMetadata? flagMetadata, ActivityTagsCollection tagList) + private void AddFlagMetadataTags(ImmutableMetadata? flagMetadata, ActivityTagsCollection tagList) { flagMetadata ??= new ImmutableMetadata(); diff --git a/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs b/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs index 4278d7ccd..da3aa604c 100644 --- a/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs +++ b/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs @@ -13,9 +13,9 @@ public sealed class TraceEnricherHookOptions public static TraceEnricherHookOptions Default { get; } = new TraceEnricherHookOptions(); /// - /// Custom dimensions or tags to be associated with current in . + /// Custom tags to be associated with current in . /// - public IReadOnlyCollection> CustomDimensions { get; } + public IReadOnlyCollection> Tags { get; } /// /// Flag metadata callbacks to be associated with current . @@ -32,12 +32,12 @@ private TraceEnricherHookOptions() : this(null, null) /// /// Initializes a new instance of the class. /// - /// Optional custom dimensions to tag Counter increments with. + /// Optional custom tags to tag Counter increments with. /// Optional flag metadata callbacks to be associated with current . - internal TraceEnricherHookOptions(IReadOnlyCollection>? customDimensions = null, + internal TraceEnricherHookOptions(IReadOnlyCollection>? tags = null, IReadOnlyCollection>>? flagMetadataSelectors = null) { - this.CustomDimensions = customDimensions ?? []; + this.Tags = tags ?? []; this.FlagMetadataCallbacks = flagMetadataSelectors ?? []; } @@ -51,24 +51,24 @@ internal TraceEnricherHookOptions(IReadOnlyCollection public sealed class TraceEnricherHookOptionsBuilder { - private readonly List> _customDimensions = new List>(); + private readonly List> _customTags = new List>(); private readonly List>> _flagMetadataExpressions = new List>>(); /// - /// Adds a custom dimension. + /// Adds a custom tag to the . /// /// The key for the custom dimension. /// The value for the custom dimension. - public TraceEnricherHookOptionsBuilder WithCustomDimension(string key, object? value) + public TraceEnricherHookOptionsBuilder WithTag(string key, object? value) { - this._customDimensions.Add(new KeyValuePair(key, value)); + this._customTags.Add(new KeyValuePair(key, value)); return this; } /// - /// Provide a callback to evaluate flag metadata and add it as a custom dimension on the current . + /// Provide a callback to evaluate flag metadata and add it as a custom tag on the current . /// - /// The key for the custom dimension. + /// The key for the custom tag. /// The callback to retrieve the value to tag successful flag evaluations. /// public TraceEnricherHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func flagMetadataCallback) @@ -85,7 +85,7 @@ public TraceEnricherHookOptionsBuilder WithFlagEvaluationMetadata(string key, Fu /// public TraceEnricherHookOptions Build() { - return new TraceEnricherHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); + return new TraceEnricherHookOptions(this._customTags.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); } } } diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs index 8f252db98..003102a72 100644 --- a/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs @@ -13,7 +13,7 @@ public void Default_Options_Should_Be_Initialized_Correctly() // Assert Assert.NotNull(options); - Assert.Empty(options.CustomDimensions); + Assert.Empty(options.Tags); Assert.Empty(options.FlagMetadataCallbacks); } @@ -55,13 +55,13 @@ public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value) var key = "custom_dimension_key"; // Act - builder.WithCustomDimension(key, value); + builder.WithTag(key, value); var options = builder.Build(); // Assert - Assert.Single(options.CustomDimensions); - Assert.Equal(key, options.CustomDimensions.First().Key); - Assert.Equal(value, options.CustomDimensions.First().Value); + Assert.Single(options.Tags); + Assert.Equal(key, options.Tags.First().Key); + Assert.Equal(value, options.Tags.First().Value); } [Fact] diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs index 2477b26a5..5f0b617d3 100644 --- a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs @@ -74,7 +74,7 @@ public async Task TestFinally_WithCustomDimension() { // Arrange var traceHookOptions = TraceEnricherHookOptions.CreateBuilder() - .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .WithTag("custom_dimension_key", "custom_dimension_value") .Build(); var traceEnricherHook = new TraceEnricherHook(traceHookOptions); var evaluationContext = EvaluationContext.Empty;