Skip to content

Commit af0ccb3

Browse files
Feature Flag Telemetry Support (#101)
* WIP * populate feature flag id * fix lint * populate only when telemetry is enabled * add testcase * fix lint * update * use window.btoa * rename method * revert add window.
1 parent cca37e7 commit af0ccb3

File tree

5 files changed

+161
-13
lines changed

5 files changed

+161
-13
lines changed

rollup.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import dts from "rollup-plugin-dts";
44

55
export default [
66
{
7-
external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline"],
7+
external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto"],
88
input: "src/index.ts",
99
output: [
1010
{

src/AzureAppConfigurationImpl.ts

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter";
99
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter";
1010
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions";
1111
import { Disposable } from "./common/disposable";
12-
import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME } from "./featureManagement/constants";
12+
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";
1313
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter";
1414
import { RefreshTimer } from "./refresh/RefreshTimer";
1515
import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils";
@@ -36,6 +36,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
3636
#sortedTrimKeyPrefixes: string[] | undefined;
3737
readonly #requestTracingEnabled: boolean;
3838
#client: AppConfigurationClient;
39+
#clientEndpoint: string | undefined;
3940
#options: AzureAppConfigurationOptions | undefined;
4041
#isInitialLoadCompleted: boolean = false;
4142

@@ -57,9 +58,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
5758

5859
constructor(
5960
client: AppConfigurationClient,
61+
clientEndpoint: string | undefined,
6062
options: AzureAppConfigurationOptions | undefined
6163
) {
6264
this.#client = client;
65+
this.#clientEndpoint = clientEndpoint;
6366
this.#options = options;
6467

6568
// Enable request tracing if not opt-out
@@ -255,8 +258,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
255258
}
256259

257260
async #loadFeatureFlags() {
258-
// Temporary map to store feature flags, key is the key of the setting, value is the raw value of the setting
259-
const featureFlagsMap = new Map<string, any>();
261+
const featureFlagSettings: ConfigurationSetting[] = [];
260262
for (const selector of this.#featureFlagSelectors) {
261263
const listOptions: ListConfigurationSettingsOptions = {
262264
keyFilter: `${featureFlagPrefix}${selector.keyFilter}`,
@@ -273,15 +275,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
273275
pageEtags.push(page.etag ?? "");
274276
for (const setting of page.items) {
275277
if (isFeatureFlag(setting)) {
276-
featureFlagsMap.set(setting.key, setting.value);
278+
featureFlagSettings.push(setting);
277279
}
278280
}
279281
}
280282
selector.pageEtags = pageEtags;
281283
}
282284

283285
// parse feature flags
284-
const featureFlags = Array.from(featureFlagsMap.values()).map(rawFlag => JSON.parse(rawFlag));
286+
const featureFlags = await Promise.all(
287+
featureFlagSettings.map(setting => this.#parseFeatureFlag(setting))
288+
);
285289

286290
// feature_management is a reserved key, and feature_flags is an array of feature flags
287291
this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags });
@@ -532,6 +536,83 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
532536
}
533537
return response;
534538
}
539+
540+
async #parseFeatureFlag(setting: ConfigurationSetting<string>): Promise<any> {
541+
const rawFlag = setting.value;
542+
if (rawFlag === undefined) {
543+
throw new Error("The value of configuration setting cannot be undefined.");
544+
}
545+
const featureFlag = JSON.parse(rawFlag);
546+
547+
if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) {
548+
const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME];
549+
featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = {
550+
[ETAG_KEY_NAME]: setting.etag,
551+
[FEATURE_FLAG_ID_KEY_NAME]: await this.#calculateFeatureFlagId(setting),
552+
[FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting),
553+
...(metadata || {})
554+
};
555+
}
556+
557+
return featureFlag;
558+
}
559+
560+
async #calculateFeatureFlagId(setting: ConfigurationSetting<string>): Promise<string> {
561+
let crypto;
562+
563+
// Check for browser environment
564+
if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
565+
crypto = window.crypto;
566+
}
567+
// Check for Node.js environment
568+
else if (typeof global !== "undefined" && global.crypto) {
569+
crypto = global.crypto;
570+
}
571+
// Fallback to native Node.js crypto module
572+
else {
573+
try {
574+
if (typeof module !== "undefined" && module.exports) {
575+
crypto = require("crypto");
576+
}
577+
else {
578+
crypto = await import("crypto");
579+
}
580+
} catch (error) {
581+
console.error("Failed to load the crypto module:", error.message);
582+
throw error;
583+
}
584+
}
585+
586+
let baseString = `${setting.key}\n`;
587+
if (setting.label && setting.label.trim().length !== 0) {
588+
baseString += `${setting.label}`;
589+
}
590+
591+
// Convert to UTF-8 encoded bytes
592+
const data = new TextEncoder().encode(baseString);
593+
594+
// In the browser, use crypto.subtle.digest
595+
if (crypto.subtle) {
596+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
597+
const hashArray = new Uint8Array(hashBuffer);
598+
const base64String = btoa(String.fromCharCode(...hashArray));
599+
const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
600+
return base64urlString;
601+
}
602+
// In Node.js, use the crypto module's hash function
603+
else {
604+
const hash = crypto.createHash("sha256").update(data).digest();
605+
return hash.toString("base64url");
606+
}
607+
}
608+
609+
#createFeatureFlagReference(setting: ConfigurationSetting<string>): string {
610+
let featureFlagReference = `${this.#clientEndpoint}kv/${setting.key}`;
611+
if (setting.label && setting.label.trim().length !== 0) {
612+
featureFlagReference += `?label=${setting.label}`;
613+
}
614+
return featureFlagReference;
615+
}
535616
}
536617

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

