diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 87238c15..4eb825fe 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -10,6 +10,7 @@ using OpenFeature.Providers.MultiProvider.DependencyInjection; using OpenFeature.Providers.MultiProvider.Models; using OpenFeature.Providers.MultiProvider.Strategies; +using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -25,10 +26,23 @@ builder.Services.AddProblemDetails(); // Configure OpenTelemetry +builder.Logging.AddOpenTelemetry(options => +{ + options + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService("openfeature-aspnetcore-sample")) + .AddOtlpExporter(); + + options.IncludeScopes = true; + options.IncludeFormattedMessage = true; +}); + builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource.AddService("openfeature-aspnetcore-sample")) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation() + .SetSampler(new AlwaysOnSampler()) .AddOtlpExporter()) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() diff --git a/src/OpenFeature/OpenFeatureActivitySource.cs b/src/OpenFeature/OpenFeatureActivitySource.cs new file mode 100644 index 00000000..3eb4bb97 --- /dev/null +++ b/src/OpenFeature/OpenFeatureActivitySource.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.Reflection; +using OpenFeature.Constant; + +namespace OpenFeature; + +static class OpenFeatureActivitySource +{ + static readonly ActivitySource Source = new("OpenFeature", GetLibraryVersion()); + + internal static Activity? StartActivity(string name) + => Source.StartActivity(name, ActivityKind.Internal); + + // Mapped to standard `error.types` https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/#evaluation-event + internal static string GetFlagEvaluationErrorDescription(this ErrorType errorType) => + errorType switch + { + ErrorType.ProviderNotReady => "provider_not_ready", + ErrorType.FlagNotFound => "flag_not_found", + ErrorType.ParseError => "parse_error", + ErrorType.TypeMismatch => "type_mismatch", + ErrorType.General => "general", + ErrorType.InvalidContext => "invalid_context", + ErrorType.TargetingKeyMissing => "targeting_key_missing", + ErrorType.ProviderFatal => "provider_fatal", + _ => "_OTHER" + }; + + // Mapped to standard `feature_flag.result.reason` https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/#evaluation-event + internal static string? GetFlagEvaluationReasonDescription(string? reason) => + reason switch + { + Reason.TargetingMatch => "targeting_match", + Reason.Split => "split", + Reason.Disabled => "disabled", + Reason.Default => "default", + Reason.Static => "static", + Reason.Cached => "cached", + Reason.Unknown => "unknown", + Reason.Error => "error", + _ => reason + }; + + static string GetLibraryVersion() + => typeof(OpenFeatureActivitySource).Assembly + .GetCustomAttribute()? + .InformationalVersion ?? "UNKNOWN"; +} diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 1f47d2d2..f276c582 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; @@ -211,6 +212,15 @@ private async Task> EvaluateFlagAsync( var resolveValueDelegate = providerInfo.Item1; var provider = providerInfo.Item2; + var activity = OpenFeatureActivitySource.StartActivity("feature_flag.evaluation"); + activity?.SetTag("feature_flag.key", flagKey); + + var providerMetadata = provider.GetMetadata(); + if (providerMetadata != null) + { + activity?.SetTag("feature_flag.provider.name", providerMetadata.Name); + } + // New up an evaluation context if one was not provided. context ??= EvaluationContext.Empty; @@ -261,8 +271,13 @@ private async Task> EvaluateFlagAsync( .ConfigureAwait(false)) .ToFlagEvaluationDetails(); + activity?.SetTag("feature_flag.result.reason", OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(evaluation.Reason)); + if (evaluation.ErrorType == ErrorType.None) { + activity?.SetTag("feature_flag.result.value", evaluation.Value); + activity?.SetTag("feature_flag.result.variant", evaluation.Variant); + await hookRunner.TriggerAfterHooksAsync( evaluation, options?.HookHints, @@ -271,6 +286,10 @@ await hookRunner.TriggerAfterHooksAsync( } else { + activity?.SetStatus(ActivityStatusCode.Error); + activity?.AddTag("error.type", OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType)); + activity?.SetTag("feature_flag.error.message", evaluation.ErrorMessage); + var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage); this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception); await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken) @@ -282,6 +301,11 @@ await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellat this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex); evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, string.Empty, ex.Message); + + activity?.SetStatus(ActivityStatusCode.Error); + activity?.AddTag("error.type", OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType)); + activity?.SetTag("feature_flag.error.message", evaluation.ErrorMessage); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) .ConfigureAwait(false); } @@ -290,6 +314,11 @@ await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToke var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, ex.Message); + + activity?.SetStatus(ActivityStatusCode.Error); + activity?.AddTag("error.type", OpenFeatureActivitySource.GetFlagEvaluationErrorDescription(evaluation.ErrorType)); + activity?.SetTag("feature_flag.error.message", evaluation.ErrorMessage); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) .ConfigureAwait(false); } @@ -302,6 +331,8 @@ await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancel .ConfigureAwait(false); } + activity?.Dispose(); + return evaluation; } diff --git a/test/OpenFeature.Tests/OpenFeatureActivitySourceTests.cs b/test/OpenFeature.Tests/OpenFeatureActivitySourceTests.cs new file mode 100644 index 00000000..2494c379 --- /dev/null +++ b/test/OpenFeature.Tests/OpenFeatureActivitySourceTests.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using OpenFeature.Constant; + +namespace OpenFeature.Tests; + +public class OpenFeatureActivitySourceTests +{ + [Fact] + public void StartActivity_ReturnsActivityWithCorrectName() + { + using var activityListener = new ActivityListener() + { + ShouldListenTo = source => true, + Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded + }; + + ActivitySource.AddActivityListener(activityListener); + + var activity = OpenFeatureActivitySource.StartActivity("test_activity"); + + Assert.NotNull(activity); + Assert.Equal("test_activity", activity.OperationName); + Assert.Equal("OpenFeature", activity.Source.Name); + Assert.NotNull(activity.Source.Version); + Assert.NotEmpty(activity.Source.Version); + Assert.Equal(ActivityKind.Internal, activity.Kind); + } + + [Theory] + [InlineData(ErrorType.ProviderNotReady, "provider_not_ready")] + [InlineData(ErrorType.FlagNotFound, "flag_not_found")] + [InlineData(ErrorType.ParseError, "parse_error")] + [InlineData(ErrorType.TypeMismatch, "type_mismatch")] + [InlineData(ErrorType.General, "general")] + [InlineData(ErrorType.InvalidContext, "invalid_context")] + [InlineData(ErrorType.TargetingKeyMissing, "targeting_key_missing")] + [InlineData(ErrorType.ProviderFatal, "provider_fatal")] + [InlineData((ErrorType)999, "_OTHER")] + public void GetFlagEvaluationErrorDescription_ReturnsCorrectDescription(ErrorType errorType, string expectedDescription) + { + var actual = errorType.GetFlagEvaluationErrorDescription(); + + Assert.Equal(expectedDescription, actual); + } + + [Theory] + [InlineData("TARGETING_MATCH", "targeting_match")] + [InlineData("SPLIT", "split")] + [InlineData("DISABLED", "disabled")] + [InlineData("DEFAULT", "default")] + [InlineData("STATIC", "static")] + [InlineData("CACHED", "cached")] + [InlineData("UNKNOWN", "unknown")] + [InlineData("ERROR", "error")] + [InlineData("OTHER", "OTHER")] + public void GetFlagEvaluationReasonDescription(string? reason, string expectedDescription) + { + var actual = OpenFeatureActivitySource.GetFlagEvaluationReasonDescription(reason); + + Assert.Equal(expectedDescription, actual); + } +} diff --git a/test/OpenFeature.Tests/OpenFeatureClientTracingTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTracingTests.cs new file mode 100644 index 00000000..d5601788 --- /dev/null +++ b/test/OpenFeature.Tests/OpenFeatureClientTracingTests.cs @@ -0,0 +1,225 @@ +using System.Diagnostics; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Tests; + +public class OpenFeatureClientTracingTests : IAsyncLifetime +{ + private readonly Api _api; + + private readonly List _exportedActivities; + private readonly ActivityListener _activityListener; + + public OpenFeatureClientTracingTests() + { + this._api = Api.Instance; + this._exportedActivities = []; + this._activityListener = new ActivityListener() + { + ShouldListenTo = source => source.Name == "OpenFeature", + Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => this._exportedActivities.Add(activity) + }; + + ActivitySource.AddActivityListener(this._activityListener); + } + + public Task InitializeAsync() + { + var flags = new Dictionary + { + ["bool-flag"] = new Flag(new Dictionary { { "on", true } }, "on"), + ["string-flag"] = new Flag(new Dictionary { { "on", "hello" } }, "on"), + ["int-flag"] = new Flag(new Dictionary { { "on", 42 } }, "on"), + ["double-flag"] = new Flag(new Dictionary { { "on", 3.14 } }, "on"), + ["object-flag"] = new Flag(new Dictionary { { "on", new Value(Structure.Builder().Set("value1", true).Build()) } }, "on") + }; + var provider = new InMemoryProvider(flags); + + return this._api.SetProviderAsync(provider); + } + + public static IEnumerable ResolveValue() + { + yield return new object[] + { + new Func>>((r) => r.GetBooleanDetailsAsync("bool-flag", false)) + }; + yield return new object[] + { + new Func>>((r) => r.GetStringDetailsAsync("string-flag", "def")) + }; + yield return new object[] + { + new Func>>((r) => r.GetIntegerDetailsAsync("int-flag", 3)) + }; + yield return new object[] + { + new Func>>((r) => r.GetDoubleDetailsAsync("double-flag", 3.5)) + }; + yield return new object[] + { + new Func>>((r) => r.GetObjectDetailsAsync("object-flag", new Value(Structure.Builder().Set("value1", true).Build()))) + }; + } + + [Theory] + [MemberData(nameof(ResolveValue))] + public async Task GetValueAsync_ShouldCreateSpan(Func>> act) + { + // Arrange + var client = this._api.GetClient("TestClient"); + + // Act + var result = await act(client); + + // Assert + Assert.Single(this._exportedActivities); + + var trace = this._exportedActivities[0]; + var tags = trace.TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Contains("feature_flag.key", tags); + Assert.Equal(result.FlagKey, tags["feature_flag.key"]); + + Assert.Contains("feature_flag.provider.name", tags); + Assert.Equal("InMemory", tags["feature_flag.provider.name"]); + + Assert.Contains("feature_flag.result.reason", tags); + Assert.Equal("static", tags["feature_flag.result.reason"]); + + Assert.Contains("feature_flag.result.value", tags); + Assert.Equal(result.Value, tags["feature_flag.result.value"]); + + Assert.Contains("feature_flag.result.variant", tags); + Assert.Equal("on", tags["feature_flag.result.variant"]); + } + + [Theory] + [MemberData(nameof(ResolveValue))] + public async Task GetValueAsync_WhenProviderErrors_ShouldCreateSpanWithErrorTags(Func>> act) + { + // Arrange + var mockProvider = Substitute.For(); + mockProvider.GetMetadata().Returns(new Metadata("TestProvider")); + mockProvider.ResolveBooleanValueAsync("bool-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails("bool-flag", true, ErrorType.ProviderFatal, errorMessage: "Error!"))); + mockProvider.ResolveStringValueAsync("string-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails("string-flag", "world", ErrorType.ProviderFatal, errorMessage: "Error!"))); + mockProvider.ResolveIntegerValueAsync("int-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails("int-flag", 42, ErrorType.ProviderFatal, errorMessage: "Error!"))); + mockProvider.ResolveDoubleValueAsync("double-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails("double-flag", 1.0f, ErrorType.ProviderFatal, errorMessage: "Error!"))); + mockProvider.ResolveStructureValueAsync("object-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails("object-flag", new Value(Structure.Builder().Build()), ErrorType.ProviderFatal, errorMessage: "Error!"))); + + await this._api.SetProviderAsync("domain", mockProvider); + + var client = this._api.GetClient("domain"); + + // Act + await act(client); + + // Assert + Assert.Single(this._exportedActivities); + + var trace = this._exportedActivities[0]; + Assert.Equal(ActivityStatusCode.Error, trace.Status); + + var tags = trace.TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Contains("error.type", tags); + Assert.Equal("provider_fatal", tags["error.type"]); + + Assert.Contains("feature_flag.error.message", tags); + Assert.Equal("Error!", tags["feature_flag.error.message"]); + } + + [Theory] + [MemberData(nameof(ResolveValue))] + public async Task GetValueAsync_WhenProviderThrowsException_ShouldCreateSpanWithErrorTags(Func>> act) + { + // Arrange + var mockProvider = Substitute.For(); + mockProvider.GetMetadata().Returns(new Metadata("TestProvider")); + mockProvider.ResolveBooleanValueAsync("bool-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new FeatureProviderException(ErrorType.TargetingKeyMissing, "Error!")); + mockProvider.ResolveStringValueAsync("string-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new FeatureProviderException(ErrorType.TargetingKeyMissing, "Error!")); + mockProvider.ResolveIntegerValueAsync("int-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new FeatureProviderException(ErrorType.TargetingKeyMissing, "Error!")); + mockProvider.ResolveDoubleValueAsync("double-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new FeatureProviderException(ErrorType.TargetingKeyMissing, "Error!")); + mockProvider.ResolveStructureValueAsync("object-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new FeatureProviderException(ErrorType.TargetingKeyMissing, "Error!")); + + await this._api.SetProviderAsync("domain", mockProvider); + + var client = this._api.GetClient("domain"); + + // Act + await act(client); + + // Assert + Assert.Single(this._exportedActivities); + + var trace = this._exportedActivities[0]; + Assert.Equal(ActivityStatusCode.Error, trace.Status); + + var tags = trace.TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Contains("error.type", tags); + Assert.Equal("targeting_key_missing", tags["error.type"]); + + Assert.Contains("feature_flag.error.message", tags); + Assert.Equal("Error!", tags["feature_flag.error.message"]); + } + + [Theory] + [MemberData(nameof(ResolveValue))] + public async Task GetValueAsync_WhenProviderThrowsGeneralException_ShouldCreateSpanWithErrorTags(Func>> act) + { + // Arrange + var mockProvider = Substitute.For(); + mockProvider.GetMetadata().Returns(new Metadata("TestProvider")); + mockProvider.ResolveBooleanValueAsync("bool-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Error!")); + mockProvider.ResolveStringValueAsync("string-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Error!")); + mockProvider.ResolveIntegerValueAsync("int-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Error!")); + mockProvider.ResolveDoubleValueAsync("double-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Error!")); + mockProvider.ResolveStructureValueAsync("object-flag", Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Error!")); + + await this._api.SetProviderAsync("domain", mockProvider); + + var client = this._api.GetClient("domain"); + + // Act + await act(client); + + // Assert + Assert.Single(this._exportedActivities); + + var trace = this._exportedActivities[0]; + Assert.Equal(ActivityStatusCode.Error, trace.Status); + + var tags = trace.TagObjects.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + Assert.Contains("error.type", tags); + Assert.Equal("general", tags["error.type"]); + + Assert.Contains("feature_flag.error.message", tags); + Assert.Equal("Error!", tags["feature_flag.error.message"]); + } + + public Task DisposeAsync() + { + this._activityListener.Dispose(); + + return this._api.ShutdownAsync(); + } +}