Skip to content
Merged
139 changes: 138 additions & 1 deletion src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 || {})
};
}
Expand Down Expand Up @@ -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;
Expand All @@ -613,6 +640,116 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
return featureFlagReference;
}

async #generateAllocationId(featureFlag: any): Promise<string> {
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[] {
Expand Down
24 changes: 24 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions src/featureManagement/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading