Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
88 changes: 85 additions & 3 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -273,15 +276,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
pageEtags.push(page.etag ?? "");
for (const setting of page.items) {
if (isFeatureFlag(setting)) {
featureFlagsMap.set(setting.key, setting.value);
featureFlagsMap.set(setting.key, setting);
}
}
}
selector.pageEtags = pageEtags;
}

// parse feature flags
const featureFlags = Array.from(featureFlagsMap.values()).map(rawFlag => JSON.parse(rawFlag));
const featureFlags = await Promise.all(
Array.from(featureFlagsMap.values()).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 });
Expand Down Expand Up @@ -532,6 +537,83 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
return response;
}

async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
Comment thread
zhiyuanliang-ms marked this conversation as resolved.
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<string>): Promise<string> {
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>): string {
let featureFlagReference = `${this.#clientEndpoint}kv/${setting.key}`;
Comment thread
zhiyuanliang-ms marked this conversation as resolved.
Comment thread
zhiyuanliang-ms marked this conversation as resolved.
if (setting.label && setting.label.trim().length !== 0) {
featureFlagReference += `?label=${setting.label}`;
}
return featureFlagReference;
}
}

function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
Expand Down
8 changes: 7 additions & 1 deletion src/featureManagement/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
29 changes: 24 additions & 5 deletions src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export async function load(
): Promise<AzureAppConfiguration> {
const startTimestamp = Date.now();
let client: AppConfigurationClient;
let clientEndpoint: string | undefined;
let options: AzureAppConfigurationOptions | undefined;

// input validation
Expand All @@ -40,30 +41,33 @@ export async function load(
options = credentialOrOptions as AzureAppConfigurationOptions;
const clientOptions = getClientOptions(options);
client = new AppConfigurationClient(connectionString, clientOptions);
clientEndpoint = parseEndpoint(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 });
} else {
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) {
Expand Down Expand Up @@ -104,3 +108,18 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat
}
});
}

function parseEndpoint(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;
}
44 changes: 43 additions & 1 deletion test/featureFlag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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<any>("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`);
});
});