diff --git a/Directory.Packages.props b/Directory.Packages.props index ab69e552..91b5679a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,8 @@ + + @@ -37,4 +39,4 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 0aaeb399..f9f16ed6 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,121 @@ services.AddOpenFeature() }); ``` +### Trace Hook + +For this hook to function correctly a global `TracerProvider` must be set, an example of how to do this can be found below. + +The `open telemetry hook` taps into the after and error methods of the hook lifecycle to write `events` and `attributes` to an existing `span`. +For this, an active span must be set in the `Tracer`, otherwise the hook will no-op. + +### Example + +The following example demonstrates the use of the `OpenTelemetry hook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`. + +```csharp +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature.Hooks; +using OpenTelemetry.Exporter; +using OpenTelemetry.Resources; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace OpenFeatureTestApp +{ + class Hello { + static void Main(string[] args) { + + // set up the OpenTelemetry OTLP exporter + var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("my-tracer") + .ConfigureResource(r => r.AddService("jaeger-test")) + .AddOtlpExporter(o => + { + o.ExportProcessorType = ExportProcessorType.Simple; + }) + .Build(); + + // add the Otel Hook to the OpenFeature instance + OpenFeature.Api.Instance.AddHooks(new TracingHook()); + + var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013")); + + // Set the flagdProvider as the provider for the OpenFeature SDK + OpenFeature.Api.Instance.SetProvider(flagdProvider); + + var client = OpenFeature.Api.Instance.GetClient("my-app"); + + var val = client.GetBooleanValueAsync("myBoolFlag", false, null); + + // Print the value of the 'myBoolFlag' feature flag + System.Console.WriteLine(val.Result.ToString()); + } + } +} +``` + +After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI. + +### Metrics Hook + +For this hook to function correctly a global `MeterProvider` must be set. +`MetricsHook` performs metric collection by tapping into various hook stages. + +Below are the metrics extracted by this hook and dimensions they carry: + +| Metric key | Description | Unit | Dimensions | +| -------------------------------------- | ------------------------------- | ------------ | ----------------------------------- | +| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name | +| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason, variant | +| feature_flag.evaluation_error_total | Flag evaluation errors | Counter | key, provider name | +| feature_flag.evaluation_active_count | Active flag evaluations counter | Counter | key | + +Consider the following code example for usage. + +### Example + +The following example demonstrates the use of the `OpenTelemetry hook` with the `OpenFeature dotnet-sdk`. The metrics are sent to the `console`. + +```csharp +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature; +using OpenFeature.Hooks; +using OpenTelemetry; +using OpenTelemetry.Metrics; + +namespace OpenFeatureTestApp +{ + class Hello { + static void Main(string[] args) { + + // set up the OpenTelemetry OTLP exporter + var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter("OpenFeature") + .ConfigureResource(r => r.AddService("openfeature-test")) + .AddConsoleExporter() + .Build(); + + // add the Otel Hook to the OpenFeature instance + OpenFeature.Api.Instance.AddHooks(new MetricsHook()); + + var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013")); + + // Set the flagdProvider as the provider for the OpenFeature SDK + OpenFeature.Api.Instance.SetProvider(flagdProvider); + + var client = OpenFeature.Api.Instance.GetClient("my-app"); + + var val = client.GetBooleanValueAsync("myBoolFlag", false, null); + + // Print the value of the 'myBoolFlag' feature flag + System.Console.WriteLine(val.Result.ToString()); + } + } +} +``` + +After running this example, you should be able to see some metrics being generated into the console. + ## ⭐️ Support the project diff --git a/src/OpenFeature/Hooks/MetricsConstants.cs b/src/OpenFeature/Hooks/MetricsConstants.cs new file mode 100644 index 00000000..7b020fb3 --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsConstants.cs @@ -0,0 +1,20 @@ +namespace OpenFeature.Hooks; + +internal static class MetricsConstants +{ + internal const string ActiveCountName = "feature_flag.evaluation_active_count"; + internal const string RequestsTotalName = "feature_flag.evaluation_requests_total"; + internal const string SuccessTotalName = "feature_flag.evaluation_success_total"; + internal const string ErrorTotalName = "feature_flag.evaluation_error_total"; + + internal const string ActiveDescription = "active flag evaluations counter"; + internal const string RequestsDescription = "feature flag evaluation request counter"; + internal const string SuccessDescription = "feature flag evaluation success counter"; + internal const string ErrorDescription = "feature flag evaluation error counter"; + + internal const string KeyAttr = "key"; + internal const string ProviderNameAttr = "provider_name"; + internal const string VariantAttr = "variant"; + internal const string ReasonAttr = "reason"; + internal const string ExceptionAttr = "exception"; +} diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs new file mode 100644 index 00000000..b6d088a5 --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.Hooks; + +/// +/// Represents a hook for capturing metrics related to flag evaluations. +/// The meter instrumentation name is "OpenFeature". +/// +public class MetricsHook : Hook +{ + private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName(); + private static readonly string InstrumentationName = AssemblyName.Name ?? "OpenFeature"; + private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0"; + + private readonly UpDownCounter _evaluationActiveUpDownCounter; + private readonly Counter _evaluationRequestCounter; + private readonly Counter _evaluationSuccessCounter; + private readonly Counter _evaluationErrorCounter; + + /// + /// Initializes a new instance of the class. + /// + public MetricsHook() + { + var meter = new Meter(InstrumentationName, InstrumentationVersion); + + this._evaluationActiveUpDownCounter = meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription); + this._evaluationRequestCounter = meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription); + this._evaluationSuccessCounter = meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription); + this._evaluationErrorCounter = meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription); + } + + /// + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { MetricsConstants.KeyAttr, context.FlagKey }, + { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name } + }; + + this._evaluationActiveUpDownCounter.Add(1, tagList); + this._evaluationRequestCounter.Add(1, tagList); + + return base.BeforeAsync(context, hints, cancellationToken); + } + + + /// + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { MetricsConstants.KeyAttr, context.FlagKey }, + { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name }, + { MetricsConstants.VariantAttr, details.Variant ?? details.Value?.ToString() }, + { MetricsConstants.ReasonAttr, details.Reason ?? "UNKNOWN" } + }; + + this._evaluationSuccessCounter.Add(1, tagList); + + return base.AfterAsync(context, details, hints, cancellationToken); + } + + /// + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { MetricsConstants.KeyAttr, context.FlagKey }, + { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name }, + { MetricsConstants.ExceptionAttr, error.Message } + }; + + this._evaluationErrorCounter.Add(1, tagList); + + return base.ErrorAsync(context, error, hints, cancellationToken); + } + + /// + public override ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { MetricsConstants.KeyAttr, context.FlagKey }, + { MetricsConstants.ProviderNameAttr, context.ProviderMetadata.Name } + }; + + this._evaluationActiveUpDownCounter.Add(-1, tagList); + + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } +} diff --git a/src/OpenFeature/Hooks/TracingConstants.cs b/src/OpenFeature/Hooks/TracingConstants.cs new file mode 100644 index 00000000..27be6d7c --- /dev/null +++ b/src/OpenFeature/Hooks/TracingConstants.cs @@ -0,0 +1,9 @@ +namespace OpenFeature.Hooks; + +internal static class TracingConstants +{ + internal const string AttributeExceptionEventName = "exception"; + internal const string AttributeExceptionType = "exception.type"; + internal const string AttributeExceptionMessage = "exception.message"; + internal const string AttributeExceptionStacktrace = "exception.stacktrace"; +} diff --git a/src/OpenFeature/Hooks/TracingHook.cs b/src/OpenFeature/Hooks/TracingHook.cs new file mode 100644 index 00000000..b3afdd46 --- /dev/null +++ b/src/OpenFeature/Hooks/TracingHook.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.Hooks; + +/// +/// Stub. +/// +public class TracingHook : Hook +{ + /// + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + Activity.Current? + .SetTag("feature_flag.key", details.FlagKey) + .SetTag("feature_flag.variant", details.Variant) + .SetTag("feature_flag.provider_name", context.ProviderMetadata.Name) + .AddEvent(new ActivityEvent("feature_flag", tags: new ActivityTagsCollection + { + ["feature_flag.key"] = details.FlagKey, + ["feature_flag.variant"] = details.Variant, + ["feature_flag.provider_name"] = context.ProviderMetadata.Name + })); + + return default; + } + + /// + public override ValueTask ErrorAsync(HookContext context, System.Exception error, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { +#if NET9_0_OR_GREATER + // For dotnet9 we should use the new API https://learn.microsoft.com/en-gb/dotnet/api/system.diagnostics.activity.addexception?view=net-9.0 + Activity.Current?.AddException(error); +#else + var tagsCollection = new ActivityTagsCollection + { + { TracingConstants.AttributeExceptionType, error.GetType().FullName }, + { TracingConstants.AttributeExceptionStacktrace, error.ToString() }, + }; + if (!string.IsNullOrWhiteSpace(error.Message)) + { + tagsCollection.Add(TracingConstants.AttributeExceptionMessage, error.Message); + } + + Activity.Current?.AddEvent(new ActivityEvent(TracingConstants.AttributeExceptionEventName, default, tagsCollection)); +#endif + return default; + } +} diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs new file mode 100644 index 00000000..5192df37 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OpenFeature.Hooks; +using OpenFeature.Model; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using Xunit; + +namespace OpenFeature.Tests.Hooks; + +[CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)] +public class MetricsHookTest : IDisposable +{ + private readonly List _exportedItems; + private readonly MeterProvider _meterProvider; + + public MetricsHookTest() + { + // Arrange metrics collector + this._exportedItems = []; + this._meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter("OpenFeature") + .ConfigureResource(r => r.AddService("open-feature")) + .AddInMemoryExporter(this._exportedItems, + option => option.PeriodicExportingMetricReaderOptions = + new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 }) + .Build(); + } + +#pragma warning disable CA1816 + public void Dispose() + { + this._meterProvider.Shutdown(); + } +#pragma warning restore CA1816 + + [Fact] + public async Task After_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_success_total"; + var metricsHook = new MetricsHook(); + 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 + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Error_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_error_total"; + var metricsHook = new MetricsHook(); + 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 + await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Finally_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_active_count"; + var metricsHook = new MetricsHook(); + 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 evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric feature_flag.evaluation_success_total is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Before_Test() + { + // Arrange + const string metricName1 = "feature_flag.evaluation_active_count"; + const string metricName2 = "feature_flag.evaluation_requests_total"; + var metricsHook = new MetricsHook(); + 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 + await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1); + Assert.NotNull(metric1); + + var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2); + Assert.NotNull(metric2); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2); + Assert.True(noOtherMetric); + } +} diff --git a/test/OpenFeature.Tests/Hooks/TracingHookTests.cs b/test/OpenFeature.Tests/Hooks/TracingHookTests.cs new file mode 100644 index 00000000..0c236f40 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/TracingHookTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using OpenFeature.Hooks; +using OpenFeature.Model; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenFeature.Tests.Hooks; + +[CollectionDefinition(nameof(TracingHookTest), DisableParallelization = true)] +public class TracingHookTest : IDisposable +{ + private readonly List _exportedItems; + private readonly TracerProvider _tracerProvider; + private readonly Tracer _tracer; + + public TracingHookTest() + { + // List that will be populated with the traces by InMemoryExporter + this._exportedItems = []; + + // Create a new in-memory exporter + this._tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("my-tracer") + .ConfigureResource(r => r.AddService("in-memory-test")) + .AddInMemoryExporter(this._exportedItems) + .Build(); + + this._tracer = this._tracerProvider.GetTracer("my-tracer"); + } + +#pragma warning disable CA1816 + public void Dispose() + { + this._tracerProvider.Shutdown(); + } +#pragma warning restore CA1816 + + [Fact] + public async Task TestAfter() + { + // Arrange + var tracingHook = new TracingHook(); + 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 tracingHook.AfterAsync(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", ev.Name); + + Assert.Contains(new KeyValuePair("feature_flag.key", "my-flag"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.variant", "default"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.provider_name", "my-provider"), ev.Tags); + } + + [Fact] + public async Task TestAfter_NoSpan() + { + // Arrange + var tracingHook = new TracingHook(); + 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 + await tracingHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Empty(this._exportedItems); + } + + [Fact] + public async Task TestError() + { + // Arrange + var tracingHook = new TracingHook(); + 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 tracingHook.ErrorAsync(ctx, new Exception("unexpected error"), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + var ev = rootSpan.Events.First(); + + Assert.Equal("exception", ev.Name); + + Assert.Contains(new KeyValuePair("exception.message", "unexpected error"), ev.Tags); + } + + [Fact] + public async Task TestError_NoSpan() + { + // Arrange + var tracingHook = new TracingHook(); + 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 + await tracingHook.ErrorAsync(ctx, new Exception("unexpected error"), + new Dictionary()).ConfigureAwait(true); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Empty(this._exportedItems); + } +} diff --git a/test/OpenFeature.Tests/OpenFeature.Tests.csproj b/test/OpenFeature.Tests/OpenFeature.Tests.csproj index 4df0c681..b0eb6bd1 100644 --- a/test/OpenFeature.Tests/OpenFeature.Tests.csproj +++ b/test/OpenFeature.Tests/OpenFeature.Tests.csproj @@ -19,6 +19,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive