Skip to content

Commit 3f6f7aa

Browse files
revert revert allocation id change
1 parent b5f7202 commit 3f6f7aa

File tree

3 files changed

+212
-6
lines changed

3 files changed

+212
-6
lines changed

src/AzureAppConfigurationImpl.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js";
99
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js";
1010
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js";
1111
import { Disposable } from "./common/disposable.js";
12+
import { base64Helper, jsonSorter } from "./common/utils.js";
1213
import {
1314
FEATURE_FLAGS_KEY_NAME,
1415
FEATURE_MANAGEMENT_KEY_NAME,
@@ -19,9 +20,16 @@ import {
1920
ETAG_KEY_NAME,
2021
FEATURE_FLAG_ID_KEY_NAME,
2122
FEATURE_FLAG_REFERENCE_KEY_NAME,
23+
ALLOCATION_ID_KEY_NAME,
2224
ALLOCATION_KEY_NAME,
25+
DEFAULT_WHEN_ENABLED_KEY_NAME,
26+
PERCENTILE_KEY_NAME,
27+
FROM_KEY_NAME,
28+
TO_KEY_NAME,
2329
SEED_KEY_NAME,
30+
VARIANT_KEY_NAME,
2431
VARIANTS_KEY_NAME,
32+
CONFIGURATION_VALUE_KEY_NAME,
2533
CONDITIONS_KEY_NAME,
2634
CLIENT_FILTERS_KEY_NAME
2735
} from "./featureManagement/constants.js";
@@ -669,10 +677,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
669677

670678
if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) {
671679
const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME];
680+
let allocationId = "";
681+
if (featureFlag[ALLOCATION_KEY_NAME] !== undefined) {
682+
allocationId = await this.#generateAllocationId(featureFlag);
683+
}
672684
featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = {
673685
[ETAG_KEY_NAME]: setting.etag,
674686
[FEATURE_FLAG_ID_KEY_NAME]: await this.#calculateFeatureFlagId(setting),
675687
[FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting),
688+
...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }),
676689
...(metadata || {})
677690
};
678691
}
@@ -756,6 +769,116 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
756769
}
757770
return featureFlagReference;
758771
}
772+
773+
async #generateAllocationId(featureFlag: any): Promise<string> {
774+
let rawAllocationId = "";
775+
// Only default variant when enabled and variants allocated by percentile involve in the experimentation
776+
// The allocation id is genearted from default variant when enabled and percentile allocation
777+
const variantsForExperimentation: string[] = [];
778+
779+
rawAllocationId += `seed=${featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] ?? ""}\ndefault_when_enabled=`;
780+
781+
if (featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]) {
782+
variantsForExperimentation.push(featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]);
783+
rawAllocationId += `${featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]}`;
784+
}
785+
786+
rawAllocationId += "\npercentiles=";
787+
788+
const percentileList = featureFlag[ALLOCATION_KEY_NAME][PERCENTILE_KEY_NAME];
789+
if (percentileList) {
790+
const sortedPercentileList = percentileList
791+
.filter(p =>
792+
(p[FROM_KEY_NAME] !== undefined) &&
793+
(p[TO_KEY_NAME] !== undefined) &&
794+
(p[VARIANT_KEY_NAME] !== undefined) &&
795+
(p[FROM_KEY_NAME] !== p[TO_KEY_NAME]))
796+
.sort((a, b) => a[FROM_KEY_NAME] - b[FROM_KEY_NAME]);
797+
798+
const percentileAllocation: string[] = [];
799+
for (const percentile of sortedPercentileList) {
800+
variantsForExperimentation.push(percentile[VARIANT_KEY_NAME]);
801+
percentileAllocation.push(`${percentile[FROM_KEY_NAME]},${base64Helper(percentile[VARIANT_KEY_NAME])},${percentile[TO_KEY_NAME]}`);
802+
}
803+
rawAllocationId += percentileAllocation.join(";");
804+
}
805+
806+
if (variantsForExperimentation.length === 0 && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] === undefined) {
807+
// All fields required for generating allocation id are missing, short-circuit and return empty string
808+
return "";
809+
}
810+
811+
rawAllocationId += "\nvariants=";
812+
813+
if (variantsForExperimentation.length !== 0) {
814+
const variantsList = featureFlag[VARIANTS_KEY_NAME];
815+
if (variantsList) {
816+
const sortedVariantsList = variantsList
817+
.filter(v =>
818+
(v[NAME_KEY_NAME] !== undefined) &&
819+
variantsForExperimentation.includes(v[NAME_KEY_NAME]))
820+
.sort((a, b) => (a.name > b.name ? 1 : -1));
821+
822+
const variantConfiguration: string[] = [];
823+
for (const variant of sortedVariantsList) {
824+
const configurationValue = JSON.stringify(variant[CONFIGURATION_VALUE_KEY_NAME], jsonSorter) ?? "";
825+
variantConfiguration.push(`${base64Helper(variant[NAME_KEY_NAME])},${configurationValue}`);
826+
}
827+
rawAllocationId += variantConfiguration.join(";");
828+
}
829+
}
830+
831+
let crypto;
832+
833+
// Check for browser environment
834+
if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
835+
crypto = window.crypto;
836+
}
837+
// Check for Node.js environment
838+
else if (typeof global !== "undefined" && global.crypto) {
839+
crypto = global.crypto;
840+
}
841+
// Fallback to native Node.js crypto module
842+
else {
843+
try {
844+
if (typeof module !== "undefined" && module.exports) {
845+
crypto = require("crypto");
846+
}
847+
else {
848+
crypto = await import("crypto");
849+
}
850+
} catch (error) {
851+
console.error("Failed to load the crypto module:", error.message);
852+
throw error;
853+
}
854+
}
855+
856+
// Convert to UTF-8 encoded bytes
857+
const data = new TextEncoder().encode(rawAllocationId);
858+
859+
// In the browser, use crypto.subtle.digest
860+
if (crypto.subtle) {
861+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
862+
const hashArray = new Uint8Array(hashBuffer);
863+
864+
// Only use the first 15 bytes
865+
const first15Bytes = hashArray.slice(0, 15);
866+
867+
// btoa/atob is also available in Node.js 18+
868+
const base64String = btoa(String.fromCharCode(...first15Bytes));
869+
const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
870+
return base64urlString;
871+
}
872+
// In Node.js, use the crypto module's hash function
873+
else {
874+
const hash = crypto.createHash("sha256").update(data).digest();
875+
876+
// Only use the first 15 bytes
877+
const first15Bytes = hash.slice(0, 15);
878+
879+
return first15Bytes.toString("base64url");
880+
}
881+
}
759882
}
760883

761884
function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {

src/common/utils.ts

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

4-
export function shuffleList<T>(array: T[]): T[] {
5-
for (let i = array.length - 1; i > 0; i--) {
6-
const j = Math.floor(Math.random() * (i + 1));
7-
[array[i], array[j]] = [array[j], array[i]];
8-
}
9-
return array;
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;
1024
}
25+
26+
export function shuffleList<T>(array: T[]): T[] {
27+
for (let i = array.length - 1; i > 0; i--) {
28+
const j = Math.floor(Math.random() * (i + 1));
29+
[array[i], array[j]] = [array[j], array[i]];
30+
}
31+
return array;
32+
}

test/featureFlag.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,65 @@ describe("feature flags", function () {
339339
expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o");
340340
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
341341
});
342+
343+
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+
});
342403
});

0 commit comments

Comments
 (0)