Skip to content

Loading feature flags from new endpoint#738

Open
linglingye001 wants to merge 21 commits into
previewfrom
linglingye/exclude-classic-ff
Open

Loading feature flags from new endpoint#738
linglingye001 wants to merge 21 commits into
previewfrom
linglingye/exclude-classic-ff

Conversation

@linglingye001

@linglingye001 linglingye001 commented Jun 12, 2026

Copy link
Copy Markdown
Member

Previously, feature flags were loaded only as classic key-values prefixed with .appconfig.featureflag/. This PR registers a parallel "new feature flag" selector/watcher for each FeatureFlagSelector, fetches flags via the dedicated SDK endpoint, and synthesizes them back into the feature-management JSON schema so downstream parsing is unchanged. Classic flags can be turned off via a new opt-in flag ExcludeClassicFeatureFlags.

Changes:

  • Add opt-in behavior to exclude classic feature flags and prefer the new feature-flag endpoint as the source of truth.
  • Introduce “new feature-flag selector/watcher” markers and update selector equality/hash behavior accordingly.
  • Implement loading + change detection for the new feature-flag endpoint, including synthesis into the existing feature-management JSON schema.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for loading feature flags from Azure App Configuration’s newer feature-flag endpoint and introduces an option to exclude “classic” feature flags stored under the .appconfig.featureflag/ key namespace.

Changes:

  • Add opt-in behavior to exclude classic feature flags and prefer the new feature-flag endpoint as the source of truth.
  • Introduce “new feature-flag selector/watcher” markers and update selector equality/hash behavior accordingly.
  • Implement loading + change detection for the new feature-flag endpoint, including synthesis into the existing feature-management JSON schema.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs Adds a flag to distinguish watchers targeting the new feature-flag endpoint.
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs Adds a flag to distinguish selectors for the new endpoint and updates equality/hash logic.
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagSettingConverter.cs New converter that synthesizes ConfigurationSetting instances from SDK FeatureFlag models using the existing feature-management JSON schema.
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs Adds a new public option to exclude classic feature flags.
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs Adds change detection for the new feature-flag endpoint by comparing page ETags.
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs Routes loading/refresh logic between classic key-values and the new feature-flag endpoint; ensures new flags win on key conflicts.
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs Updates UseFeatureFlags to register new-endpoint selectors/watchers (and optionally omit classic flags); changes default SDK service version.

Comment on lines +350 to +354
// The new endpoint uses null to mean "any name". A bare "*" is equivalent.
if (string.IsNullOrEmpty(nameFilter) || nameFilter == "*")
{
nameFilter = null;
}
Comment on lines 590 to 593
private static ConfigurationClientOptions GetDefaultClientOptions()
{
var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_11_01);
var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2026_05_01_Preview);
clientOptions.Retry.MaxRetries = MaxRetries;
Comment on lines +38 to +44
/// <summary>
/// When set to true, classic feature flags (key-values whose key is prefixed with
/// ".appconfig.featureflag/") are not loaded from the configuration store. Only feature flags
/// returned by the new feature-flag endpoint will be loaded.
/// Defaults to false; classic flags are loaded alongside new flags for backward compatibility.
/// </summary>
public bool ExcludeClassicFeatureFlags { get; set; } = false;
@linglingye001
linglingye001 marked this pull request as ready for review June 23, 2026 01:23
@linglingye001 linglingye001 changed the title Exclude classic feature flags Loading feature flags from new endpoint Jun 23, 2026
@jimmyca15

Copy link
Copy Markdown
Member

This needs to go into the preview branch.

@linglingye001
linglingye001 force-pushed the linglingye/exclude-classic-ff branch from 708b5b1 to 56f906e Compare June 25, 2026 09:50
@zhiyuanliang-ms
zhiyuanliang-ms changed the base branch from main to preview June 25, 2026 09:58
Comment thread src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ClientWrapper.cs Outdated
ETag etag = featureFlag.Etag ?? default;
FeatureManagementKeyValueAdapter featureFlagAdapter = _options.Adapters
.OfType<FeatureManagementKeyValueAdapter>()
.FirstOrDefault();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We shouldn't have the code looking like we may not have what we need to convert a feature flag. Here's what I think we can do to make it more explicit.

Add a static type static class FeatureFlagConverter that has a public method

public IEnumerable<KeyValuePair<string, string>> ToConfiguration(FeatureFlag flag).

Adapters are for processing configuration settings. The new feature flags aren't configuration settings so we don't need an adapter.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

.NET config binds feature_management:feature_flags as an array, classic flags get indices 0..N-1, then standalone continue N..N+M-1. For FeatureFlagConverter, we need _featureFlagIndex, _featureFlagTracing and Endpoint for FeatureFlagReference.

