diff --git a/rollup.config.mjs b/rollup.config.mjs index b2e87c6..1cd15df 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,7 @@ import dts from "rollup-plugin-dts"; export default [ { - external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline"], + external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto"], input: "src/index.ts", output: [ { diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 30eba14..7f5600f 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,7 +9,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions"; import { Disposable } from "./common/disposable"; -import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME } from "./featureManagement/constants"; +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"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; import { RefreshTimer } from "./refresh/RefreshTimer"; import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils"; @@ -36,6 +36,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #sortedTrimKeyPrefixes: string[] | undefined; readonly #requestTracingEnabled: boolean; #client: AppConfigurationClient; + #clientEndpoint: string | undefined; #options: AzureAppConfigurationOptions | undefined; #isInitialLoadCompleted: boolean = false; @@ -57,9 +58,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { constructor( client: AppConfigurationClient, + clientEndpoint: string | undefined, options: AzureAppConfigurationOptions | undefined ) { this.#client = client; + this.#clientEndpoint = clientEndpoint; this.#options = options; // Enable request tracing if not opt-out @@ -255,8 +258,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}`, @@ -273,7 +275,7 @@ 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); } } } @@ -281,7 +283,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // 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 }); @@ -532,6 +536,83 @@ 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 (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { + const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; + 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), + ...(metadata || {}) + }; + } + + return featureFlag; + } + + async #calculateFeatureFlagId(setting: ConfigurationSetting): Promise { + 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; + } + } + + let baseString = `${setting.key}\n`; + if (setting.label && setting.label.trim().length !== 0) { + baseString += `${setting.label}`; + } + + // Convert to UTF-8 encoded bytes + const data = new TextEncoder().encode(baseString); + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + const base64String = btoa(String.fromCharCode(...hashArray)); + 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(); + return hash.toString("base64url"); + } + } + + #createFeatureFlagReference(setting: ConfigurationSetting): string { + let featureFlagReference = `${this.#clientEndpoint}kv/${setting.key}`; + if (setting.label && setting.label.trim().length !== 0) { + featureFlagReference += `?label=${setting.label}`; + } + return featureFlagReference; + } } function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index f0082f4..fe4d8cd 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -2,4 +2,10 @@ // 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 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"; diff --git a/src/load.ts b/src/load.ts index 10dda11..fffd09b 100644 --- a/src/load.ts +++ b/src/load.ts @@ -32,6 +32,7 @@ export async function load( ): Promise { const startTimestamp = Date.now(); let client: AppConfigurationClient; + let clientEndpoint: string | undefined; let options: AzureAppConfigurationOptions | undefined; // input validation @@ -40,12 +41,13 @@ export async function load( options = credentialOrOptions as AzureAppConfigurationOptions; const clientOptions = getClientOptions(options); client = new AppConfigurationClient(connectionString, clientOptions); + clientEndpoint = getEndpoint(connectionStringOrEndpoint); } else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && instanceOfTokenCredential(credentialOrOptions)) { - let endpoint = connectionStringOrEndpoint; // ensure string is a valid URL. - if (typeof endpoint === "string") { + if (typeof connectionStringOrEndpoint === "string") { try { - endpoint = new URL(endpoint); + const endpointUrl = new URL(connectionStringOrEndpoint); + clientEndpoint = endpointUrl.toString(); } catch (error) { if (error.code === "ERR_INVALID_URL") { throw new Error("Invalid endpoint URL.", { cause: error }); @@ -53,17 +55,19 @@ export async function load( throw error; } } + } else { + clientEndpoint = connectionStringOrEndpoint.toString(); } const credential = credentialOrOptions as TokenCredential; options = appConfigOptions; const clientOptions = getClientOptions(options); - client = new AppConfigurationClient(endpoint.toString(), credential, clientOptions); + client = new AppConfigurationClient(clientEndpoint, credential, clientOptions); } else { throw new Error("A connection string or an endpoint with credential must be specified to create a client."); } try { - const appConfiguration = new AzureAppConfigurationImpl(client, options); + const appConfiguration = new AzureAppConfigurationImpl(client, clientEndpoint, options); await appConfiguration.load(); return appConfiguration; } catch (error) { @@ -104,3 +108,18 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat } }); } + +function getEndpoint(connectionString: string): string | undefined { + const parts = connectionString.split(";"); + const endpointPart = parts.find(part => part.startsWith("Endpoint=")); + + if (endpointPart) { + let endpoint = endpointPart.split("=")[1]; + if (!endpoint.endsWith("/")) { + endpoint += "/"; + } + return endpoint; + } + + return undefined; +} diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 5022c0f..05537fc 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -4,7 +4,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; import { load } from "./exportedApi"; -import { createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper"; +import { createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -54,6 +54,8 @@ const mockedKVs = [{ createMockedFeatureFlag("Beta", { enabled: true }), 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"}) ]); describe("feature flags", function () { @@ -158,4 +160,44 @@ describe("feature flags", function () { expect(variant.telemetry).not.undefined; }); + it("should populate telemetry metadata", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "Telemetry_1" + }, + { + keyFilter: "Telemetry_2", + labelFilter: "Test" + } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(2); + + let featureFlag = featureFlags[0]; + expect(featureFlag).not.undefined; + expect(featureFlag.id).equals("Telemetry_1"); + expect(featureFlag.telemetry).not.undefined; + expect(featureFlag.telemetry.enabled).equals(true); + expect(featureFlag.telemetry.metadata.Etag).equals("Etag"); + expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("krkOsu9dVV9huwbQDPR6gkV_2T0buWxOCS-nNsj5-6g"); + expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_1`); + + featureFlag = featureFlags[1]; + expect(featureFlag).not.undefined; + expect(featureFlag.id).equals("Telemetry_2"); + expect(featureFlag.telemetry).not.undefined; + expect(featureFlag.telemetry.enabled).equals(true); + expect(featureFlag.telemetry.metadata.Etag).equals("Etag"); + expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o"); + expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); + }); });