Skip to content

Commit d9b3347

Browse files
Merge branch 'preview' of https://github.com/Azure/AppConfiguration-JavaScriptProvider into zhiyuanliang/snapshot-reference
2 parents 59e825a + 30e879c commit d9b3347

File tree

5 files changed

+231
-22
lines changed

5 files changed

+231
-22
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export default defineConfig([globalIgnores([
5353
}],
5454

5555
"@typescript-eslint/no-explicit-any": "off",
56+
"@typescript-eslint/no-require-imports": "off",
5657
"eol-last": ["error", "always"],
5758
"no-trailing-spaces": "error",
5859
"space-before-blocks": ["error", "always"],

package-lock.json

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/appConfigurationImpl.ts

Lines changed: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./startupOptions.js";
2525
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js";
2626
import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyvault/keyVaultOptions.js";
2727
import { Disposable } from "./common/disposable.js";
28+
import { base64Helper, jsonSorter } from "./common/utils.js";
2829
import {
2930
FEATURE_FLAGS_KEY_NAME,
3031
FEATURE_MANAGEMENT_KEY_NAME,
@@ -34,9 +35,16 @@ import {
3435
METADATA_KEY_NAME,
3536
ETAG_KEY_NAME,
3637
FEATURE_FLAG_REFERENCE_KEY_NAME,
38+
ALLOCATION_ID_KEY_NAME,
3739
ALLOCATION_KEY_NAME,
40+
DEFAULT_WHEN_ENABLED_KEY_NAME,
41+
PERCENTILE_KEY_NAME,
42+
FROM_KEY_NAME,
43+
TO_KEY_NAME,
3844
SEED_KEY_NAME,
45+
VARIANT_KEY_NAME,
3946
VARIANTS_KEY_NAME,
47+
CONFIGURATION_VALUE_KEY_NAME,
4048
CONDITIONS_KEY_NAME,
4149
CLIENT_FILTERS_KEY_NAME
4250
} from "./featureManagement/constants.js";
@@ -642,23 +650,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
642650

643651
// try refresh if any of watched settings is changed.
644652
let needRefresh = false;
645-
let changedSentinel;
646-
let changedSentinelWatcher;
653+
let changedSentinel: WatchedSetting | undefined;
654+
let changedSentinelWatcher: SettingWatcher | undefined;
647655
if (this.#watchAll) {
648656
needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors);
649657
} else {
650658
for (const watchedSetting of this.#sentinels.keys()) {
651659
const configurationSettingId: ConfigurationSettingId = { key: watchedSetting.key, label: watchedSetting.label, etag: this.#sentinels.get(watchedSetting)?.etag };
652-
const response = await this.#getConfigurationSetting(configurationSettingId, {
653-
onlyIfChanged: true
654-
});
655-
656-
const watcher = this.#sentinels.get(watchedSetting);
657-
if (response?.statusCode === 200 // created or changed
658-
|| (response === undefined && watcher?.etag !== undefined) // deleted
659-
) {
660+
const response: GetConfigurationSettingResponse | undefined =
661+
await this.#getConfigurationSetting(configurationSettingId, { onlyIfChanged: true });
662+
663+
const watcher: SettingWatcher = this.#sentinels.get(watchedSetting)!; // watcher should always exist for sentinels
664+
const isDeleted = response === undefined && watcher.etag !== undefined; // previously existed, now deleted
665+
const isChanged = response && response.statusCode === 200 && watcher.etag !== response.etag; // etag changed
666+
if (isDeleted || isChanged) {
660667
changedSentinel = watchedSetting;
661-
changedSentinelWatcher = watcher;
668+
changedSentinelWatcher = { etag: isChanged ? response.etag : undefined };
662669
needRefresh = true;
663670
break;
664671
}
@@ -670,7 +677,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
670677
await adapter.onChangeDetected();
671678
}
672679
await this.#loadSelectedKeyValues();
673-
this.#sentinels.set(changedSentinel, changedSentinelWatcher); // update the changed sentinel's watcher
680+
681+
if (changedSentinel && changedSentinelWatcher) {
682+
// update the changed sentinel's watcher after loading new values, this can ensure a failed refresh will retry on next refresh
683+
this.#sentinels.set(changedSentinel, changedSentinelWatcher);
684+
}
674685
}
675686

676687
this.#kvRefreshTimer.reset();
@@ -964,9 +975,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
964975

965976
if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) {
966977
const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME];
978+
let allocationId = "";
979+
if (featureFlag[ALLOCATION_KEY_NAME] !== undefined) {
980+
allocationId = await this.#generateAllocationId(featureFlag);
981+
}
967982
featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = {
968983
[ETAG_KEY_NAME]: setting.etag,
969984
[FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting),
985+
...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }),
970986
...(metadata || {})
971987
};
972988
}
@@ -1004,6 +1020,116 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
10041020
}
10051021
}
10061022
}
1023+
1024+
async #generateAllocationId(featureFlag: any): Promise<string> {
1025+
let rawAllocationId = "";
1026+
// Only default variant when enabled and variants allocated by percentile involve in the experimentation
1027+
// The allocation id is genearted from default variant when enabled and percentile allocation
1028+
const variantsForExperimentation: string[] = [];
1029+
1030+
rawAllocationId += `seed=${featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] ?? ""}\ndefault_when_enabled=`;
1031+
1032+
if (featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]) {
1033+
variantsForExperimentation.push(featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]);
1034+
rawAllocationId += `${featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]}`;
1035+
}
1036+
1037+
rawAllocationId += "\npercentiles=";
1038+
1039+
const percentileList = featureFlag[ALLOCATION_KEY_NAME][PERCENTILE_KEY_NAME];
1040+
if (percentileList) {
1041+
const sortedPercentileList = percentileList
1042+
.filter(p =>
1043+
(p[FROM_KEY_NAME] !== undefined) &&
1044+
(p[TO_KEY_NAME] !== undefined) &&
1045+
(p[VARIANT_KEY_NAME] !== undefined) &&
1046+
(p[FROM_KEY_NAME] !== p[TO_KEY_NAME]))
1047+
.sort((a, b) => a[FROM_KEY_NAME] - b[FROM_KEY_NAME]);
1048+
1049+
const percentileAllocation: string[] = [];
1050+
for (const percentile of sortedPercentileList) {
1051+
variantsForExperimentation.push(percentile[VARIANT_KEY_NAME]);
1052+
percentileAllocation.push(`${percentile[FROM_KEY_NAME]},${base64Helper(percentile[VARIANT_KEY_NAME])},${percentile[TO_KEY_NAME]}`);
1053+
}
1054+
rawAllocationId += percentileAllocation.join(";");
1055+
}
1056+
1057+
if (variantsForExperimentation.length === 0 && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] === undefined) {
1058+
// All fields required for generating allocation id are missing, short-circuit and return empty string
1059+
return "";
1060+
}
1061+
1062+
rawAllocationId += "\nvariants=";
1063+
1064+
if (variantsForExperimentation.length !== 0) {
1065+
const variantsList = featureFlag[VARIANTS_KEY_NAME];
1066+
if (variantsList) {
1067+
const sortedVariantsList = variantsList
1068+
.filter(v =>
1069+
(v[NAME_KEY_NAME] !== undefined) &&
1070+
variantsForExperimentation.includes(v[NAME_KEY_NAME]))
1071+
.sort((a, b) => (a.name > b.name ? 1 : -1));
1072+
1073+
const variantConfiguration: string[] = [];
1074+
for (const variant of sortedVariantsList) {
1075+
const configurationValue = JSON.stringify(variant[CONFIGURATION_VALUE_KEY_NAME], jsonSorter) ?? "";
1076+
variantConfiguration.push(`${base64Helper(variant[NAME_KEY_NAME])},${configurationValue}`);
1077+
}
1078+
rawAllocationId += variantConfiguration.join(";");
1079+
}
1080+
}
1081+
1082+
let crypto;
1083+
1084+
// Check for browser environment
1085+
if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
1086+
crypto = window.crypto;
1087+
}
1088+
// Check for Node.js environment
1089+
else if (typeof global !== "undefined" && global.crypto) {
1090+
crypto = global.crypto;
1091+
}
1092+
// Fallback to native Node.js crypto module
1093+
else {
1094+
try {
1095+
if (typeof module !== "undefined" && module.exports) {
1096+
crypto = require("crypto");
1097+
}
1098+
else {
1099+
crypto = await import("crypto");
1100+
}
1101+
} catch (error) {
1102+
console.error("Failed to load the crypto module:", error.message);
1103+
throw error;
1104+
}
1105+
}
1106+
1107+
// Convert to UTF-8 encoded bytes
1108+
const data = new TextEncoder().encode(rawAllocationId);
1109+
1110+
// In the browser, use crypto.subtle.digest
1111+
if (crypto.subtle) {
1112+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1113+
const hashArray = new Uint8Array(hashBuffer);
1114+
1115+
// Only use the first 15 bytes
1116+
const first15Bytes = hashArray.slice(0, 15);
1117+
1118+
// btoa/atob is also available in Node.js 18+
1119+
const base64String = btoa(String.fromCharCode(...first15Bytes));
1120+
const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1121+
return base64urlString;
1122+
}
1123+
// In Node.js, use the crypto module's hash function
1124+
else {
1125+
const hash = crypto.createHash("sha256").update(data).digest();
1126+
1127+
// Only use the first 15 bytes
1128+
const first15Bytes = hash.slice(0, 15);
1129+
1130+
return first15Bytes.toString("base64url");
1131+
}
1132+
}
10071133
}
10081134

