diff --git a/src/OpenFeature/Telemetry/EvaluationEvent.cs b/src/OpenFeature/Telemetry/EvaluationEvent.cs new file mode 100644 index 00000000..51506ad7 --- /dev/null +++ b/src/OpenFeature/Telemetry/EvaluationEvent.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace OpenFeature.Telemetry; + +/// +/// Represents an evaluation event for feature flags. +/// +public class EvaluationEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the event. + /// The attributes of the event. + public EvaluationEvent(string name, IDictionary attributes) + { + Name = name; + Attributes = new Dictionary(attributes); + } + + /// + /// Gets the name of the event. + /// + public string Name { get; } + + /// + /// Gets the attributes of the event. + /// + public IReadOnlyDictionary Attributes { get; } +} diff --git a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs new file mode 100644 index 00000000..2f73224f --- /dev/null +++ b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Telemetry; + +/// +/// Class for creating evaluation events for feature flags. +/// +public sealed class EvaluationEventBuilder +{ + private const string EventName = "feature_flag.evaluation"; + + /// + /// Gets the default instance of the . + /// + public static EvaluationEventBuilder Default { get; } = new(); + + /// + /// Creates an evaluation event based on the provided hook context and flag evaluation details. + /// + /// The context of the hook containing flag key and provider metadata. + /// The details of the flag evaluation including reason, variant, and metadata. + /// An instance of containing the event name, attributes, and body. + public EvaluationEvent Build(HookContext hookContext, FlagEvaluationDetails details) + { + var attributes = new Dictionary + { + { TelemetryConstants.Key, hookContext.FlagKey }, + { TelemetryConstants.Provider, hookContext.ProviderMetadata.Name } + }; + + attributes[TelemetryConstants.Reason] = !string.IsNullOrWhiteSpace(details.Reason) + ? details.Reason?.ToLowerInvariant() + : Reason.Unknown.ToLowerInvariant(); + attributes[TelemetryConstants.Variant] = details.Variant; + attributes[TelemetryConstants.Value] = details.Value; + + if (details.FlagMetadata != null) + { + attributes[TelemetryConstants.ContextId] = details.FlagMetadata.GetString(TelemetryFlagMetadata.ContextId); + attributes[TelemetryConstants.FlagSetId] = details.FlagMetadata.GetString(TelemetryFlagMetadata.FlagSetId); + attributes[TelemetryConstants.Version] = details.FlagMetadata.GetString(TelemetryFlagMetadata.Version); + } + + if (details.ErrorType != ErrorType.None) + { + attributes[TelemetryConstants.ErrorCode] = details.ErrorType.ToString().ToLowerInvariant(); + + if (!string.IsNullOrWhiteSpace(details.ErrorMessage)) + { + attributes[TelemetryConstants.ErrorMessage] = details.ErrorMessage; + } + } + + return new EvaluationEvent(EventName, attributes); + } +} diff --git a/src/OpenFeature/Telemetry/TelemetryConstants.cs b/src/OpenFeature/Telemetry/TelemetryConstants.cs new file mode 100644 index 00000000..62730666 --- /dev/null +++ b/src/OpenFeature/Telemetry/TelemetryConstants.cs @@ -0,0 +1,59 @@ +namespace OpenFeature.Telemetry; + +/// +/// The attributes of an OpenTelemetry compliant event for flag evaluation. +/// +/// +public static class TelemetryConstants +{ + /// + /// The lookup key of the feature flag. + /// + public const string Key = "feature_flag.key"; + + /// + /// Describes a class of error the operation ended with. + /// + public const string ErrorCode = "error.type"; + + /// + /// A message explaining the nature of an error occurring during flag evaluation. + /// + public const string ErrorMessage = "error.message"; + + /// + /// A semantic identifier for an evaluated flag value. + /// + public const string Variant = "feature_flag.result.variant"; + + /// + /// The evaluated value of the feature flag. + /// + public const string Value = "feature_flag.result.value"; + + /// + /// The unique identifier for the flag evaluation context. For example, the targeting key. + /// + public const string ContextId = "feature_flag.context.id"; + + /// + /// The reason code which shows how a feature flag value was determined. + /// + public const string Reason = "feature_flag.result.reason"; + + /// + /// Describes a class of error the operation ended with. + /// + public const string Provider = "feature_flag.provider.name"; + + /// + /// The identifier of the flag set to which the feature flag belongs. + /// + public const string FlagSetId = "feature_flag.set.id"; + + /// + /// The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + /// + public const string Version = "feature_flag.version"; + +} diff --git a/src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs b/src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs new file mode 100644 index 00000000..40b58f04 --- /dev/null +++ b/src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs @@ -0,0 +1,25 @@ +namespace OpenFeature.Telemetry; + +/// +/// Well-known flag metadata attributes for telemetry events. +/// See also: https://openfeature.dev/specification/appendix-d#flag-metadata +/// +public static class TelemetryFlagMetadata +{ + /// + /// The context identifier returned in the flag metadata uniquely identifies + /// the subject of the flag evaluation. If not available, the targeting key + /// should be used. + /// + public const string ContextId = "contextId"; + + /// + /// ///A logical identifier for the flag set. + /// + public const string FlagSetId = "flagSetId"; + + /// + /// A version string (format unspecified) for the flag or flag set. + /// + public const string Version = "version"; +} diff --git a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs new file mode 100644 index 00000000..3b02a8ee --- /dev/null +++ b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Telemetry; +using Xunit; + +namespace OpenFeature.Tests.Telemetry; + +public class EvaluationEventBuilderTests +{ + private readonly EvaluationEventBuilder _builder = EvaluationEventBuilder.Default; + + [Fact] + public void Build_ShouldReturnEventWithCorrectAttributes() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value(), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var metadata = new Dictionary + { + { "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" } + }; + var flagMetadata = new ImmutableMetadata(metadata); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: "reason", variant: "variant", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal("feature_flag.evaluation", evaluationEvent.Name); + Assert.Equal("flagKey", evaluationEvent.Attributes[TelemetryConstants.Key]); + Assert.Equal("provider", evaluationEvent.Attributes[TelemetryConstants.Provider]); + Assert.Equal("reason", evaluationEvent.Attributes[TelemetryConstants.Reason]); + Assert.Equal("variant", evaluationEvent.Attributes[TelemetryConstants.Variant]); + Assert.Equal("contextId", evaluationEvent.Attributes[TelemetryConstants.ContextId]); + Assert.Equal("flagSetId", evaluationEvent.Attributes[TelemetryConstants.FlagSetId]); + Assert.Equal("version", evaluationEvent.Attributes[TelemetryConstants.Version]); + } + + [Fact] + public void Build_ShouldHandleErrorDetails() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value(), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var metadata = new Dictionary + { + { "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" } + }; + var flagMetadata = new ImmutableMetadata(metadata); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.General, + errorMessage: "errorMessage", reason: "reason", variant: "variant", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]); + Assert.Equal("errorMessage", evaluationEvent.Attributes[TelemetryConstants.ErrorMessage]); + } + + [Fact] + public void Build_ShouldHandleMissingVariant() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var metadata = new Dictionary + { + { "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" } + }; + var flagMetadata = new ImmutableMetadata(metadata); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: "reason", variant: null, flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Variant]); + } + + [Fact] + public void Build_ShouldHandleMissingFlagMetadata() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var flagMetadata = new ImmutableMetadata(); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: "reason", variant: "", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.ContextId]); + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.FlagSetId]); + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Version]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Build_ShouldHandleMissingReason(string? reason) + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var flagMetadata = new ImmutableMetadata(); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: reason, variant: "", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal(Reason.Unknown.ToLowerInvariant(), evaluationEvent.Attributes[TelemetryConstants.Reason]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Build_ShouldHandleErrorWithEmptyErrorMessage(string? errorMessage) + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var flagMetadata = new ImmutableMetadata(); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.General, + errorMessage: errorMessage, reason: "reason", variant: "", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]); + Assert.False(evaluationEvent.Attributes.ContainsKey(TelemetryConstants.ErrorMessage)); + } + + [Fact] + public void Build_ShouldIncludeValueAttributeInEvent() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value(), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var testValue = new Value("test-value"); + var details = new FlagEvaluationDetails("flagKey", testValue, ErrorType.None, + reason: "reason", variant: "variant", flagMetadata: new ImmutableMetadata()); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal(testValue, evaluationEvent.Attributes[TelemetryConstants.Value]); + } +}