src/featureManagement/constants.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,10 @@
22
// Licensed under the MIT license.
33

44
export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management";
5-
export const FEATURE_FLAGS_KEY_NAME = "feature_flags";
5+
export const FEATURE_FLAGS_KEY_NAME = "feature_flags";
6+
export const TELEMETRY_KEY_NAME = "telemetry";
7+
export const ENABLED_KEY_NAME = "enabled";
8+
export const METADATA_KEY_NAME = "metadata";
9+
export const ETAG_KEY_NAME = "Etag";
10+
export const FEATURE_FLAG_ID_KEY_NAME = "FeatureFlagId";
11+
export const FEATURE_FLAG_REFERENCE_KEY_NAME = "FeatureFlagReference";

src/load.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export async function load(
3232
): Promise<AzureAppConfiguration> {
3333
const startTimestamp = Date.now();
3434
let client: AppConfigurationClient;
35+
let clientEndpoint: string | undefined;
3536
let options: AzureAppConfigurationOptions | undefined;
3637

3738
// input validation
@@ -40,30 +41,33 @@ export async function load(
4041
options = credentialOrOptions as AzureAppConfigurationOptions;
4142
const clientOptions = getClientOptions(options);
4243
client = new AppConfigurationClient(connectionString, clientOptions);
44+
clientEndpoint = getEndpoint(connectionStringOrEndpoint);
4345
} else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && instanceOfTokenCredential(credentialOrOptions)) {
44-
let endpoint = connectionStringOrEndpoint;
4546
// ensure string is a valid URL.
46-
if (typeof endpoint === "string") {
47+
if (typeof connectionStringOrEndpoint === "string") {
4748
try {
48-
endpoint = new URL(endpoint);
49+
const endpointUrl = new URL(connectionStringOrEndpoint);
50+
clientEndpoint = endpointUrl.toString();
4951
} catch (error) {
5052
if (error.code === "ERR_INVALID_URL") {
5153
throw new Error("Invalid endpoint URL.", { cause: error });
5254
} else {
5355
throw error;
5456
}
5557
}
58+
} else {
59+
clientEndpoint = connectionStringOrEndpoint.toString();
5660
}
5761
const credential = credentialOrOptions as TokenCredential;
5862
options = appConfigOptions;
5963
const clientOptions = getClientOptions(options);
60-
client = new AppConfigurationClient(endpoint.toString(), credential, clientOptions);
64+
client = new AppConfigurationClient(clientEndpoint, credential, clientOptions);
6165
} else {
6266
throw new Error("A connection string or an endpoint with credential must be specified to create a client.");
6367
}
6468

6569
try {
66-
const appConfiguration = new AzureAppConfigurationImpl(client, options);
70+
const appConfiguration = new AzureAppConfigurationImpl(client, clientEndpoint, options);
6771
await appConfiguration.load();
6872
return appConfiguration;
6973
} catch (error) {
@@ -104,3 +108,18 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat
104108
}
105109
});
106110
}
111+
112+
function getEndpoint(connectionString: string): string | undefined {
113+
const parts = connectionString.split(";");
114+
const endpointPart = parts.find(part => part.startsWith("Endpoint="));
115+
116+
if (endpointPart) {
117+
let endpoint = endpointPart.split("=")[1];
118+
if (!endpoint.endsWith("/")) {
119+
endpoint += "/";
120+
}
121+
return endpoint;
122+
}
123+
124+
return undefined;
125+
}