10091135
function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] {

src/common/utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4+
export function base64Helper(str: string): string {
5+
const bytes = new TextEncoder().encode(str); // UTF-8 encoding
6+
let chars = "";
7+
for (let i = 0; i < bytes.length; i++) {
8+
chars += String.fromCharCode(bytes[i]);
9+
}
10+
return btoa(chars);
11+
}
12+
13+
export function jsonSorter(key, value) {
14+
if (value === null) {
15+
return null;
16+
}
17+
if (Array.isArray(value)) {
18+
return value;
19+
}
20+
if (typeof value === "object") {
21+
return Object.fromEntries(Object.entries(value).sort());
22+
}
23+
return value;
24+
}
25+
426
export function shuffleList<T>(array: T[]): T[] {
527
for (let i = array.length - 1; i > 0; i--) {
628
const j = Math.floor(Math.random() * (i + 1));

test/featureFlag.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,66 @@ describe("feature flags", function () {
341341
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
342342
});
343343

344+
it("should not populate allocation id", async () => {
345+
const connectionString = createMockedConnectionString();
346+
const settings = await load(connectionString, {
347+
featureFlagOptions: {
348+
enabled: true,
349+
selectors: [ { keyFilter: "*" } ]
350+
}
351+
});
352+
expect(settings).not.undefined;
353+
expect(settings.get("feature_management")).not.undefined;
354+
const featureFlags = settings.get<any>("feature_management").feature_flags;
355+
expect(featureFlags).not.undefined;
356+
357+
const NoPercentileAndSeed = (featureFlags as any[]).find(item => item.id === "NoPercentileAndSeed");
358+
expect(NoPercentileAndSeed).not.undefined;
359+
expect(NoPercentileAndSeed?.telemetry.metadata.AllocationId).to.be.undefined;
360+
});
361+
362+
it("should populate allocation id", async () => {
363+
const connectionString = createMockedConnectionString();
364+
const settings = await load(connectionString, {
365+
featureFlagOptions: {
366+
enabled: true,
367+
selectors: [ { keyFilter: "*" } ]
368+
}
369+
});
370+
expect(settings).not.undefined;
371+
expect(settings.get("feature_management")).not.undefined;
372+
const featureFlags = settings.get<any>("feature_management").feature_flags;
373+
expect(featureFlags).not.undefined;
374+
375+
const SeedOnly = (featureFlags as any[]).find(item => item.id === "SeedOnly");
376+
expect(SeedOnly).not.undefined;
377+
expect(SeedOnly?.telemetry.metadata.AllocationId).equals("qZApcKdfXscxpgn_8CMf");
378+
379+
const DefaultWhenEnabledOnly = (featureFlags as any[]).find(item => item.id === "DefaultWhenEnabledOnly");
380+
expect(DefaultWhenEnabledOnly).not.undefined;
381+
expect(DefaultWhenEnabledOnly?.telemetry.metadata.AllocationId).equals("k486zJjud_HkKaL1C4qB");
382+
383+
const PercentileOnly = (featureFlags as any[]).find(item => item.id === "PercentileOnly");
384+
expect(PercentileOnly).not.undefined;
385+
expect(PercentileOnly?.telemetry.metadata.AllocationId).equals("5YUbmP0P5s47zagO_LvI");
386+
387+
const SimpleConfigurationValue = (featureFlags as any[]).find(item => item.id === "SimpleConfigurationValue");
388+
expect(SimpleConfigurationValue).not.undefined;
389+
expect(SimpleConfigurationValue?.telemetry.metadata.AllocationId).equals("QIOEOTQJr2AXo4dkFFqy");
390+
391+
const ComplexConfigurationValue = (featureFlags as any[]).find(item => item.id === "ComplexConfigurationValue");
392+
expect(ComplexConfigurationValue).not.undefined;
393+
expect(ComplexConfigurationValue?.telemetry.metadata.AllocationId).equals("4Bes0AlwuO8kYX-YkBWs");
394+
395+
const TelemetryVariantPercentile = (featureFlags as any[]).find(item => item.id === "TelemetryVariantPercentile");
396+
expect(TelemetryVariantPercentile).not.undefined;
397+
expect(TelemetryVariantPercentile?.telemetry.metadata.AllocationId).equals("YsdJ4pQpmhYa8KEhRLUn");
398+
399+
const Complete = (featureFlags as any[]).find(item => item.id === "Complete");
400+
expect(Complete).not.undefined;
401+
expect(Complete?.telemetry.metadata.AllocationId).equals("DER2rF-ZYog95c4CBZoi");
402+
});
403+
344404
it("should load feature flags using tag filters", async () => {
345405
const connectionString = createMockedConnectionString();
346406

0 commit comments

Comments
 (0)