Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Flag Telemetry Support #101

Merged
merged 11 commits into from
Oct 15, 2024
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> {
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide 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}`;
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide resolved
zhiyuanliang-ms marked this conversation as resolved.
Show resolved Hide 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;
}
Loading