test/featureFlag.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import * as chai from "chai";
55
import * as chaiAsPromised from "chai-as-promised";
66
import { load } from "./exportedApi";
7-
import { createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper";
7+
import { createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper";
88
chai.use(chaiAsPromised);
99
const expect = chai.expect;
1010

@@ -54,6 +54,8 @@ const mockedKVs = [{
5454
createMockedFeatureFlag("Beta", { enabled: true }),
5555
createMockedFeatureFlag("Alpha_1", { enabled: true }),
5656
createMockedFeatureFlag("Alpha_2", { enabled: false }),
57+
createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "Etag"}),
58+
createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "Etag", label: "Test"})
5759
]);
5860

5961
describe("feature flags", function () {
@@ -158,4 +160,44 @@ describe("feature flags", function () {
158160
expect(variant.telemetry).not.undefined;
159161
});
160162

163+
it("should populate telemetry metadata", async () => {
164+
const connectionString = createMockedConnectionString();
165+
const settings = await load(connectionString, {
166+
featureFlagOptions: {
167+
enabled: true,
168+
selectors: [
169+
{
170+
keyFilter: "Telemetry_1"
171+
},
172+
{
173+
keyFilter: "Telemetry_2",
174+
labelFilter: "Test"
175+
}
176+
]
177+
}
178+
});
179+
expect(settings).not.undefined;
180+
expect(settings.get("feature_management")).not.undefined;
181+
const featureFlags = settings.get<any>("feature_management").feature_flags;
182+
expect(featureFlags).not.undefined;
183+
expect((featureFlags as []).length).equals(2);
184+
185+
let featureFlag = featureFlags[0];
186+
expect(featureFlag).not.undefined;
187+
expect(featureFlag.id).equals("Telemetry_1");
188+
expect(featureFlag.telemetry).not.undefined;
189+
expect(featureFlag.telemetry.enabled).equals(true);
190+
expect(featureFlag.telemetry.metadata.Etag).equals("Etag");
191+
expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("krkOsu9dVV9huwbQDPR6gkV_2T0buWxOCS-nNsj5-6g");
192+
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_1`);
193+
194+
featureFlag = featureFlags[1];
195+
expect(featureFlag).not.undefined;
196+
expect(featureFlag.id).equals("Telemetry_2");
197+
expect(featureFlag.telemetry).not.undefined;
198+
expect(featureFlag.telemetry.enabled).equals(true);
199+
expect(featureFlag.telemetry.metadata.Etag).equals("Etag");
200+
expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o");
201+
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
202+
});
161203
});

0 commit comments

Comments
 (0)