How about

public static IEnumerable<KeyValuePair<string, string>> ToConfiguration(
            FeatureFlag flag,
            ref int featureFlagIndex,
            Uri endpoint,
            FeatureFlagTracing tracing)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Discussed offline.

FeatureFlag flag,
ref int featureFlagIndex,
Uri endpoint,
FeatureFlagTracing tracing)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tracing updating should be separated from conversion to configuration.

// uses a single contiguous set of indices.
int featureFlagIndex = _options.Adapters
.OfType<FeatureManagementKeyValueAdapter>()
.FirstOrDefault()?.FeatureFlagIndex ?? 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What about on refresh, does the index keep incrementing? It seems like we should track index only in the provider. I think we need to move away from the adapter pattern for feature flags.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Discussed offline. Use converter for the classic and new ffs. When process classic ff, there're Dotnet ff schema and Microsoft ff schema, so the ff index is not equal to the classicFf.count() and invisible to the caller after the conversion. Working in progress for this part.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

so the ff index is not equal to the classicFf.count()

But it's fine to start at classicFfCount right? The only issue would be if we started at an index that was already occupied. No issue if we skip an index.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Oh I see. I thought the index must be contiguous. Yes, we can start at classicFFCount. Will update the code

{
_options.FeatureFlagTracing.Update(featureFlag);

string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{featureFlagIndex}";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why isn't this part of FeatureFlagConverter.ToConfiguration?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It should be in ToConfiguration(). Updated.

// continue the "feature_management:feature_flags" array from this index so that the combined array
// uses a single contiguous set of indices. Classic flags emitted using the .NET schema do not
// occupy an array slot and therefore do not advance this count.
_classicFeatureFlagCount = featureFlagIndex;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I thought we would be able to use the classic feature flag count and simplify things. The worse thing I could think was an index might be skipped which isn't an issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated, use classic feature flag count now.

Dictionary<string, ConfigurationSetting> data,
ClassicFeatureFlagLoadResult classicFeatureFlagLoadResult)
{
if (classicFeatureFlagLoadResult?.ClassicFeatureFlags != null)

@jimmyca15 jimmyca15 Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Based off when we call this, is it ever expected to be null? I would expect no.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's not clear to me that this is worth a helper method.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, it won't be null.
Removed helper method.

    ffKeys = new HashSet<string>(
        classicFeatureFlagLoadResult.ClassicFeatureFlags.Select(ff => ff.Key)
        .Concat(featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name))
    );

return ffKeys;
}

private static FlagSelector BuildFlagSelector(FeatureFlagSelector ffSelector)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks like a good candiate for an extension method on FeatureFlagSelector

}

Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false);
// Drop any classic feature flag in the mapped data that is now superseded by a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Even with the comment I'm having trouble understanding what this is for.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sorry about that, this loop is redundant. Will remove.

@linglingye001
linglingye001 force-pushed the linglingye/exclude-classic-ff branch from e798fae to e2b5a0c Compare July 16, 2026 11:14
SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false));
List<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags
.Where(setting => !featureFlagKeys.Contains(setting.Key))
.ToList();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No need to allocate a list, keeping as an IEnumerable is fine.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

