diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 5a66cfb6..6f2ee9dd 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -29,11 +29,14 @@ import { SEED_KEY_NAME, VARIANT_KEY_NAME, VARIANTS_KEY_NAME, - CONFIGURATION_VALUE_KEY_NAME + CONFIGURATION_VALUE_KEY_NAME, + CONDITIONS_KEY_NAME, + CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; @@ -61,6 +64,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #options: AzureAppConfigurationOptions | undefined; #isInitialLoadCompleted: boolean = false; #isFailoverRequest: boolean = false; + #featureFlagTracing: FeatureFlagTracingOptions | undefined; // Refresh #refreshInProgress: boolean = false; @@ -92,6 +96,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)); @@ -203,7 +210,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { appConfigOptions: this.#options, initialLoadCompleted: this.#isInitialLoadCompleted, replicaCount: this.#clientManager.getReplicaCount(), - isFailoverRequest: this.#isFailoverRequest + isFailoverRequest: this.#isFailoverRequest, + featureFlagTracing: this.#featureFlagTracing }; } @@ -367,6 +375,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const featureFlagSettings = await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + this.#featureFlagTracing.resetFeatureFlagTracing(); + } + // parse feature flags const featureFlags = await Promise.all( featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) @@ -647,6 +659,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; } + 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; } diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index 7b6bda8e..564bfbd9 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -20,3 +20,8 @@ export const VARIANT_KEY_NAME = "variant"; export const VARIANTS_KEY_NAME = "variants"; export const CONFIGURATION_VALUE_KEY_NAME = "configuration_value"; export const ALLOCATION_ID_KEY_NAME = "AllocationId"; +export const CONDITIONS_KEY_NAME = "conditions"; +export const CLIENT_FILTERS_KEY_NAME = "client_filters"; + +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 00000000..006d969e --- /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 30a12f43..5a88b0fc 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -54,3 +54,16 @@ export const FAILOVER_REQUEST_TAG = "Failover"; // Compact feature tags export const FEATURES_KEY = "Features"; export const LOAD_BALANCE_CONFIGURED_TAG = "LB"; + +// 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 23ec4602..b56c460c 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, @@ -32,6 +36,7 @@ export interface RequestTracingOptions { initialLoadCompleted: boolean; replicaCount: number; isFailoverRequest: boolean; + featureFlagTracing: FeatureFlagTracingOptions | undefined; } // Utils @@ -78,6 +83,9 @@ export function createCorrelationContextHeader(requestTracingOptions: RequestTra Env: identify by env `NODE_ENV` which is a popular but not standard. Usually, the value can be "development", "production". ReplicaCount: identify how many replicas are found Features: LB + Filter: CSTM+TIME+TRGT + MaxVariants: identify the max number of variants feature flag uses + FFFeatures: Seed+Telemetry UsersKeyVault Failover */ @@ -96,6 +104,15 @@ export function createCorrelationContextHeader(requestTracingOptions: RequestTra } } + const featureFlagTracing = requestTracingOptions.featureFlagTracing; + 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()); + } + } + if (requestTracingOptions.isFailoverRequest) { tags.push(FAILOVER_REQUEST_TAG); } diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 311f857e..ed3fdaee 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -5,23 +5,11 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { MAX_TIME_OUT, createMockedConnectionString, createMockedKeyValue, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; import { ConfigurationClientManager } from "../src/ConfigurationClientManager.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(MAX_TIME_OUT); @@ -156,6 +144,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 d3e9a063..a5812694 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -247,6 +247,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, @@ -265,6 +279,6 @@ export { createMockedFeatureFlag, sleepInMs, - - MAX_TIME_OUT + MAX_TIME_OUT, + HttpRequestHeadersPolicy };