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;