updated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClassicFeatureFlagConverter.cs:33

  • IsClassicFeatureFlag now requires a non-empty ContentType and only checks the parsed content type. This can regress classic feature flags that are stored under the ".appconfig.featureflag/" key namespace but lack (or have an unexpected) ContentType, and it also prevents ExcludeClassicFeatureFlags from reliably filtering those classic flags.
    examples/ConfigStoreDemo/Program.cs:30
  • The example now passes settings["connection_string"] directly into Connect(). When the value is missing/empty, this will throw an ArgumentNullException with a less actionable message than the previous explicit check.
                    var settings = config.AddJsonFile("appsettings.json").Build();
                    config.AddAzureAppConfiguration(options =>
                    {
                        options.Connect(settings["connection_string"])
                               .ConfigureRefresh(refresh =>

Comment on lines +525 to +528
if (ClientOptions.Transport != null)
{
FeatureFlagClientOptions.Transport = ClientOptions.Transport;
}
Comment on lines +354 to +358
// Derive a deterministic ETag from the flag names + enabled state so that an unchanged
// collection keeps the same ETag and a changed collection produces a different one.
string content = string.Join("|", collection.Select(f => $"{f.Name}:{f.Enabled}"));

return "ff-" + content.GetHashCode().ToString("x8");
Comment on lines 33 to 35
IConfigurationSection endpointsSection = configuration.GetSection("AppConfig:Endpoints");
IEnumerable<Uri> endpoints = endpointsSection.GetChildren()
.Select(endpoint => endpoint.Value)
.Where(value => !string.IsNullOrEmpty(value))
.Select(value => new Uri(value));
IEnumerable<Uri> endpoints = endpointsSection.GetChildren().Select(endpoint => new Uri(endpoint.Value));

_options.FeatureFlagSelectors,
cancellationToken).ConfigureAwait(false);

ffKeys = new HashSet<string>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ff keys should only need to hold classic feature flag keys. It's only used to remove classic feature flags from mapped data.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Then _featureFlagIndex shouldn't be needed because it can always be obtained from _ffKeys.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As a result, _ffKeys can be renamed to _classicFfKeys.

// Avoid instance state modification
Dictionary<KeyValueSelector, IEnumerable<WatchedPage>> kvEtags = null;
Dictionary<KeyValueSelector, IEnumerable<WatchedPage>> ffEtags = null;
HashSet<string> ffKeys = null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ffKeys can be declared after ExecuteWithFailoverPolicy. Since it doesn't rely on client, there's no need to initialize it inside that block.

.Where(setting => !featureFlagKeys.Contains(setting.Key));

// Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously
foreach (string key in _ffKeys.Except(ffKeys))

@jimmyca15 jimmyca15 Jul 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems we don't have to be too smart here. We can just remove all _ffKeys from _mappedData because we add them back right after.

Given we take the change for ff keys to only have classic feature flag keys.


var pages = new Dictionary<FeatureFlagSelector, IEnumerable<WatchedPage>>();

if (_options.ExcludeClassicFeatureFlags)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Keeping a single return statement makes things easier to review. I'd suggest keeping the single return statement at the bottom and putting the central piece in the if statement.

/// ".appconfig.featureflag/") are not loaded from the configuration store. Only feature flags
/// returned by the new feature-flag endpoint will be loaded.
/// Defaults to false; classic flags are loaded alongside new flags for backward compatibility.
/// </summary>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No need for default value ( = false;) for booleans.

var featureFlagKeys = new HashSet<string>(
featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name));

IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags
IEnumerable<ConfigurationSetting> eligibleClassicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags

Comment on lines +447 to +473
// Exclude any classic feature flags that are superseded by a standalone feature flag with the same name.
var featureFlagKeys = new HashSet<string>(
featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name));

IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags
.Where(setting => !featureFlagKeys.Contains(setting.Key));

// Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously
foreach (string key in _ffKeys.Except(ffKeys))
{
_mappedData.Remove(key);
}

Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false);
// Remove any classic feature flags that are now superseded by a standalone feature flag with the same name.
foreach (FeatureFlag featureFlag in featureFlagLoadResult.FeatureFlags)
{
_mappedData.Remove(FeatureManagementConstants.FeatureFlagMarker + featureFlag.Name);
}

Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(classicFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false);

foreach (KeyValuePair<string, ConfigurationSetting> kvp in mappedFfData)
{
_mappedData[kvp.Key] = kvp.Value;
}

_featureFlags = featureFlagLoadResult.FeatureFlags;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// Exclude any classic feature flags that are superseded by a standalone feature flag with the same name.
var featureFlagKeys = new HashSet<string>(
featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name));
IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags
.Where(setting => !featureFlagKeys.Contains(setting.Key));
// Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously
foreach (string key in _ffKeys.Except(ffKeys))
{
_mappedData.Remove(key);
}
Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false);
// Remove any classic feature flags that are now superseded by a standalone feature flag with the same name.
foreach (FeatureFlag featureFlag in featureFlagLoadResult.FeatureFlags)
{
_mappedData.Remove(FeatureManagementConstants.FeatureFlagMarker + featureFlag.Name);
}
Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(classicFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false);
foreach (KeyValuePair<string, ConfigurationSetting> kvp in mappedFfData)
{
_mappedData[kvp.Key] = kvp.Value;
}
_featureFlags = featureFlagLoadResult.FeatureFlags;
//
// Remove all previous classic feature flag keys
// We'll will add them back below if they are still in the latest batch
foreach (string key in _classicFfKeys)
{
_mappedData.Remove(key);
}
//
// Exclude any classic feature flags that are superseded by a standalone feature flag with the same name.
var ineligibleClassicFfKeys = new HashSet<string>(
featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name));
IEnumerable<ConfigurationSetting> eligibleClassicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags
.Where(setting => !ineligibleClassicFfKeys.Contains(setting.Key));
Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(eligibleClassicFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false);
foreach (KeyValuePair<string, ConfigurationSetting> kvp in mappedFfData)
{
_mappedData[kvp.Key] = kvp.Value;
}
_featureFlags = featureFlagLoadResult.FeatureFlags;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants