diff --git a/Microsoft.FeatureManagement.sln b/Microsoft.FeatureManagement.sln index bad8dbe5..90205454 100644 --- a/Microsoft.FeatureManagement.sln +++ b/Microsoft.FeatureManagement.sln @@ -19,7 +19,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "examples\Cons EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TargetingConsoleApp", "examples\TargetingConsoleApp\TargetingConsoleApp.csproj", "{6558C21E-CF20-4278-AA08-EB9D1DF29D66}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPages", "examples\RazorPages\RazorPages.csproj", "{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPages", "examples\RazorPages\RazorPages.csproj", "{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.FeatureManagement.AspNetCore", "tests\Tests.FeatureManagement.AspNetCore\Tests.FeatureManagement.AspNetCore.csproj", "{FC0DC3E2-5646-4AEC-A7DB-2D6167BC3BB4}" EndProject diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 6d48941e..5359d060 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -148,6 +148,10 @@ We support var enabledFor = new List(); + bool telemetryEnabled = false; + + Dictionary telemetryMetadata = null; + string val = configurationSection.Value; // configuration[$"{featureName}"]; if (string.IsNullOrEmpty(val)) @@ -283,6 +287,17 @@ We support variants.Add(variant); } } + + telemetryEnabled = configurationSection.GetValue("TelemetryEnabled"); + + IConfigurationSection telemetryMetadataSection = configurationSection.GetSection("TelemetryMetadata"); + + if (telemetryMetadataSection.Exists()) + { + telemetryMetadata = new Dictionary(); + + telemetryMetadata = telemetryMetadataSection.GetChildren().ToDictionary(x => x.Key, x => x.Value); + } } return new FeatureDefinition() @@ -292,7 +307,9 @@ We support RequirementType = requirementType, Status = featureStatus, Allocation = allocation, - Variants = variants + Variants = variants, + TelemetryEnabled = telemetryEnabled, + TelemetryMetadata = telemetryMetadata }; } diff --git a/src/Microsoft.FeatureManagement/FeatureDefinition.cs b/src/Microsoft.FeatureManagement/FeatureDefinition.cs index 53819fc6..48ebeb00 100644 --- a/src/Microsoft.FeatureManagement/FeatureDefinition.cs +++ b/src/Microsoft.FeatureManagement/FeatureDefinition.cs @@ -42,5 +42,15 @@ public class FeatureDefinition /// A list of variant definitions that specify a configuration to return when assigned. /// public IEnumerable Variants { get; set; } = Enumerable.Empty(); + + /// + /// A flag to enable or disable sending telemetry events to the registered . + /// + public bool TelemetryEnabled { get; set; } + + /// + /// A container for metadata relevant to telemetry. + /// + public IReadOnlyDictionary TelemetryMetadata { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs new file mode 100644 index 00000000..72de9c40 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement.Telemetry; +using System; +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement +{ + /// + /// Extensions used to add feature management functionality. + /// + public static class FeatureManagementBuilderExtensions + { + /// + /// Adds a telemetry publisher to the feature management system. + /// + /// The used to customize feature management functionality. + /// A that can be used to customize feature management functionality. + public static IFeatureManagementBuilder AddTelemetryPublisher(this IFeatureManagementBuilder builder) where T : ITelemetryPublisher + { + builder.AddTelemetryPublisher(sp => ActivatorUtilities.CreateInstance(sp, typeof(T)) as ITelemetryPublisher); + + return builder; + } + + private static IFeatureManagementBuilder AddTelemetryPublisher(this IFeatureManagementBuilder builder, Func factory) + { + builder.Services.Configure(options => + { + if (options.TelemetryPublisherFactories == null) + { + options.TelemetryPublisherFactories = new List>(); + } + + options.TelemetryPublisherFactories.Add(factory); + }); + + return builder; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs index 46658786..0e2c8eaf 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.FeatureManagement.Telemetry; +using System; +using System.Collections.Generic; + namespace Microsoft.FeatureManagement { /// @@ -22,5 +26,11 @@ public class FeatureManagementOptions /// The default value is true. /// public bool IgnoreMissingFeatures { get; set; } = true; + + /// + /// Holds a collection of factories that can be used to create instances. + /// This avoids the need to add the publishers to the service collection. + /// + internal ICollection> TelemetryPublisherFactories { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index addac954..992c80b4 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -1,10 +1,11 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.Telemetry; using Microsoft.FeatureManagement.FeatureFilters; using Microsoft.FeatureManagement.Targeting; using System; @@ -62,6 +63,8 @@ public FeatureManager( _parametersCache = new MemoryCache(new MemoryCacheOptions()); } + public IEnumerable TelemetryPublishers { get; init; } + public IConfiguration Configuration { get; init; } public ITargetingContextAccessor TargetingContextAccessor { get; init; } @@ -88,51 +91,52 @@ public Task IsEnabledAsync(string feature, TContext appContext, private async Task IsEnabledWithVariantsAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { - bool isFeatureEnabled = await IsEnabledAsync(feature, appContext, useAppContext, cancellationToken).ConfigureAwait(false); + bool isFeatureEnabled = false; - FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature).ConfigureAwait(false); + FeatureDefinition featureDefinition = await GetFeatureDefinition(feature).ConfigureAwait(false); - if (featureDefinition == null || featureDefinition.Status == FeatureStatus.Disabled) - { - isFeatureEnabled = false; - } - else if ((featureDefinition.Variants?.Any() ?? false) && featureDefinition.Allocation != null) + VariantDefinition variantDefinition = null; + + if (featureDefinition != null) { - VariantDefinition variantDefinition; + isFeatureEnabled = await IsEnabledAsync(featureDefinition, appContext, useAppContext, cancellationToken).ConfigureAwait(false); - if (!isFeatureEnabled) + if (featureDefinition.Variants != null && featureDefinition.Variants.Any() && featureDefinition.Allocation != null) { - variantDefinition = featureDefinition.Variants.FirstOrDefault((variant) => variant.Name == featureDefinition.Allocation.DefaultWhenDisabled); - } - else - { - TargetingContext targetingContext; - - if (useAppContext) + if (!isFeatureEnabled) { - targetingContext = appContext as TargetingContext; + variantDefinition = featureDefinition.Variants.FirstOrDefault((variant) => variant.Name == featureDefinition.Allocation.DefaultWhenDisabled); } else { - targetingContext = await ResolveTargetingContextAsync(cancellationToken).ConfigureAwait(false); - } + TargetingContext targetingContext; - variantDefinition = await GetAssignedVariantAsync( - featureDefinition, - targetingContext, - cancellationToken) - .ConfigureAwait(false); - } + if (useAppContext) + { + targetingContext = appContext as TargetingContext; + } + else + { + targetingContext = await ResolveTargetingContextAsync(cancellationToken).ConfigureAwait(false); + } - if (variantDefinition != null) - { - if (variantDefinition.StatusOverride == StatusOverride.Enabled) - { - isFeatureEnabled = true; + variantDefinition = await GetAssignedVariantAsync( + featureDefinition, + targetingContext, + cancellationToken) + .ConfigureAwait(false); } - else if (variantDefinition.StatusOverride == StatusOverride.Disabled) + + if (variantDefinition != null && featureDefinition.Status != FeatureStatus.Disabled) { - isFeatureEnabled = false; + if (variantDefinition.StatusOverride == StatusOverride.Enabled) + { + isFeatureEnabled = true; + } + else if (variantDefinition.StatusOverride == StatusOverride.Disabled) + { + isFeatureEnabled = false; + } } } } @@ -142,6 +146,16 @@ private async Task IsEnabledWithVariantsAsync(string feature, TC await sessionManager.SetAsync(feature, isFeatureEnabled).ConfigureAwait(false); } + if (featureDefinition.TelemetryEnabled) + { + PublishTelemetry(new EvaluationEvent + { + FeatureDefinition = featureDefinition, + IsEnabled = isFeatureEnabled, + Variant = variantDefinition != null ? GetVariantFromVariantDefinition(variantDefinition) : null + }, cancellationToken); + } + return isFeatureEnabled; } @@ -165,11 +179,13 @@ public void Dispose() _parametersCache.Dispose(); } - private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) + private async Task IsEnabledAsync(FeatureDefinition featureDefinition, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { + Debug.Assert(featureDefinition != null); + foreach (ISessionManager sessionManager in _sessionManagers) { - bool? readSessionResult = await sessionManager.GetAsync(feature).ConfigureAwait(false); + bool? readSessionResult = await sessionManager.GetAsync(featureDefinition.Name).ConfigureAwait(false); if (readSessionResult.HasValue) { @@ -179,124 +195,110 @@ private async Task IsEnabledAsync(string feature, TContext appCo bool enabled; - FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature).ConfigureAwait(false); - - if (featureDefinition != null) + // + // Treat an empty or status disabled feature as disabled + if (featureDefinition.EnabledFor == null || + !featureDefinition.EnabledFor.Any() || + featureDefinition.Status == FeatureStatus.Disabled) + { + enabled = false; + } + else { + // + // Ensure no conflicts in the feature definition if (featureDefinition.RequirementType == RequirementType.All && _options.IgnoreMissingFeatureFilters) { throw new FeatureManagementException( - FeatureManagementError.Conflict, - $"The 'IgnoreMissingFeatureFilters' flag cannot use used in combination with a feature of requirement type 'All'."); + FeatureManagementError.Conflict, + $"The 'IgnoreMissingFeatureFilters' flag cannot be used in combination with a feature of requirement type 'All'."); } // - // Treat an empty list of enabled filters or if status is disabled as a disabled feature - if (featureDefinition.EnabledFor == null || !featureDefinition.EnabledFor.Any() || featureDefinition.Status == FeatureStatus.Disabled) - { - enabled = false; - } - else - { - // - // If the requirement type is all, we default to true. Requirement type All will end on a false - enabled = featureDefinition.RequirementType == RequirementType.All; + // If the requirement type is all, we default to true. Requirement type All will end on a false + enabled = featureDefinition.RequirementType == RequirementType.All; - // - // We iterate until we hit our target evaluation - bool targetEvaluation = !enabled; + // + // We iterate until we hit our target evaluation + bool targetEvaluation = !enabled; - // - // Keep track of the index of the filter we are evaluating - int filterIndex = -1; + // + // Keep track of the index of the filter we are evaluating + int filterIndex = -1; + + // + // For all enabling filters listed in the feature's state, evaluate them according to requirement type + foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) + { + filterIndex++; // - // For all enabling filters listed in the feature's state, evaluate them according to requirement type - foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) + // Handle AlwaysOn and On filters + if (string.Equals(featureFilterConfiguration.Name, "AlwaysOn", StringComparison.OrdinalIgnoreCase) || + string.Equals(featureFilterConfiguration.Name, "On", StringComparison.OrdinalIgnoreCase)) { - filterIndex++; - - // - // Handle AlwaysOn and On filters - if (string.Equals(featureFilterConfiguration.Name, "AlwaysOn", StringComparison.OrdinalIgnoreCase) || - string.Equals(featureFilterConfiguration.Name, "On", StringComparison.OrdinalIgnoreCase)) + if (featureDefinition.RequirementType == RequirementType.Any) { - if (featureDefinition.RequirementType == RequirementType.Any) - { - enabled = true; - break; - } - - continue; + enabled = true; + break; } - IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name); - - if (filter == null) - { - string errorMessage = $"The feature filter '{featureFilterConfiguration.Name}' specified for feature '{feature}' was not found."; + continue; + } - if (!_options.IgnoreMissingFeatureFilters) - { - throw new FeatureManagementException(FeatureManagementError.MissingFeatureFilter, errorMessage); - } + IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name); - _logger.LogWarning(errorMessage); + if (filter == null) + { + string errorMessage = $"The feature filter '{featureFilterConfiguration.Name}' specified for feature '{featureDefinition.Name}' was not found."; - continue; + if (!_options.IgnoreMissingFeatureFilters) + { + throw new FeatureManagementException(FeatureManagementError.MissingFeatureFilter, errorMessage); } - var context = new FeatureFilterEvaluationContext() - { - FeatureName = feature, - Parameters = featureFilterConfiguration.Parameters - }; + _logger.LogWarning(errorMessage); - // - // IContextualFeatureFilter - if (useAppContext) - { - ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext)); + continue; + } - BindSettings(filter, context, filterIndex); + var context = new FeatureFilterEvaluationContext() + { + FeatureName = featureDefinition.Name, + Parameters = featureFilterConfiguration.Parameters + }; - if (contextualFilter != null && - await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false) == targetEvaluation) - { - enabled = targetEvaluation; + // + // IContextualFeatureFilter + if (useAppContext) + { + ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext)); - break; - } - } + BindSettings(filter, context, filterIndex); - // - // IFeatureFilter - if (filter is IFeatureFilter featureFilter) + if (contextualFilter != null && + await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false) == targetEvaluation) { - BindSettings(filter, context, filterIndex); - - if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation) - { - enabled = targetEvaluation; + enabled = targetEvaluation; - break; - } + break; } } - } - } - else - { - enabled = false; - string errorMessage = $"The feature declaration for the feature '{feature}' was not found."; + // + // IFeatureFilter + if (filter is IFeatureFilter featureFilter) + { + BindSettings(filter, context, filterIndex); - if (!_options.IgnoreMissingFeatures) - { - throw new FeatureManagementException(FeatureManagementError.MissingFeature, errorMessage); + if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation) + { + enabled = targetEvaluation; + + break; + } + } } - - _logger.LogWarning(errorMessage); } return enabled; @@ -329,30 +331,16 @@ public ValueTask GetVariantAsync(string feature, TargetingContext conte private async ValueTask GetVariantAsync(string feature, TargetingContext context, bool useContext, CancellationToken cancellationToken) { - FeatureDefinition featureDefinition = await _featureDefinitionProvider - .GetFeatureDefinitionAsync(feature) - .ConfigureAwait(false); - - if (featureDefinition == null) - { - string errorMessage = $"The feature declaration for the feature '{feature}' was not found."; - - if (!_options.IgnoreMissingFeatures) - { - throw new FeatureManagementException(FeatureManagementError.MissingFeature, errorMessage); - } + FeatureDefinition featureDefinition = await GetFeatureDefinition(feature).ConfigureAwait(false); - _logger.LogWarning(errorMessage); - } - - if (featureDefinition?.Allocation == null || (!featureDefinition.Variants?.Any() ?? false)) + if (featureDefinition == null || featureDefinition.Allocation == null || (!featureDefinition.Variants?.Any() ?? false)) { return null; } VariantDefinition variantDefinition = null; - bool isFeatureEnabled = await IsEnabledAsync(feature, context, useContext, cancellationToken).ConfigureAwait(false); + bool isFeatureEnabled = await IsEnabledAsync(featureDefinition, context, useContext, cancellationToken).ConfigureAwait(false); if (!isFeatureEnabled) { @@ -368,39 +356,40 @@ private async ValueTask GetVariantAsync(string feature, TargetingContex variantDefinition = await GetAssignedVariantAsync(featureDefinition, context, cancellationToken).ConfigureAwait(false); } - if (variantDefinition == null) + Variant variant = variantDefinition != null ? GetVariantFromVariantDefinition(variantDefinition) : null; + + if (featureDefinition.TelemetryEnabled) { - return null; + PublishTelemetry(new EvaluationEvent + { + FeatureDefinition = featureDefinition, + IsEnabled = isFeatureEnabled, + Variant = variant + }, cancellationToken); } - IConfigurationSection variantConfiguration = null; + return variant; + } - bool configValueSet = variantDefinition.ConfigurationValue.Exists(); - bool configReferenceSet = !string.IsNullOrEmpty(variantDefinition.ConfigurationReference); + private async ValueTask GetFeatureDefinition(string feature) + { + FeatureDefinition featureDefinition = await _featureDefinitionProvider + .GetFeatureDefinitionAsync(feature) + .ConfigureAwait(false); - if (configValueSet) - { - variantConfiguration = variantDefinition.ConfigurationValue; - } - else if (configReferenceSet) + if (featureDefinition == null) { - if (Configuration == null) - { - _logger.LogWarning($"Cannot use {nameof(variantDefinition.ConfigurationReference)} as no instance of {nameof(IConfiguration)} is present."); + string errorMessage = $"The feature declaration for the feature '{feature}' was not found."; - return null; - } - else + if (!_options.IgnoreMissingFeatures) { - variantConfiguration = Configuration.GetSection(variantDefinition.ConfigurationReference); + throw new FeatureManagementException(FeatureManagementError.MissingFeature, errorMessage); } + + _logger.LogWarning(errorMessage); } - return new Variant() - { - Name = variantDefinition.Name, - Configuration = variantConfiguration - }; + return featureDefinition; } private async ValueTask ResolveTargetingContextAsync(CancellationToken cancellationToken) @@ -646,5 +635,51 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte return filter; } + + private async void PublishTelemetry(EvaluationEvent evaluationEvent, CancellationToken cancellationToken) + { + if (TelemetryPublishers == null || !TelemetryPublishers.Any()) + { + _logger.LogWarning("The feature declaration enabled telemetry but no telemetry publisher was registered."); + } + else + { + foreach (ITelemetryPublisher telemetryPublisher in TelemetryPublishers) + { + await telemetryPublisher.PublishEvent( + evaluationEvent, + cancellationToken); + } + } + } + + private Variant GetVariantFromVariantDefinition(VariantDefinition variantDefinition) + { + IConfigurationSection variantConfiguration = null; + + if (variantDefinition.ConfigurationValue.Exists()) + { + variantConfiguration = variantDefinition.ConfigurationValue; + } + else if (!string.IsNullOrEmpty(variantDefinition.ConfigurationReference)) + { + if (Configuration == null) + { + _logger.LogWarning($"Cannot use {nameof(variantDefinition.ConfigurationReference)} as no instance of {nameof(IConfiguration)} is present."); + + return null; + } + else + { + variantConfiguration = Configuration.GetSection(variantDefinition.ConfigurationReference); + } + } + + return new Variant() + { + Name = variantDefinition.Name, + Configuration = variantConfiguration + }; + } } } diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 1525c1d8..40103663 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Generic; +using System.Linq; namespace Microsoft.FeatureManagement { @@ -30,25 +31,7 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add required services services.TryAddSingleton(); - services.AddSingleton(); - - services.TryAddSingleton(sp => sp.GetRequiredService()); - - services.TryAddSingleton(sp => - new FeatureManager( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>()) - { - Configuration = sp.GetService(), - TargetingContextAccessor = sp.GetService() - }); - - services.TryAddSingleton(sp => - new FeatureManager( + services.AddSingleton(sp => new FeatureManager( sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService>(), @@ -57,8 +40,16 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec sp.GetRequiredService>()) { Configuration = sp.GetService(), - TargetingContextAccessor = sp.GetService() + TargetingContextAccessor = sp.GetService(), + TelemetryPublishers = sp.GetService>()?.Value.TelemetryPublisherFactories? + .Select(factory => factory(sp)) + .ToList() }); + + services.TryAddSingleton(sp => sp.GetRequiredService()); + + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddScoped(); diff --git a/src/Microsoft.FeatureManagement/Telemetry/EvaluationEvent.cs b/src/Microsoft.FeatureManagement/Telemetry/EvaluationEvent.cs new file mode 100644 index 00000000..a425c290 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Telemetry/EvaluationEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement.FeatureFilters; + +namespace Microsoft.FeatureManagement.Telemetry +{ + /// + /// An event representing the evaluation of a feature. + /// + public class EvaluationEvent + { + /// + /// The definition of the feature that was evaluated. + /// + public FeatureDefinition FeatureDefinition { get; set; } + + /// + /// The enabled state of the feature after evaluation. + /// + public bool IsEnabled { get; set; } + + /// + /// The variant given after evaluation. + /// + public Variant Variant { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/Telemetry/ITelemetryPublisher.cs b/src/Microsoft.FeatureManagement/Telemetry/ITelemetryPublisher.cs new file mode 100644 index 00000000..89f7a931 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Telemetry/ITelemetryPublisher.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement.Telemetry +{ + /// + /// A publisher of telemetry events. + /// + public interface ITelemetryPublisher + { + /// + /// Handles an EvaluationEvent and publishes it to the configured telemetry channel. + /// + /// The event to publish. + /// A cancellation token. + /// ValueTask + public ValueTask PublishEvent(EvaluationEvent evaluationEvent, CancellationToken cancellationToken); + } +} diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 29f4373e..935bf9ae 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; using Microsoft.FeatureManagement.FeatureFilters; +using Microsoft.FeatureManagement.Tests; using System; using System.Collections.Generic; using System.IO; @@ -824,6 +825,105 @@ public async Task BindsFeatureFlagSettings() } [Fact] + public async Task TelemetryPublishing() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddTelemetryPublisher() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + FeatureManager featureManager = (FeatureManager) serviceProvider.GetRequiredService(); + TestTelemetryPublisher testPublisher = (TestTelemetryPublisher) featureManager.TelemetryPublishers.First(); + + // Test a feature with telemetry disabled + bool result = await featureManager.IsEnabledAsync(OnFeature, CancellationToken.None); + + Assert.True(result); + Assert.Null(testPublisher.evaluationEventCache); + + // Test telemetry cases + string onFeature = "AlwaysOnTestFeature"; + + result = await featureManager.IsEnabledAsync(onFeature, CancellationToken.None); + + Assert.True(result); + Assert.Equal(onFeature, testPublisher.evaluationEventCache.FeatureDefinition.Name); + Assert.Equal(result, testPublisher.evaluationEventCache.IsEnabled); + Assert.Equal("EtagValue", testPublisher.evaluationEventCache.FeatureDefinition.TelemetryMetadata["Etag"]); + Assert.Equal("LabelValue", testPublisher.evaluationEventCache.FeatureDefinition.TelemetryMetadata["Label"]); + Assert.Equal("Tag1Value", testPublisher.evaluationEventCache.FeatureDefinition.TelemetryMetadata["Tags.Tag1"]); + + string offFeature = "OffTimeTestFeature"; + + result = await featureManager.IsEnabledAsync(offFeature, CancellationToken.None); + + Assert.False(result); + Assert.Equal(offFeature, testPublisher.evaluationEventCache.FeatureDefinition.Name); + Assert.Equal(result, testPublisher.evaluationEventCache.IsEnabled); + + // Test variant cases + string variantDefaultEnabledFeature = "VariantFeatureDefaultEnabled"; + + result = await featureManager.IsEnabledAsync(variantDefaultEnabledFeature, CancellationToken.None); + + Assert.True(result); + Assert.Equal(variantDefaultEnabledFeature, testPublisher.evaluationEventCache.FeatureDefinition.Name); + Assert.Equal(result, testPublisher.evaluationEventCache.IsEnabled); + Assert.Equal("Medium", testPublisher.evaluationEventCache.Variant.Name); + + Variant variantResult = await featureManager.GetVariantAsync(variantDefaultEnabledFeature, CancellationToken.None); + + Assert.True(testPublisher.evaluationEventCache.IsEnabled); + Assert.Equal(variantDefaultEnabledFeature, testPublisher.evaluationEventCache.FeatureDefinition.Name); + Assert.Equal(variantResult.Name, testPublisher.evaluationEventCache.Variant.Name); + + string variantFeatureStatusDisabled = "VariantFeatureStatusDisabled"; + + result = await featureManager.IsEnabledAsync(variantFeatureStatusDisabled, CancellationToken.None); + + Assert.False(result); + Assert.Equal(variantFeatureStatusDisabled, testPublisher.evaluationEventCache.FeatureDefinition.Name); + Assert.Equal(result, testPublisher.evaluationEventCache.IsEnabled); + Assert.Equal("Small", testPublisher.evaluationEventCache.Variant.Name); + + variantResult = await featureManager.GetVariantAsync(variantFeatureStatusDisabled, CancellationToken.None); + + Assert.False(testPublisher.evaluationEventCache.IsEnabled); + Assert.Equal(variantFeatureStatusDisabled, testPublisher.evaluationEventCache.FeatureDefinition.Name); + Assert.Equal(variantResult.Name, testPublisher.evaluationEventCache.Variant.Name); + } + + [Fact] + public async Task TelemetryPublishingNullPublisher() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + FeatureManager featureManager = (FeatureManager)serviceProvider.GetRequiredService(); + + // Test telemetry enabled feature with no telemetry publisher + string onFeature = "AlwaysOnTestFeature"; + + bool result = await featureManager.IsEnabledAsync(onFeature, CancellationToken.None); + + Assert.True(result); + } + public async Task UsesVariants() { IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); diff --git a/tests/Tests.FeatureManagement/TestTelemetryPublisher.cs b/tests/Tests.FeatureManagement/TestTelemetryPublisher.cs new file mode 100644 index 00000000..088941b5 --- /dev/null +++ b/tests/Tests.FeatureManagement/TestTelemetryPublisher.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement.Telemetry; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement.Tests +{ + public class TestTelemetryPublisher : ITelemetryPublisher + { + public EvaluationEvent evaluationEventCache { get; private set; } + + public ValueTask PublishEvent(EvaluationEvent evaluationEvent, CancellationToken cancellationToken) + { + evaluationEventCache = evaluationEvent; + + return new ValueTask(); + } + } +} \ No newline at end of file diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index ba6a18d7..0bdac1d5 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -20,6 +20,31 @@ "FeatureManagement": { "OnTestFeature": true, "OffTestFeature": false, + "AlwaysOnTestFeature": { + "TelemetryEnabled": true, + "EnabledFor": [ + { + "Name": "AlwaysOn" + } + ], + "TelemetryMetadata": { + "Tags.Tag1": "Tag1Value", + "Tags.Tag2": "Tag2Value", + "Etag": "EtagValue", + "Label": "LabelValue" + } + }, + "OffTimeTestFeature": { + "TelemetryEnabled": true, + "EnabledFor": [ + { + "Name": "TimeWindow", + "Parameters": { + "End": "1970-01-01T00:00:00Z" + } + } + ] + }, "TargetingTestFeature": { "EnabledFor": [ { @@ -209,6 +234,7 @@ }, "VariantFeatureStatusDisabled": { "Status": "Disabled", + "TelemetryEnabled": true, "Allocation": { "DefaultWhenDisabled": "Small" }, @@ -225,6 +251,7 @@ ] }, "VariantFeatureDefaultEnabled": { + "TelemetryEnabled": true, "Allocation": { "DefaultWhenEnabled": "Medium", "User": [