diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 1541c40..4523642 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,10 +9,11 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js"; import { Disposable } from "./common/disposable.js"; -import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME } from "./featureManagement/constants.js"; +import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME, CONDITIONS_KEY_NAME, CLIENT_FILTERS_KEY_NAME, TELEMETRY_KEY_NAME, VARIANTS_KEY_NAME, ALLOCATION_KEY_NAME, SEED_KEY_NAME, NAME_KEY_NAME, ENABLED_KEY_NAME } from "./featureManagement/constants.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; type PagedSettingSelector = SettingSelector & { @@ -38,6 +39,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #client: AppConfigurationClient; #options: AzureAppConfigurationOptions | undefined; #isInitialLoadCompleted: boolean = false; + #featureFlagTracing: FeatureFlagTracingOptions | undefined; // Refresh #refreshInProgress: boolean = false; @@ -66,6 +68,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); + if (this.#requestTracingEnabled) { + this.#featureFlagTracing = new FeatureFlagTracingOptions(); + } if (options?.trimKeyPrefixes) { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); @@ -175,7 +180,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return { requestTracingEnabled: this.#requestTracingEnabled, initialLoadCompleted: this.#isInitialLoadCompleted, - appConfigOptions: this.#options + appConfigOptions: this.#options, + featureFlagTracingOptions: this.#featureFlagTracing }; } @@ -257,8 +263,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } async #loadFeatureFlags() { - // Temporary map to store feature flags, key is the key of the setting, value is the raw value of the setting - const featureFlagsMap = new Map(); + const featureFlagSettings: ConfigurationSetting[] = []; for (const selector of this.#featureFlagSelectors) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, @@ -275,15 +280,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { pageEtags.push(page.etag ?? ""); for (const setting of page.items) { if (isFeatureFlag(setting)) { - featureFlagsMap.set(setting.key, setting.value); + featureFlagSettings.push(setting); } } } selector.pageEtags = pageEtags; } + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + this.#featureFlagTracing.resetFeatureFlagTracing(); + } + // parse feature flags - const featureFlags = Array.from(featureFlagsMap.values()).map(rawFlag => JSON.parse(rawFlag)); + const featureFlags = await Promise.all( + featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) + ); // feature_management is a reserved key, and feature_flags is an array of feature flags this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); @@ -546,6 +557,33 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } return response; } + + async #parseFeatureFlag(setting: ConfigurationSetting): Promise { + const rawFlag = setting.value; + if (rawFlag === undefined) { + throw new Error("The value of configuration setting cannot be undefined."); + } + const featureFlag = JSON.parse(rawFlag); + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + if (featureFlag[CONDITIONS_KEY_NAME] && + featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME] && + Array.isArray(featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME])) { + for (const filter of featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME]) { + this.#featureFlagTracing.updateFeatureFilterTracing(filter[NAME_KEY_NAME]); + } + } + if (featureFlag[VARIANTS_KEY_NAME] && Array.isArray(featureFlag[VARIANTS_KEY_NAME])) { + this.#featureFlagTracing.notifyMaxVariants(featureFlag[VARIANTS_KEY_NAME].length); + } + if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME]) { + this.#featureFlagTracing.usesTelemetry = true; + } + if (featureFlag[ALLOCATION_KEY_NAME] && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME]) { + this.#featureFlagTracing.usesSeed = true; + } + } + return featureFlag; + } } function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index f0082f4..67afa55 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -2,4 +2,15 @@ // Licensed under the MIT license. export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management"; -export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; +export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; +export const CONDITIONS_KEY_NAME = "conditions"; +export const CLIENT_FILTERS_KEY_NAME = "client_filters"; +export const TELEMETRY_KEY_NAME = "telemetry"; +export const VARIANTS_KEY_NAME = "variants"; +export const ALLOCATION_KEY_NAME = "allocation"; +export const SEED_KEY_NAME = "seed"; +export const NAME_KEY_NAME = "name"; +export const ENABLED_KEY_NAME = "enabled"; + +export const TIME_WINDOW_FILTER_NAMES = ["TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter"]; +export const TARGETING_FILTER_NAMES = ["Targeting", "Microsoft.Targeting", "TargetingFilter", "Microsoft.TargetingFilter"]; diff --git a/src/requestTracing/FeatureFlagTracingOptions.ts b/src/requestTracing/FeatureFlagTracingOptions.ts new file mode 100644 index 0000000..006d969 --- /dev/null +++ b/src/requestTracing/FeatureFlagTracingOptions.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TIME_WINDOW_FILTER_NAMES, TARGETING_FILTER_NAMES } from "../featureManagement/constants.js"; +import { CUSTOM_FILTER_KEY, TIME_WINDOW_FILTER_KEY, TARGETING_FILTER_KEY, FF_SEED_USED_TAG, FF_TELEMETRY_USED_TAG, DELIMITER } from "./constants.js"; + +/** + * Tracing for tracking feature flag usage. + */ +export class FeatureFlagTracingOptions { + /** + * Built-in feature filter usage. + */ + usesCustomFilter: boolean = false; + usesTimeWindowFilter: boolean = false; + usesTargetingFilter: boolean = false; + usesTelemetry: boolean = false; + usesSeed: boolean = false; + maxVariants: number = 0; + + resetFeatureFlagTracing(): void { + this.usesCustomFilter = false; + this.usesTimeWindowFilter = false; + this.usesTargetingFilter = false; + this.usesTelemetry = false; + this.usesSeed = false; + this.maxVariants = 0; + } + + updateFeatureFilterTracing(filterName: string): void { + if (TIME_WINDOW_FILTER_NAMES.some(name => name === filterName)) { + this.usesTimeWindowFilter = true; + } else if (TARGETING_FILTER_NAMES.some(name => name === filterName)) { + this.usesTargetingFilter = true; + } else { + this.usesCustomFilter = true; + } + } + + notifyMaxVariants(currentFFTotalVariants: number): void { + if (currentFFTotalVariants > this.maxVariants) { + this.maxVariants = currentFFTotalVariants; + } + } + + usesAnyFeatureFilter(): boolean { + return this.usesCustomFilter || this.usesTimeWindowFilter || this.usesTargetingFilter; + } + + usesAnyTracingFeature() { + return this.usesSeed || this.usesTelemetry; + } + + createFeatureFiltersString(): string { + if (!this.usesAnyFeatureFilter()) { + return ""; + } + + let result: string = ""; + if (this.usesCustomFilter) { + result += CUSTOM_FILTER_KEY; + } + if (this.usesTimeWindowFilter) { + if (result !== "") { + result += DELIMITER; + } + result += TIME_WINDOW_FILTER_KEY; + } + if (this.usesTargetingFilter) { + if (result !== "") { + result += DELIMITER; + } + result += TARGETING_FILTER_KEY; + } + return result; + } + + createFeaturesString(): string { + if (!this.usesAnyTracingFeature()) { + return ""; + } + + let result: string = ""; + if (this.usesSeed) { + result += FF_SEED_USED_TAG; + } + if (this.usesTelemetry) { + if (result !== "") { + result += DELIMITER; + } + result += FF_TELEMETRY_USED_TAG; + } + return result; + } +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index d46cdfd..f32996b 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -37,7 +37,7 @@ export const CONTAINER_APP_ENV_VAR = "CONTAINER_APP_NAME"; export const KUBERNETES_ENV_VAR = "KUBERNETES_PORT"; export const SERVICE_FABRIC_ENV_VAR = "Fabric_NodeName"; // See: https://docs.microsoft.com/en-us/azure/service-fabric/service-fabric-environment-variables-reference -// Request Type +// Request type export const REQUEST_TYPE_KEY = "RequestType"; export enum RequestType { STARTUP = "Startup", @@ -46,3 +46,16 @@ export enum RequestType { // Tag names export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; + +// Feature flag usage tracing +export const FEATURE_FILTER_TYPE_KEY = "Filter"; +export const CUSTOM_FILTER_KEY = "CSTM"; +export const TIME_WINDOW_FILTER_KEY = "TIME"; +export const TARGETING_FILTER_KEY = "TRGT"; + +export const FF_TELEMETRY_USED_TAG = "Telemetry"; +export const FF_MAX_VARIANTS_KEY = "MaxVariants"; +export const FF_SEED_USED_TAG = "Seed"; +export const FF_FEATURES_KEY = "FFFeatures"; + +export const DELIMITER = "+"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index de33573..e9d0b0d 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -3,6 +3,7 @@ import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; +import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; import { AZURE_FUNCTION_ENV_VAR, AZURE_WEB_APP_ENV_VAR, @@ -10,6 +11,9 @@ import { DEV_ENV_VAL, ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED, ENV_KEY, + FEATURE_FILTER_TYPE_KEY, + FF_MAX_VARIANTS_KEY, + FF_FEATURES_KEY, HOST_TYPE_KEY, HostType, KEY_VAULT_CONFIGURED_TAG, @@ -28,17 +32,18 @@ export function listConfigurationSettingsWithTrace( requestTracingEnabled: boolean; initialLoadCompleted: boolean; appConfigOptions: AzureAppConfigurationOptions | undefined; + featureFlagTracingOptions: FeatureFlagTracingOptions | undefined; }, client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions } = requestTracingOptions; + const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, featureFlagTracingOptions } = requestTracingOptions; const actualListOptions = { ...listOptions }; if (requestTracingEnabled) { actualListOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, featureFlagTracingOptions, initialLoadCompleted) } }; } @@ -51,18 +56,19 @@ export function getConfigurationSettingWithTrace( requestTracingEnabled: boolean; initialLoadCompleted: boolean; appConfigOptions: AzureAppConfigurationOptions | undefined; + featureFlagTracingOptions: FeatureFlagTracingOptions | undefined; }, client: AppConfigurationClient, configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions } = requestTracingOptions; + const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, featureFlagTracingOptions } = requestTracingOptions; const actualGetOptions = { ...getOptions }; if (requestTracingEnabled) { actualGetOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, featureFlagTracingOptions, initialLoadCompleted) } }; } @@ -70,7 +76,7 @@ export function getConfigurationSettingWithTrace( return client.getConfigurationSetting(configurationSettingId, actualGetOptions); } -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean): string { +export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, featureFlagTracing: FeatureFlagTracingOptions | undefined, isInitialLoadCompleted: boolean): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -82,6 +88,14 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt keyValues.set(HOST_TYPE_KEY, getHostType()); keyValues.set(ENV_KEY, isDevEnvironment() ? DEV_ENV_VAL : undefined); + if (featureFlagTracing) { + keyValues.set(FEATURE_FILTER_TYPE_KEY, featureFlagTracing.usesAnyFeatureFilter() ? featureFlagTracing.createFeatureFiltersString() : undefined); + keyValues.set(FF_FEATURES_KEY, featureFlagTracing.usesAnyTracingFeature() ? featureFlagTracing.createFeaturesString() : undefined); + if (featureFlagTracing.maxVariants > 0) { + keyValues.set(FF_MAX_VARIANTS_KEY, featureFlagTracing.maxVariants.toString()); + } + } + const tags: string[] = []; if (options?.keyVaultOptions) { const { credential, secretClients, secretResolver } = options.keyVaultOptions; diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index a64d2c4..7bd73ce 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -5,22 +5,10 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { createMockedConnectionString, createMockedKeyValue, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sleepInMs } from "./utils/testHelper.js"; +import { createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, HttpRequestHeadersPolicy, sleepInMs } from "./utils/testHelper.js"; import { load } from "./exportedApi.js"; -class HttpRequestHeadersPolicy { - headers: any; - name: string; - - constructor() { - this.headers = {}; - this.name = "HttpRequestHeadersPolicy"; - } - sendRequest(req, next) { - this.headers = req.headers; - return next(req).then(resp => resp); - } -} +const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; describe("request tracing", function () { this.timeout(15000); @@ -150,6 +138,155 @@ describe("request tracing", function () { restoreMocks(); }); + it("should have filter type in correlation-context header if feature flags use feature filters", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { conditions: { client_filters: [ { name: "Microsoft.TimeWindow" } ] } }), + createMockedFeatureFlag("Alpha_2", { conditions: { client_filters: [ { name: "Microsoft.Targeting" } ] } }), + createMockedFeatureFlag("Alpha_3", { conditions: { client_filters: [ { name: "CustomFilter" } ] } }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("Filter=CSTM+TIME+TRGT")).eq(true); + + restoreMocks(); + }); + + it("should have max variants in correlation-context header if feature flags use variants", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { variants: [ {name: "a"}, {name: "b"}] }), + createMockedFeatureFlag("Alpha_2", { variants: [ {name: "a"}, {name: "b"}, {name: "c"}] }), + createMockedFeatureFlag("Alpha_3", { variants: [] }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("MaxVariants=3")).eq(true); + + restoreMocks(); + }); + + it("should have telemety tag in correlation-context header if feature flags enable telemetry", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("FFFeatures=Telemetry")).eq(true); + + restoreMocks(); + }); + + it("should have seed tag in correlation-context header if feature flags use allocation seed", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }), + createMockedFeatureFlag("Alpha_2", { allocation: {seed: "123"} }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("FFFeatures=Seed+Telemetry")).eq(true); + + restoreMocks(); + }); + describe("request tracing in Web Worker environment", () => { let originalNavigator; let originalWorkerNavigator; diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index bf05882..c00c99b 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -201,6 +201,20 @@ const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => isReadOnly: false }, props)); +class HttpRequestHeadersPolicy { + headers: any; + name: string; + + constructor() { + this.headers = {}; + this.name = "HttpRequestHeadersPolicy"; + } + sendRequest(req, next) { + this.headers = req.headers; + return next(req).then(resp => resp); + } +} + export { sinon, mockAppConfigurationClientListConfigurationSettings, @@ -216,5 +230,7 @@ export { createMockedKeyValue, createMockedFeatureFlag, + HttpRequestHeadersPolicy, + sleepInMs };