diff --git a/README.md b/README.md index d9a277c2..f5bf7f4f 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ public class MyHook : Hook // code to run if there's an error during before hooks or during flag evaluation } - public ValueTask FinallyAsync(HookContext context, IReadOnlyDictionary hints = null) + public ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary hints = null) { // code to run after all other stages, regardless of success/failure } diff --git a/src/OpenFeature/Hook.cs b/src/OpenFeature/Hook.cs index aea5dc15..c1dbbe38 100644 --- a/src/OpenFeature/Hook.cs +++ b/src/OpenFeature/Hook.cs @@ -31,7 +31,8 @@ public abstract class Hook /// Flag value type (bool|number|string|object) /// Modified EvaluationContext that is used for the flag evaluation public virtual ValueTask BeforeAsync(HookContext context, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { return new ValueTask(EvaluationContext.Empty); } @@ -44,8 +45,10 @@ public virtual ValueTask BeforeAsync(HookContext contex /// Caller provided data /// The . /// Flag value type (bool|number|string|object) - public virtual ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + public virtual ValueTask AfterAsync(HookContext context, + FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { return new ValueTask(); } @@ -58,8 +61,10 @@ public virtual ValueTask AfterAsync(HookContext context, FlagEvaluationDet /// Caller provided data /// The . /// Flag value type (bool|number|string|object) - public virtual ValueTask ErrorAsync(HookContext context, Exception error, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + public virtual ValueTask ErrorAsync(HookContext context, + Exception error, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { return new ValueTask(); } @@ -68,10 +73,14 @@ public virtual ValueTask ErrorAsync(HookContext context, Exception error, /// Called unconditionally after flag evaluation. /// /// Provides context of innovation + /// Flag evaluation information /// Caller provided data /// The . /// Flag value type (bool|number|string|object) - public virtual ValueTask FinallyAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + public virtual ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { return new ValueTask(); } diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index e774c6b5..787c89ee 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -244,7 +244,7 @@ private async Task> EvaluateFlagAsync( evaluationContextBuilder.Build() ); - FlagEvaluationDetails evaluation; + FlagEvaluationDetails? evaluation = null; try { var contextFromHooks = await this.TriggerBeforeHooksAsync(allHooks, hookContext, options, cancellationToken).ConfigureAwait(false); @@ -297,7 +297,9 @@ await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, exception, opti } finally { - await this.TriggerFinallyHooksAsync(allHooksReversed, hookContext, options, cancellationToken).ConfigureAwait(false); + evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, string.Empty, + "Evaluation failed to return a result."); + await this.TriggerFinallyHooksAsync(allHooksReversed, evaluation, hookContext, options, cancellationToken).ConfigureAwait(false); } return evaluation; @@ -351,14 +353,14 @@ private async Task TriggerErrorHooksAsync(IReadOnlyList hooks, HookCont } } - private async Task TriggerFinallyHooksAsync(IReadOnlyList hooks, HookContext context, - FlagEvaluationOptions? options, CancellationToken cancellationToken = default) + private async Task TriggerFinallyHooksAsync(IReadOnlyList hooks, FlagEvaluationDetails evaluation, + HookContext context, FlagEvaluationOptions? options, CancellationToken cancellationToken = default) { foreach (var hook in hooks) { try { - await hook.FinallyAsync(context, options?.HookHints, cancellationToken).ConfigureAwait(false); + await hook.FinallyAsync(context, evaluation, options?.HookHints, cancellationToken).ConfigureAwait(false); } catch (Exception e) { diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index 13d3fa93..1cab2d76 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -656,5 +656,25 @@ public async Task TheClient_MergesTheEvaluationContextInTheCorrectOrder(string k Assert.Equal(expectedResult, actualEvaluationContext.GetValue(key).AsString); } + + [Fact] + [Specification("4.3.8", "'evaluation details' passed to the 'finally' stage matches the evaluation details returned to the application author")] + public async Task FinallyHook_IncludesEvaluationDetails() + { + // Arrange + var provider = new TestProvider(); + var providerHook = Substitute.For(); + provider.AddHook(providerHook); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string flagName = "flagName"; + + // Act + var evaluationDetails = await client.GetBooleanDetailsAsync(flagName, true); + + // Assert + await providerHook.Received(1).FinallyAsync(Arg.Any>(), evaluationDetails); + } } } diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index cc8b08a1..48de5ee5 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -45,10 +45,10 @@ public async Task Hooks_Should_Be_Called_In_Order() invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - providerHook.FinallyAsync(Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - clientHook.FinallyAsync(Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - apiHook.FinallyAsync(Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); var testProvider = new TestProvider(); testProvider.AddHook(providerHook); @@ -70,10 +70,10 @@ await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empt invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - providerHook.FinallyAsync(Arg.Any>(), Arg.Any>()); - invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>()); - clientHook.FinallyAsync(Arg.Any>(), Arg.Any>()); - apiHook.FinallyAsync(Arg.Any>(), Arg.Any>()); + providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); }); _ = apiHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); @@ -84,10 +84,10 @@ await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empt _ = invocationHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); _ = clientHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); _ = apiHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = providerHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>()); - _ = invocationHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>()); - _ = clientHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>()); - _ = apiHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = apiHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); } [Fact] @@ -239,10 +239,12 @@ public async Task Hook_Should_Return_No_Errors() }; var hookContext = new HookContext("test", false, FlagValueType.Boolean, new ClientMetadata(null, null), new Metadata(null), EvaluationContext.Empty); + var evaluationDetails = + new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"); await hook.BeforeAsync(hookContext, hookHints); - await hook.AfterAsync(hookContext, new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"), hookHints); - await hook.FinallyAsync(hookContext, hookHints); + await hook.AfterAsync(hookContext, evaluationDetails, hookHints); + await hook.FinallyAsync(hookContext, evaluationDetails, hookHints); await hook.ErrorAsync(hookContext, new Exception(), hookHints); hookContext.ClientMetadata.Name.Should().BeNull(); @@ -269,7 +271,7 @@ public async Task Hook_Should_Execute_In_Correct_Order() hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>()); + _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); await Api.Instance.SetProviderAsync(featureProvider); var client = Api.Instance.GetClient(); @@ -282,12 +284,12 @@ public async Task Hook_Should_Execute_In_Correct_Order() hook.BeforeAsync(Arg.Any>(), Arg.Any>()); featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - hook.FinallyAsync(Arg.Any>(), Arg.Any>()); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); }); _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -331,8 +333,8 @@ public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); - hook2.FinallyAsync(Arg.Any>(), null).Returns(new ValueTask()); - hook1.FinallyAsync(Arg.Any>(), null).Throws(new Exception()); + hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Throws(new Exception()); await Api.Instance.SetProviderAsync(featureProvider); var client = Api.Instance.GetClient(); @@ -348,8 +350,8 @@ public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null); hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null); - hook2.FinallyAsync(Arg.Any>(), null); - hook1.FinallyAsync(Arg.Any>(), null); + hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null); }); _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); @@ -357,8 +359,8 @@ public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); _ = hook2.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); _ = hook1.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); - _ = hook2.Received(1).FinallyAsync(Arg.Any>(), null); - _ = hook1.Received(1).FinallyAsync(Arg.Any>(), null); + _ = hook2.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook1.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); } [Fact] @@ -458,7 +460,7 @@ public async Task Hook_Hints_May_Be_Optional() hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) .Returns(new ValueTask()); - hook.FinallyAsync(Arg.Any>(), Arg.Any>()) + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) .Returns(new ValueTask()); await Api.Instance.SetProviderAsync(featureProvider); @@ -471,7 +473,7 @@ public async Task Hook_Hints_May_Be_Optional() hook.Received().BeforeAsync(Arg.Any>(), Arg.Any>()); featureProvider.Received().ResolveBooleanValueAsync("test", false, Arg.Any()); hook.Received().AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - hook.Received().FinallyAsync(Arg.Any>(), Arg.Any>()); + hook.Received().FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); }); } @@ -489,7 +491,7 @@ public async Task When_Error_Occurs_In_Before_Hook_Should_Return_Default_Value() // Sequence hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(exceptionToThrow); hook.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); - hook.FinallyAsync(Arg.Any>(), null).Returns(new ValueTask()); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); var client = Api.Instance.GetClient(); client.AddHooks(hook); @@ -500,13 +502,13 @@ public async Task When_Error_Occurs_In_Before_Hook_Should_Return_Default_Value() { hook.BeforeAsync(Arg.Any>(), Arg.Any>()); hook.ErrorAsync(Arg.Any>(), Arg.Any(), null); - hook.FinallyAsync(Arg.Any>(), null); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null); }); resolvedFlag.Should().BeTrue(); _ = hook.Received(1).BeforeAsync(Arg.Any>(), null); _ = hook.Received(1).ErrorAsync(Arg.Any>(), exceptionToThrow, null); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), null); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); } [Fact] @@ -536,7 +538,7 @@ public async Task When_Error_Occurs_In_After_Hook_Should_Invoke_Error_Hook() hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) .Returns(new ValueTask()); - hook.FinallyAsync(Arg.Any>(), Arg.Any>()) + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) .Returns(new ValueTask()); await Api.Instance.SetProviderAsync(featureProvider); @@ -550,7 +552,7 @@ public async Task When_Error_Occurs_In_After_Hook_Should_Invoke_Error_Hook() { hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>()); + hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); }); await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); @@ -569,7 +571,7 @@ public async Task Successful_Resolution_Should_Pass_Cancellation_Token() hook.BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token).Returns(EvaluationContext.Empty); featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), cts.Token).Returns(new ResolutionDetails("test", false)); _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); await Api.Instance.SetProviderAsync(featureProvider); var client = Api.Instance.GetClient(); @@ -579,7 +581,7 @@ public async Task Successful_Resolution_Should_Pass_Cancellation_Token() _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); } [Fact] @@ -606,7 +608,7 @@ public async Task Failed_Resolution_Should_Pass_Cancellation_Token() hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) .Returns(new ValueTask()); - hook.FinallyAsync(Arg.Any>(), Arg.Any>()) + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) .Returns(new ValueTask()); await Api.Instance.SetProviderAsync(featureProvider); @@ -616,7 +618,7 @@ public async Task Failed_Resolution_Should_Pass_Cancellation_Token() _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); _ = hook.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>(), cts.Token); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); } diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index aa4dc784..724278e8 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -48,6 +48,7 @@ public override ValueTask ErrorAsync(HookContext context, Exception error, } public override ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) { Interlocked.Increment(ref this._finallyCallCount);