Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use ITargetingContext when calling GetVariantAsync #484

Merged
merged 11 commits into from
Aug 26, 2024
28 changes: 16 additions & 12 deletions src/Microsoft.FeatureManagement/FeatureManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ public async Task<bool> IsEnabledAsync(string feature)
/// Checks whether a given feature is enabled.
/// </summary>
/// <param name="feature">The name of the feature to check.</param>
/// <param name="appContext">A context providing information that can be used to evaluate whether a feature should be on or off.</param>
/// <param name="appContext">A context that provides information that can be used to evaluate whether a feature should be on or off.</param>
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
/// <returns>True if the feature is enabled, otherwise false.</returns>
public async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appContext)
{
Expand All @@ -170,7 +170,7 @@ public async ValueTask<bool> IsEnabledAsync(string feature, CancellationToken ca
/// Checks whether a given feature is enabled.
/// </summary>
/// <param name="feature">The name of the feature to check.</param>
/// <param name="appContext">A context providing information that can be used to evaluate whether a feature should be on or off.</param>
/// <param name="appContext">A context that provides information that can be used to evaluate whether a feature should be on or off.</param>
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>True if the feature is enabled, otherwise false.</returns>
public async ValueTask<bool> IsEnabledAsync<TContext>(string feature, TContext appContext, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -216,7 +216,7 @@ public async ValueTask<Variant> GetVariantAsync(string feature, CancellationToke
throw new ArgumentNullException(nameof(feature));
}

EvaluationEvent evaluationEvent = await EvaluateFeature<TargetingContext>(feature, context: null, useContext: false, cancellationToken);
EvaluationEvent evaluationEvent = await EvaluateFeature<object>(feature, context: null, useContext: false, cancellationToken);

return evaluationEvent.Variant;
}
Expand All @@ -225,10 +225,10 @@ public async ValueTask<Variant> GetVariantAsync(string feature, CancellationToke
/// Gets the assigned variant for a specific feature.
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
/// <param name="feature">The name of the feature to evaluate.</param>
/// <param name="context">An instance of <see cref="TargetingContext"/> used to evaluate which variant the user will be assigned.</param>
/// <param name="context">A context that provides information to evaluate which variant will be assigned to the user.</param>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>A variant assigned to the user based on the feature's configured allocation.</returns>
public async ValueTask<Variant> GetVariantAsync(string feature, TargetingContext context, CancellationToken cancellationToken = default)
public async ValueTask<Variant> GetVariantAsync<TContext>(string feature, TContext context, CancellationToken cancellationToken = default)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it required for TContext to implement ITargetingContext for this API to work? If so, what's the point to have TContext?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While GetVariantAsync, the first step is to evaluate the feature flag state according to the feature filter.

If the user uses a contextual feature filter which requires TContext for filter evaluation.
TContext is not necessarily ITargetingContext. The feature manager will still use it to evaluate whether feature flag is enabled or disabled.
And he can also use two variants through DefaultWhenEnabled and DefaultWhenDisabled which don't require TargetingContext to do the variant allocation.

Previously, we only allow user to pass TargetingContext which will make it impossible to use contextual filter.

How about this?

If the TContext doesn't implement ITargetingContext, the feature manager will still give it another chance to get targeting context from the targeting context accessor.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are talking about an edge case where ITargetingContext may not be necessary, but general speaking, ITargetingContext is required for variant assignment. We should enforce the context to implement ITargetingContext.

If the TContext doesn't implement ITargetingContext, the feature manager will still give it another chance to get targeting context from the targeting context accessor.

No, we shouldn't use ambient context in contextual feature evaluation.

Copy link
Contributor Author

@zhiyuanliang-ms zhiyuanliang-ms Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ITargetingContext is required for variant assignment. We should enforce the context to implement ITargetingContext.

ITargetingContext is a public interface, we cannot modify it to avoid breaking changes.
If in the future, we want to add new fields in targeting context, we may create a new interface called IAttributeTargetingContext. If we enforce the context to implement ITargetingContext here, we will constrain ourselves.

TargetingContext should be sealed. We had an oversight on it. I created an issue #486 Currently, we are in an very uncomfortable spot that we cannot modify anything related to targeting context to avoid potential breaking changes.

Copy link
Contributor

@rossgrambo rossgrambo Aug 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't think Variants need to be fully tied to targeting. We don't support it yet- but it seems quite reasonable to want variants without wanting user based allocation.

Making it TContext also aligns it with IsEnabled, which is more consistent. IsEnabled is also able to use Allocation in the same way that GetVariant is. So whatever we decide on- the two interfaces should use the same.

{
if (string.IsNullOrEmpty(feature))
{
Expand Down Expand Up @@ -262,15 +262,19 @@ private async ValueTask<EvaluationEvent> EvaluateFeature<TContext>(string featur

//
// Determine Targeting Context
TargetingContext targetingContext;
TargetingContext targetingContext = null;

if (useContext)
if (!useContext)
{
targetingContext = context as TargetingContext;
targetingContext = await ResolveTargetingContextAsync(cancellationToken).ConfigureAwait(false);
}
else
else if (context is ITargetingContext targetingInfo)
{
targetingContext = await ResolveTargetingContextAsync(cancellationToken).ConfigureAwait(false);
targetingContext = new TargetingContext
{
UserId = targetingInfo.UserId,
Groups = targetingInfo.Groups
};
}

evaluationEvent.TargetingContext = targetingContext;
Expand Down Expand Up @@ -314,7 +318,7 @@ private async ValueTask<EvaluationEvent> EvaluateFeature<TContext>(string featur

if (useContext)
{
message = $"A {nameof(TargetingContext)} required for variant assignment was not provided.";
message = $"A {nameof(ITargetingContext)} required for variant assignment was not provided.";
}
else if (TargetingContextAccessor == null)
{
Expand Down Expand Up @@ -601,7 +605,7 @@ private async ValueTask<TargetingContext> ResolveTargetingContextAsync(Cancellat
return context;
}

private ValueTask<VariantDefinition> AssignVariantAsync(EvaluationEvent evaluationEvent, TargetingContext targetingContext, CancellationToken cancellationToken)
private ValueTask<VariantDefinition> AssignVariantAsync(EvaluationEvent evaluationEvent, ITargetingContext targetingContext, CancellationToken cancellationToken)
{
Debug.Assert(evaluationEvent != null);

Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public async ValueTask<Variant> GetVariantAsync(string feature, CancellationToke
return variant;
}

public async ValueTask<Variant> GetVariantAsync(string feature, TargetingContext context, CancellationToken cancellationToken)
public async ValueTask<Variant> GetVariantAsync<TContext>(string feature, TContext context, CancellationToken cancellationToken)
{
string cacheKey = GetVariantCacheKey(feature);

Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.FeatureManagement/IFeatureManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public interface IFeatureManager
/// Checks whether a given feature is enabled.
/// </summary>
/// <param name="feature">The name of the feature to check.</param>
/// <param name="context">A context providing information that can be used to evaluate whether a feature should be on or off.</param>
/// <param name="context">A context that provides information that can be used to evaluate whether a feature should be on or off.</param>
/// <returns>True if the feature is enabled, otherwise false.</returns>
Task<bool> IsEnabledAsync<TContext>(string feature, TContext context);
}
Expand Down
7 changes: 3 additions & 4 deletions src/Microsoft.FeatureManagement/IVariantFeatureManager.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.FeatureManagement.FeatureFilters;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -32,7 +31,7 @@ public interface IVariantFeatureManager
/// Checks whether a given feature is enabled.
/// </summary>
/// <param name="feature">The name of the feature to check.</param>
/// <param name="context">A context providing information that can be used to evaluate whether a feature should be on or off.</param>
/// <param name="context">A context that provides information that can be used to evaluate whether a feature should be on or off.</param>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>True if the feature is enabled, otherwise false.</returns>
ValueTask<bool> IsEnabledAsync<TContext>(string feature, TContext context, CancellationToken cancellationToken = default);
Expand All @@ -49,9 +48,9 @@ public interface IVariantFeatureManager
/// Gets the assigned variant for a specific feature.
/// </summary>
/// <param name="feature">The name of the feature to evaluate.</param>
/// <param name="context">An instance of <see cref="TargetingContext"/> used to evaluate which variant the user will be assigned.</param>
/// <param name="context">A context that provides information to evaluate which variant will be assigned to the user.</param>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>A variant assigned to the user based on the feature's configured allocation.</returns>
ValueTask<Variant> GetVariantAsync(string feature, TargetingContext context, CancellationToken cancellationToken = default);
ValueTask<Variant> GetVariantAsync<TContext>(string feature, TContext context, CancellationToken cancellationToken = default);
}
}
28 changes: 28 additions & 0 deletions src/Microsoft.FeatureManagement/VariantFeatureManagerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.FeatureManagement.FeatureFilters;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.FeatureManagement
{
/// <summary>
/// Extensions used to provide an overload for <see cref="IVariantFeatureManager.GetVariantAsync"/> that accepts a <see cref="TargetingContext"/> type directly.
/// </summary>
public static class VariantFeatureManagerExtensions
{
/// <summary>
/// Gets the assigned variant for a specific feature.
/// </summary>
/// <param name="variantFeatureManager">The <see cref="IVariantFeatureManager"/> instance.</param>
/// <param name="feature">The name of the feature to evaluate.</param>
/// <param name="context">An instance of <see cref="TargetingContext"/> used to evaluate which variant the user will be assigned.</param>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>A variant assigned to the user based on the feature's configured allocation.</returns>
public static ValueTask<Variant> GetVariantAsync(this IVariantFeatureManager variantFeatureManager, string feature, TargetingContext context, CancellationToken cancellationToken = default)
{
return variantFeatureManager.GetVariantAsync(feature, context, cancellationToken);
}
}
}
47 changes: 47 additions & 0 deletions tests/Tests.FeatureManagement/FeatureManagementTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,53 @@ public async Task VariantBasedInjection()
}
);
}

[Fact]
public async Task VariantFeatureFlagWithContextualFeatureFilter()
{
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();

IServiceCollection services = new ServiceCollection();

services.AddSingleton(configuration)
.AddFeatureManagement()
.AddFeatureFilter<ContextualTestFilter>();

ServiceProvider serviceProvider = services.BuildServiceProvider();

ContextualTestFilter contextualTestFeatureFilter = (ContextualTestFilter)serviceProvider.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>().First(f => f is ContextualTestFilter);

contextualTestFeatureFilter.ContextualCallback = (ctx, accountContext) =>
{
var allowedAccounts = new List<string>();

ctx.Parameters.Bind("AllowedAccounts", allowedAccounts);

return allowedAccounts.Contains(accountContext.AccountId);
};

IVariantFeatureManager featureManager = serviceProvider.GetRequiredService<IVariantFeatureManager>();

AppContext context = new AppContext();

context.AccountId = "NotEnabledAccount";

Assert.False(await featureManager.IsEnabledAsync(Features.ContextualFeatureWithVariant, context));

Variant variant = await featureManager.GetVariantAsync(Features.ContextualFeatureWithVariant, context);

Assert.Equal("Small", variant.Name);

context.AccountId = "abc";

Assert.True(await featureManager.IsEnabledAsync(Features.ContextualFeatureWithVariant, context));

variant = await featureManager.GetVariantAsync(Features.ContextualFeatureWithVariant, context);

Assert.Equal("Big", variant.Name);
}
}

public class FeatureManagementTelemetryTest
Expand Down
1 change: 1 addition & 0 deletions tests/Tests.FeatureManagement/Features.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ static class Features
public const string VariantImplementationFeature = "VariantImplementationFeature";
public const string OnTelemetryTestFeature = "OnTelemetryTestFeature";
public const string OffTelemetryTestFeature = "OffTelemetryTestFeature";
public const string ContextualFeatureWithVariant = "ContextualFeatureWithVariant";
}
}
28 changes: 28 additions & 0 deletions tests/Tests.FeatureManagement/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,34 @@
"telemetry": {
"enabled": true
}
},
{
"id": "ContextualFeatureWithVariant",
"enabled": true,
"conditions": {
"client_filters": [
{
"name": "ContextualTest",
"parameters": {
"AllowedAccounts": [
"abc"
]
}
}
]
},
"variants": [
{
"name": "Big"
},
{
"name": "Small"
}
],
"allocation": {
"default_when_enabled": "Big",
"default_when_disabled": "Small"
}
}
]
}
Expand Down
Loading