Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions src/OpenFeature/Hooks/TraceEnricherHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ namespace OpenFeature.Hooks;
/// <remarks> This is still experimental and subject to change. </remarks>
public class TraceEnricherHook : Hook
{
private readonly TraceEnricherHookOptions _options;

/// <summary>
/// Initializes a new instance of the <see cref="TraceEnricherHook"/> class.
/// </summary>
/// <param name="options">Optional configuration for the traces hook.</param>
public TraceEnricherHook(TraceEnricherHookOptions? options = null)
{
_options = options ?? TraceEnricherHookOptions.Default;
}

/// <summary>
/// Adds tags and events to the current <see cref="Activity"/> for tracing purposes.
/// </summary>
Expand All @@ -31,8 +42,32 @@ public override ValueTask FinallyAsync<T>(HookContext<T> 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);
}
}
}
91 changes: 91 additions & 0 deletions src/OpenFeature/Hooks/TraceEnricherHookOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using OpenFeature.Model;

namespace OpenFeature.Hooks;

/// <summary>
/// Configuration options for the <see cref="TraceEnricherHookOptions"/>.
/// </summary>
public sealed class TraceEnricherHookOptions
{
/// <summary>
/// The default options for the <see cref="TraceEnricherHookOptions"/>.
/// </summary>
public static TraceEnricherHookOptions Default { get; } = new TraceEnricherHookOptions();

/// <summary>
/// Custom dimensions or tags to be associated with current <see cref="System.Diagnostics.Activity"/> in <see cref="TraceEnricherHook"/>.
/// </summary>
public IReadOnlyCollection<KeyValuePair<string, object?>> CustomDimensions { get; }

/// <summary>
/// Flag metadata callbacks to be associated with current <see cref="System.Diagnostics.Activity"/>.
/// </summary>
internal IReadOnlyCollection<KeyValuePair<string, Func<ImmutableMetadata, object?>>> FlagMetadataCallbacks { get; }

/// <summary>
/// Initializes a new instance of the <see cref="TraceEnricherHookOptions"/> class with default values.
/// </summary>
private TraceEnricherHookOptions() : this(null, null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="TraceEnricherHookOptions"/> class.
/// </summary>
/// <param name="customDimensions">Optional custom dimensions to tag Counter increments with.</param>
/// <param name="flagMetadataSelectors">Optional flag metadata callbacks to be associated with current <see cref="System.Diagnostics.Activity"/>.</param>
internal TraceEnricherHookOptions(IReadOnlyCollection<KeyValuePair<string, object?>>? customDimensions = null,
IReadOnlyCollection<KeyValuePair<string, Func<ImmutableMetadata, object?>>>? flagMetadataSelectors = null)
{
this.CustomDimensions = customDimensions ?? [];
this.FlagMetadataCallbacks = flagMetadataSelectors ?? [];
}

/// <summary>
/// Creates a new builder for <see cref="TraceEnricherHookOptions"/>.
/// </summary>
public static TraceEnricherHookOptionsBuilder CreateBuilder() => new TraceEnricherHookOptionsBuilder();

/// <summary>
/// A builder for constructing <see cref="TraceEnricherHookOptions"/> instances.
/// </summary>
public sealed class TraceEnricherHookOptionsBuilder
{
private readonly List<KeyValuePair<string, object?>> _customDimensions = new List<KeyValuePair<string, object?>>();
private readonly List<KeyValuePair<string, Func<ImmutableMetadata, object?>>> _flagMetadataExpressions = new List<KeyValuePair<string, Func<ImmutableMetadata, object?>>>();

/// <summary>
/// Adds a custom dimension.
/// </summary>
/// <param name="key">The key for the custom dimension.</param>
/// <param name="value">The value for the custom dimension.</param>
public TraceEnricherHookOptionsBuilder WithCustomDimension(string key, object? value)
{
this._customDimensions.Add(new KeyValuePair<string, object?>(key, value));
return this;
}

/// <summary>
/// Provide a callback to evaluate flag metadata and add it as a custom dimension on the current <see cref="System.Diagnostics.Activity"/>.
/// </summary>
/// <param name="key">The key for the custom dimension.</param>
/// <param name="flagMetadataCallback">The callback to retrieve the value to tag successful flag evaluations.</param>
/// <returns></returns>
public TraceEnricherHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func<ImmutableMetadata, object?> flagMetadataCallback)
{
var kvp = new KeyValuePair<string, Func<ImmutableMetadata, object?>>(key, flagMetadataCallback);

this._flagMetadataExpressions.Add(kvp);

return this;
}

/// <summary>
/// Builds the <see cref="TraceEnricherHookOptions"/> instance.
/// </summary>
public TraceEnricherHookOptions Build()
{
return new TraceEnricherHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly());
}
}
}
84 changes: 84 additions & 0 deletions test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -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<TraceEnricherHookOptions.TraceEnricherHookOptionsBuilder>(builder);
}

[Fact]
public void Build_Should_Return_Options()
{
// Arrange
var builder = TraceEnricherHookOptions.CreateBuilder();

// Act
var options = builder.Build();

// Assert
Assert.NotNull(options);
Assert.IsType<TraceEnricherHookOptions>(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);
}
}
78 changes: 78 additions & 0 deletions test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,84 @@ await traceEnricherHook.FinallyAsync(ctx,
Assert.Contains(new KeyValuePair<string, object?>("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<string>("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<string>("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"),
new Dictionary<string, object>()).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<string, object?>("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<string>("my-flag", "foo", Constant.FlagValueType.String,
new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);

var flagMetadata = new ImmutableMetadata(new Dictionary<string, object>
{
{ "double", 1.0 },
{ "int", 2025 },
{ "bool", true },
{ "string", "foo" }
});

// Act
var span = this._tracer.StartActiveSpan("my-span");
await traceEnricherHook.FinallyAsync(ctx,
new FlagEvaluationDetails<string>("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", flagMetadata: flagMetadata),
new Dictionary<string, object>()).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<string, object?>("double", 1.0), ev.Tags);
Assert.Contains(new KeyValuePair<string, object?>("int", 2025), ev.Tags);
Assert.Contains(new KeyValuePair<string, object?>("bool", true), ev.Tags);
Assert.Contains(new KeyValuePair<string, object?>("string", "foo"), ev.Tags);
}

[Fact]
public async Task TestFinally_NoSpan()
{
Expand Down