diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 9b351942..977872a8 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,7 +9,28 @@ 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, TELEMETRY_KEY_NAME, ENABLED_KEY_NAME, METADATA_KEY_NAME, ETAG_KEY_NAME, FEATURE_FLAG_ID_KEY_NAME, FEATURE_FLAG_REFERENCE_KEY_NAME } from "./featureManagement/constants.js"; +import { base64Helper, jsonSorter } from "./common/utils.js"; +import { + FEATURE_FLAGS_KEY_NAME, + FEATURE_MANAGEMENT_KEY_NAME, + NAME_KEY_NAME, + TELEMETRY_KEY_NAME, + ENABLED_KEY_NAME, + METADATA_KEY_NAME, + ETAG_KEY_NAME, + FEATURE_FLAG_ID_KEY_NAME, + FEATURE_FLAG_REFERENCE_KEY_NAME, + ALLOCATION_ID_KEY_NAME, + ALLOCATION_KEY_NAME, + DEFAULT_WHEN_ENABLED_KEY_NAME, + PERCENTILE_KEY_NAME, + FROM_KEY_NAME, + TO_KEY_NAME, + SEED_KEY_NAME, + VARIANT_KEY_NAME, + VARIANTS_KEY_NAME, + CONFIGURATION_VALUE_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"; @@ -546,10 +567,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; + let allocationId = ""; + if (featureFlag[ALLOCATION_KEY_NAME] !== undefined) { + allocationId = await this.#generateAllocationId(featureFlag); + } featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = { [ETAG_KEY_NAME]: setting.etag, [FEATURE_FLAG_ID_KEY_NAME]: await this.#calculateFeatureFlagId(setting), [FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), + ...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }), ...(metadata || {}) }; } @@ -595,6 +621,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (crypto.subtle) { const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = new Uint8Array(hashBuffer); + // btoa/atob is also available in Node.js 18+ const base64String = btoa(String.fromCharCode(...hashArray)); const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return base64urlString; @@ -613,6 +640,116 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } return featureFlagReference; } + + async #generateAllocationId(featureFlag: any): Promise { + let rawAllocationId = ""; + // Only default variant when enabled and variants allocated by percentile involve in the experimentation + // The allocation id is genearted from default variant when enabled and percentile allocation + const variantsForExperimentation: string[] = []; + + rawAllocationId += `seed=${featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] ?? ""}\ndefault_when_enabled=`; + + if (featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]) { + variantsForExperimentation.push(featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]); + rawAllocationId += `${featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]}`; + } + + rawAllocationId += "\npercentiles="; + + const percentileList = featureFlag[ALLOCATION_KEY_NAME][PERCENTILE_KEY_NAME]; + if (percentileList) { + const sortedPercentileList = percentileList + .filter(p => + (p[FROM_KEY_NAME] !== undefined) && + (p[TO_KEY_NAME] !== undefined) && + (p[VARIANT_KEY_NAME] !== undefined) && + (p[FROM_KEY_NAME] !== p[TO_KEY_NAME])) + .sort((a, b) => a[FROM_KEY_NAME] - b[FROM_KEY_NAME]); + + const percentileAllocation: string[] = []; + for (const percentile of sortedPercentileList) { + variantsForExperimentation.push(percentile[VARIANT_KEY_NAME]); + percentileAllocation.push(`${percentile[FROM_KEY_NAME]},${base64Helper(percentile[VARIANT_KEY_NAME])},${percentile[TO_KEY_NAME]}`); + } + rawAllocationId += percentileAllocation.join(";"); + } + + if (variantsForExperimentation.length === 0 && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] === undefined) { + // All fields required for generating allocation id are missing, short-circuit and return empty string + return ""; + } + + rawAllocationId += "\nvariants="; + + if (variantsForExperimentation.length !== 0) { + const variantsList = featureFlag[VARIANTS_KEY_NAME]; + if (variantsList) { + const sortedVariantsList = variantsList + .filter(v => + (v[NAME_KEY_NAME] !== undefined) && + variantsForExperimentation.includes(v[NAME_KEY_NAME])) + .sort((a, b) => (a.name > b.name ? 1 : -1)); + + const variantConfiguration: string[] = []; + for (const variant of sortedVariantsList) { + const configurationValue = JSON.stringify(variant[CONFIGURATION_VALUE_KEY_NAME], jsonSorter) ?? ""; + variantConfiguration.push(`${base64Helper(variant[NAME_KEY_NAME])},${configurationValue}`); + } + rawAllocationId += variantConfiguration.join(";"); + } + } + + let crypto; + + // Check for browser environment + if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { + crypto = window.crypto; + } + // Check for Node.js environment + else if (typeof global !== "undefined" && global.crypto) { + crypto = global.crypto; + } + // Fallback to native Node.js crypto module + else { + try { + if (typeof module !== "undefined" && module.exports) { + crypto = require("crypto"); + } + else { + crypto = await import("crypto"); + } + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } + + // Convert to UTF-8 encoded bytes + const data = new TextEncoder().encode(rawAllocationId); + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + + // Only use the first 15 bytes + const first15Bytes = hashArray.slice(0, 15); + + // btoa/atob is also available in Node.js 18+ + const base64String = btoa(String.fromCharCode(...first15Bytes)); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlString; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(data).digest(); + + // Only use the first 15 bytes + const first15Bytes = hash.slice(0, 15); + + return first15Bytes.toString("base64url"); + } + } } function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 00000000..ad827bbb --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export function base64Helper(str: string): string { + const bytes = new TextEncoder().encode(str); // UTF-8 encoding + let chars = ""; + for (let i = 0; i < bytes.length; i++) { + chars += String.fromCharCode(bytes[i]); + } + return btoa(chars); +} + +export function jsonSorter(key, value) { + if (value === null) { + return null; + } + if (Array.isArray(value)) { + return value; + } + if (typeof value === "object") { + return Object.fromEntries(Object.entries(value).sort()); + } + return value; +} diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index 55a1dc54..7b6bda8e 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -3,9 +3,20 @@ export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management"; export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; +export const NAME_KEY_NAME = "name"; export const TELEMETRY_KEY_NAME = "telemetry"; export const ENABLED_KEY_NAME = "enabled"; export const METADATA_KEY_NAME = "metadata"; export const ETAG_KEY_NAME = "ETag"; export const FEATURE_FLAG_ID_KEY_NAME = "FeatureFlagId"; export const FEATURE_FLAG_REFERENCE_KEY_NAME = "FeatureFlagReference"; +export const ALLOCATION_KEY_NAME = "allocation"; +export const DEFAULT_WHEN_ENABLED_KEY_NAME = "default_when_enabled"; +export const PERCENTILE_KEY_NAME = "percentile"; +export const FROM_KEY_NAME = "from"; +export const TO_KEY_NAME = "to"; +export const SEED_KEY_NAME = "seed"; +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"; diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 780cd4a4..1bf92d7f 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -55,7 +55,147 @@ const mockedKVs = [{ createMockedFeatureFlag("Alpha_1", { enabled: true }), createMockedFeatureFlag("Alpha_2", { enabled: false }), createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), - createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}) + createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), + createMockedFeatureFlag("NoPercentileAndSeed", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_disabled: "Control", + user: [ {users: ["Jeff"], variant: "Test"} ] + } + }), + createMockedFeatureFlag("SeedOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_disabled: "Control", + user: [ {users: ["Jeff"], variant: "Test"} ], + seed: "123" + } + }), + createMockedFeatureFlag("DefaultWhenEnabledOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_enabled: "Control" + } + }), + createMockedFeatureFlag("PercentileOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ ], + allocation: { + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ] + } + }), + createMockedFeatureFlag("SimpleConfigurationValue", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control", configuration_value: "standard" }, { name: "Test", configuration_value: "special" } ], + allocation: { + default_when_enabled: "Control", + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], + seed: "123" + } + }), + createMockedFeatureFlag("ComplexConfigurationValue", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control", configuration_value: { title: { size: 100, color: "red" }, options: [ 1, 2, 3 ]} }, { name: "Test", configuration_value: { title: { size: 200, color: "blue" }, options: [ "1", "2", "3" ]} } ], + allocation: { + default_when_enabled: "Control", + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], + seed: "123" + } + }), + createMockedFeatureFlag("TelemetryVariantPercentile", { + enabled: true, + telemetry: { enabled: true }, + variants: [ + { + name: "True_Override", + configuration_value: { + someOtherKey: { + someSubKey: "someSubValue" + }, + someKey4: [3, 1, 4, true], + someKey: "someValue", + someKey3: 3.14, + someKey2: 3 + } + } + ], + allocation: { + default_when_enabled: "True_Override", + percentile: [ + { + variant: "True_Override", + from: 0, + to: 100 + } + ] + } + }), + createMockedFeatureFlag("Complete", { + enabled: true, + telemetry: { enabled: true }, + variants: [ + { + name: "Large", + configuration_value: 100 + }, + { + name: "Medium", + configuration_value: 50 + }, + { + name: "Small", + configuration_value: 10 + } + ], + allocation: { + percentile: [ + { + variant: "Large", + from: 0, + to: 25 + }, + { + variant: "Medium", + from: 25, + to: 55 + }, + { + variant: "Small", + from: 55, + to: 95 + }, + { + variant: "Large", + from: 95, + to: 100 + } + ], + group: [ + { + variant: "Large", + groups: ["beta"] + } + ], + user: [ + { + variant: "Small", + users: ["Richel"] + } + ], + seed: "test-seed", + default_when_enabled: "Medium", + default_when_disabled: "Medium" + } + }) ]); describe("feature flags", function () { @@ -200,4 +340,64 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o"); expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); + + it("should not populate allocation id", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*" } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + + const NoPercentileAndSeed = (featureFlags as any[]).find(item => item.id === "NoPercentileAndSeed"); + expect(NoPercentileAndSeed).not.undefined; + expect(NoPercentileAndSeed?.telemetry.metadata.AllocationId).to.be.undefined; + }); + + it("should populate allocation id", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*" } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + + const SeedOnly = (featureFlags as any[]).find(item => item.id === "SeedOnly"); + expect(SeedOnly).not.undefined; + expect(SeedOnly?.telemetry.metadata.AllocationId).equals("qZApcKdfXscxpgn_8CMf"); + + const DefaultWhenEnabledOnly = (featureFlags as any[]).find(item => item.id === "DefaultWhenEnabledOnly"); + expect(DefaultWhenEnabledOnly).not.undefined; + expect(DefaultWhenEnabledOnly?.telemetry.metadata.AllocationId).equals("k486zJjud_HkKaL1C4qB"); + + const PercentileOnly = (featureFlags as any[]).find(item => item.id === "PercentileOnly"); + expect(PercentileOnly).not.undefined; + expect(PercentileOnly?.telemetry.metadata.AllocationId).equals("5YUbmP0P5s47zagO_LvI"); + + const SimpleConfigurationValue = (featureFlags as any[]).find(item => item.id === "SimpleConfigurationValue"); + expect(SimpleConfigurationValue).not.undefined; + expect(SimpleConfigurationValue?.telemetry.metadata.AllocationId).equals("QIOEOTQJr2AXo4dkFFqy"); + + const ComplexConfigurationValue = (featureFlags as any[]).find(item => item.id === "ComplexConfigurationValue"); + expect(ComplexConfigurationValue).not.undefined; + expect(ComplexConfigurationValue?.telemetry.metadata.AllocationId).equals("4Bes0AlwuO8kYX-YkBWs"); + + const TelemetryVariantPercentile = (featureFlags as any[]).find(item => item.id === "TelemetryVariantPercentile"); + expect(TelemetryVariantPercentile).not.undefined; + expect(TelemetryVariantPercentile?.telemetry.metadata.AllocationId).equals("YsdJ4pQpmhYa8KEhRLUn"); + + const Complete = (featureFlags as any[]).find(item => item.id === "Complete"); + expect(Complete).not.undefined; + expect(Complete?.telemetry.metadata.AllocationId).equals("DER2rF-ZYog95c4CBZoi"); + }); });