diff --git a/README.md b/README.md index e79fd544a..4caa736d3 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 tags on spans created by the `TraceEnricherHook` by providing `TraceEnricherHookOptions` when adding the hook: + +```csharp +var options = TraceEnricherHookOptions.CreateBuilder() + .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`. 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() + .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..26a4f91ba 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.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 AddCustomTags(ActivityTagsCollection tagList) + { + foreach (var customDimension in this._options.Tags) + { + tagList.Add(customDimension.Key, customDimension.Value); + } + } + + private void AddFlagMetadataTags(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..da3aa604c --- /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 tags to be associated with current in . + /// + public IReadOnlyCollection> Tags { 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 tags to tag Counter increments with. + /// Optional flag metadata callbacks to be associated with current . + internal TraceEnricherHookOptions(IReadOnlyCollection>? tags = null, + IReadOnlyCollection>>? flagMetadataSelectors = null) + { + this.Tags = tags ?? []; + 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> _customTags = new List>(); + private readonly List>> _flagMetadataExpressions = new List>>(); + + /// + /// Adds a custom tag to the . + /// + /// The key for the custom dimension. + /// The value for the custom dimension. + public TraceEnricherHookOptionsBuilder WithTag(string key, object? value) + { + this._customTags.Add(new KeyValuePair(key, value)); + return this; + } + + /// + /// Provide a callback to evaluate flag metadata and add it as a custom tag on the current . + /// + /// The key for the custom tag. + /// 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._customTags.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..003102a72 --- /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.Tags); + 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.WithTag(key, value); + var options = builder.Build(); + + // Assert + Assert.Single(options.Tags); + Assert.Equal(key, options.Tags.First().Key); + Assert.Equal(value, options.Tags.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..5f0b617d3 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() + .WithTag("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() {