diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba16c02..1c0334a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,10 @@ jobs: matrix: node-version: [18.x, 20.x] + defaults: + run: + working-directory: sdk/feature-management + steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -27,8 +31,40 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' - - run: npm ci - - run: npm run lint - - run: npm run build - - run: npm run test - - run: npm run test-browser \ No newline at end of file + cache-dependency-path: sdk/feature-management/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: sdk/feature-management + + - name: Run lint check for feature-management + run: npm run lint + working-directory: sdk/feature-management + + - name: Build feature-management + run: npm run build + working-directory: sdk/feature-management + + - name: Run tests + run: npm run test + working-directory: sdk/feature-management + + - name: Run browser tests + run: npm run test-browser + working-directory: sdk/feature-management + + - name: Build feature-management-applicationinsights-browser + run: npm run build + working-directory: sdk/feature-management-applicationinsights-browser + + - name: Run lint check for feature-management-applicationinsights-browser + run: npm run lint + working-directory: sdk/feature-management-applicationinsights-browser + + - name: Build feature-management-applicationinsights-node + run: npm run build + working-directory: sdk/feature-management-applicationinsights-node + + - name: Run lint check for feature-management-applicationinsights-node + run: npm run lint + working-directory: sdk/feature-management-applicationinsights-node diff --git a/scripts/build-and-pack.sh b/scripts/build-and-pack.sh index dba0204..1256871 100755 --- a/scripts/build-and-pack.sh +++ b/scripts/build-and-pack.sh @@ -3,25 +3,57 @@ # Stop on error. set -e -# Get the directory of the script. SCRIPT_DIR=$(dirname $(readlink -f $0)) - -# Get the directory of the project. PROJECT_BASE_DIR=$(dirname $SCRIPT_DIR) +SDK_DIR="$PROJECT_BASE_DIR/sdk" + +PACKAGE="feature-management" +PACKAGE_DIR="$SDK_DIR/$PACKAGE" -# Change to the project directory. -cd $PROJECT_BASE_DIR +echo "Building package $PACKAGE in $PACKAGE_DIR" +cd "$PACKAGE_DIR" -# Install dependencies, build, and test. -echo "npm clean install" +echo "npm clean install in $PACKAGE_DIR" npm ci -echo "npm run build" +echo "npm run build in $PACKAGE_DIR" npm run build -echo "npm run test" +echo "npm run test in $PACKAGE_DIR" npm run test -# Create a tarball. -echo "npm pack" +echo "npm pack in $PACKAGE_DIR" +npm pack + +echo "copy $PACKAGE package to $PROJECT_BASE_DIR" +cp "$PACKAGE_DIR"/*.tgz "$PROJECT_BASE_DIR" + +PACKAGE="feature-management-applicationinsights-browser" +PACKAGE_DIR="$SDK_DIR/$PACKAGE" + +echo "Building package $PACKAGE in $PACKAGE_DIR" +cd "$PACKAGE_DIR" + +echo "npm run build in $PACKAGE_DIR" +npm run build + +echo "npm pack in $PACKAGE_DIR" npm pack + +echo "copy $PACKAGE package to $PROJECT_BASE_DIR" +cp "$PACKAGE_DIR"/*.tgz "$PROJECT_BASE_DIR" + +PACKAGE="feature-management-applicationinsights-node" +PACKAGE_DIR="$SDK_DIR/$PACKAGE" + +echo "Building package $PACKAGE in $PACKAGE_DIR" +cd "$PACKAGE_DIR" + +echo "npm run build in $PACKAGE_DIR" +npm run build + +echo "npm pack in $PACKAGE_DIR" +npm pack + +echo "copy $PACKAGE package to $PROJECT_BASE_DIR" +cp "$PACKAGE_DIR"/*.tgz "$PROJECT_BASE_DIR" diff --git a/sdk/feature-management-applicationinsights-browser/.eslintrc b/sdk/feature-management-applicationinsights-browser/.eslintrc new file mode 100644 index 0000000..7271d78 --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/.eslintrc @@ -0,0 +1,60 @@ +{ + "env": { + "browser": true, + "es2021": true, + "mocha": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "ignorePatterns": [ + ], + "overrides": [ + { + "env": { + "node": true + }, + "files": [ + ".eslintrc.{js,cjs}" + ], + "parserOptions": { + "sourceType": "script" + } + } + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "quotes": [ + "error", + "double", + { + "avoidEscape": true + } + ], + "@typescript-eslint/no-explicit-any": "off", + "eol-last": [ + "error", + "always" + ], + "no-trailing-spaces": "error", + "space-before-blocks": [ + "error", + "always" + ], + "no-multi-spaces": "error", + "no-multiple-empty-lines": [ + "error", + { + "max": 1 + } + ], + "semi": ["error", "always"] + } +} \ No newline at end of file diff --git a/sdk/feature-management-applicationinsights-browser/LICENSE b/sdk/feature-management-applicationinsights-browser/LICENSE new file mode 100644 index 0000000..9e841e7 --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/sdk/feature-management-applicationinsights-browser/README.md b/sdk/feature-management-applicationinsights-browser/README.md new file mode 100644 index 0000000..61622d7 --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/README.md @@ -0,0 +1,50 @@ +# Microsoft Feature Management Application Insights Plugin for Browser + +Feature Management Application Insights Plugin for Browser provides a solution for sending feature flag evaluation events produced by the Feature Management library. + +## Getting Started + +### Usage + +``` javascript +import { ApplicationInsights } from "@microsoft/applicationinsights-web" +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "@microsoft/feature-management"; +import { createTelemetryPublisher, trackEvent } from "@microsoft/feature-management-applicationinsights-browser"; + +const appInsights = new ApplicationInsights({ config: { + connectionString: CONNECTION_STRING +}}); +appInsights.loadAppInsights(); + +const publishTelemetry = createTelemetryPublisher(appInsights); +const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); +const featureManager = new FeatureManager(provider, {onFeatureEvaluated: publishTelemetry}); + +// FeatureEvaluation event will be emitted when a feature flag is evaluated +featureManager.getVariant("TestFeature", {userId : TARGETING_ID}).then((variant) => { /* do something*/ }); + +// Emit a custom event with targeting id attached. +trackEvent(appInsights, TARGETING_ID, {name: "TestEvent"}, {"Tag": "Some Value"}); +``` + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/sdk/feature-management-applicationinsights-browser/package.json b/sdk/feature-management-applicationinsights-browser/package.json new file mode 100644 index 0000000..8970137 --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/package.json @@ -0,0 +1,48 @@ +{ + "name": "@microsoft/feature-management-applicationinsights-browser", + "version": "2.0.0-preview.3", + "description": "Feature Management Application Insights Plugin for Browser provides a solution for sending feature flag evaluation events produced by the Feature Management library.", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "types/index.d.ts", + "files": [ + "dist/", + "types/", + "LICENSE", + "README.md" + ], + "scripts": { + "build": "npm run link && npm run clean && rollup --config", + "clean": "rimraf dist types", + "link": "npm link ../feature-management", + "dev": "rollup --config --watch", + "lint": "eslint src/", + "fix-lint": "eslint src/ --fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/FeatureManagement-JavaScript.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" + }, + "homepage": "https://github.com/microsoft/FeatureManagement-JavaScript#readme", + "devDependencies": { + "@rollup/plugin-typescript": "^11.1.5", + "@types/node": "^20.10.7", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "eslint": "^8.56.0", + "rimraf": "^5.0.5", + "rollup": "^4.9.4", + "rollup-plugin-dts": "^6.1.0", + "tslib": "^2.6.2", + "typescript": "^5.3.3" + }, + "dependencies": { + "@microsoft/applicationinsights-web": "^3.3.2", + "@microsoft/feature-management": "2.0.0-preview.3" + } +} + \ No newline at end of file diff --git a/sdk/feature-management-applicationinsights-browser/rollup.config.mjs b/sdk/feature-management-applicationinsights-browser/rollup.config.mjs new file mode 100644 index 0000000..4dcb837 --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/rollup.config.mjs @@ -0,0 +1,47 @@ +// rollup.config.js +import typescript from "@rollup/plugin-typescript"; +import dts from "rollup-plugin-dts"; + +export default [ + { + input: "src/index.ts", + output: [ + { + dir: "dist/esm/", + format: "esm", + sourcemap: true, + preserveModules: true, + }, + { + file: "dist/umd/index.js", + format: "umd", + name: 'FeatureManagementApplicationInsights', + sourcemap: true + } + ], + plugins: [ + typescript({ + compilerOptions: { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + } + }) + ], + }, + { + input: "src/index.ts", + output: [{ file: "types/index.d.ts", format: "esm" }], + plugins: [dts()], + }, +]; diff --git a/sdk/feature-management-applicationinsights-browser/src/index.ts b/sdk/feature-management-applicationinsights-browser/src/index.ts new file mode 100644 index 0000000..6b53335 --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { createTelemetryPublisher, trackEvent } from "./telemetry.js"; +export { VERSION } from "./version.js"; diff --git a/sdk/feature-management-applicationinsights-browser/src/telemetry.ts b/sdk/feature-management-applicationinsights-browser/src/telemetry.ts new file mode 100644 index 0000000..6877c0f --- /dev/null +++ b/sdk/feature-management-applicationinsights-browser/src/telemetry.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { EvaluationResult, createFeatureEvaluationEventProperties } from "@microsoft/feature-management"; +import { ApplicationInsights, IEventTelemetry } from "@microsoft/applicationinsights-web"; + +const TARGETING_ID = "TargetingId"; +const FEATURE_EVALUATION_EVENT_NAME = "FeatureEvaluation"; + +/** + * Creates a telemetry publisher that sends feature evaluation events to Application Insights. + * @param client The Application Insights telemetry client. + * @returns A callback function that takes an evaluation result and tracks an event with the evaluation details. + */ +export function createTelemetryPublisher(client: ApplicationInsights): (result: EvaluationResult) => void { + return (result: EvaluationResult) => { + if (result.feature === undefined) { + return; + } + + const eventProperties = createFeatureEvaluationEventProperties(result); + client.trackEvent({ name: FEATURE_EVALUATION_EVENT_NAME }, eventProperties); + }; +} + +/** + * Tracks a custom event using Application Insights, ensuring that the "TargetingId" + * is included in the custom properties. If the "TargetingId" already exists in + * the provided custom properties, it will be overwritten. + * + * @param client The Application Insights client instance used to track the event. + * @param targetingId The unique targeting identifier that will be included in the custom properties. + * @param event The event telemetry object to be tracked, containing event details. + * @param customProperties (Optional) Additional properties to include in the event telemetry. + */ +export function trackEvent(client: ApplicationInsights, targetingId: string, event: IEventTelemetry, customProperties?: {[key: string]: any}): void { + const properties = customProperties ? { ...customProperties } : {}; + // Ensure targetingId is string so that it will be placed in customDimensions + properties[TARGETING_ID] = targetingId ? targetingId.toString() : ""; + client.trackEvent(event, properties); +} diff --git a/src/version.ts b/sdk/feature-management-applicationinsights-browser/src/version.ts similarity index 64% rename from src/version.ts rename to sdk/feature-management-applicationinsights-browser/src/version.ts index dcc0b29..074200b 100644 --- a/src/version.ts +++ b/sdk/feature-management-applicationinsights-browser/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "1.0.0"; +export const VERSION = "2.0.0-preview.3"; diff --git a/tsconfig.base.json b/sdk/feature-management-applicationinsights-browser/tsconfig.json similarity index 95% rename from tsconfig.base.json rename to sdk/feature-management-applicationinsights-browser/tsconfig.json index 358df9e..d71e705 100644 --- a/tsconfig.base.json +++ b/sdk/feature-management-applicationinsights-browser/tsconfig.json @@ -1,21 +1,21 @@ -{ - "compilerOptions": { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, - "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true - }, - "exclude": [ - "node_modules", - "**/node_modules/*" - ] +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] } \ No newline at end of file diff --git a/sdk/feature-management-applicationinsights-node/.eslintrc b/sdk/feature-management-applicationinsights-node/.eslintrc new file mode 100644 index 0000000..9a45025 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/.eslintrc @@ -0,0 +1,61 @@ +{ + "env": { + "commonjs": true, + "es2021": true, + "node": true, + "mocha": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "ignorePatterns": [ + ], + "overrides": [ + { + "env": { + "node": true + }, + "files": [ + ".eslintrc.{js,cjs}" + ], + "parserOptions": { + "sourceType": "script" + } + } + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "quotes": [ + "error", + "double", + { + "avoidEscape": true + } + ], + "@typescript-eslint/no-explicit-any": "off", + "eol-last": [ + "error", + "always" + ], + "no-trailing-spaces": "error", + "space-before-blocks": [ + "error", + "always" + ], + "no-multi-spaces": "error", + "no-multiple-empty-lines": [ + "error", + { + "max": 1 + } + ], + "semi": ["error", "always"] + } +} \ No newline at end of file diff --git a/sdk/feature-management-applicationinsights-node/LICENSE b/sdk/feature-management-applicationinsights-node/LICENSE new file mode 100644 index 0000000..9e841e7 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/sdk/feature-management-applicationinsights-node/README.md b/sdk/feature-management-applicationinsights-node/README.md new file mode 100644 index 0000000..695a421 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/README.md @@ -0,0 +1,52 @@ +# Microsoft Feature Management Application Insights Plugin for Browser + +Feature Management Application Insights Plugin for Browser provides a solution for sending feature flag evaluation events produced by the Feature Management library. + +## Getting Started + +### Prerequisites + +- Node.js LTS version + +### Usage + +``` javascript +import appInsights from "applicationinsights"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "@microsoft/feature-management"; +import { createTelemetryPublisher, trackEvent } from "@microsoft/feature-management-applicationinsights-node"; + +appInsights.setup(CONNECTION_STRING) + .start(); + +const publishTelemetry = createTelemetryPublisher(appInsights.defaultClient); +const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); +const featureManager = new FeatureManager(provider, {onFeatureEvaluated: publishTelemetry}); + +// FeatureEvaluation event will be emitted when a feature flag is evaluated +featureManager.getVariant("TestFeature", {userId : TARGETING_ID}).then((variant) => { /* do something*/ }); + +// Emit a custom event with targeting id attached. +trackEvent(appInsights.defaultClient, TARGETING_ID, {name: "TestEvent"}); +``` + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/sdk/feature-management-applicationinsights-node/package.json b/sdk/feature-management-applicationinsights-node/package.json new file mode 100644 index 0000000..975199a --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/package.json @@ -0,0 +1,48 @@ +{ + "name": "@microsoft/feature-management-applicationinsights-node", + "version": "2.0.0-preview.3", + "description": "Feature Management Application Insights Plugin for Node.js provides a solution for sending feature flag evaluation events produced by the Feature Management library.", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js", + "types": "types/index.d.ts", + "files": [ + "dist/", + "types/", + "LICENSE", + "README.md" + ], + "scripts": { + "build": "npm run link && npm run clean && rollup --config", + "clean": "rimraf dist types", + "link": "npm link ../feature-management", + "dev": "rollup --config --watch", + "lint": "eslint src/", + "fix-lint": "eslint src/ --fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/FeatureManagement-JavaScript.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" + }, + "homepage": "https://github.com/microsoft/FeatureManagement-JavaScript#readme", + "devDependencies": { + "@rollup/plugin-typescript": "^11.1.5", + "@types/node": "^20.10.7", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "eslint": "^8.56.0", + "rimraf": "^5.0.5", + "rollup": "^4.9.4", + "rollup-plugin-dts": "^6.1.0", + "tslib": "^2.6.2", + "typescript": "^5.3.3" + }, + "dependencies": { + "applicationinsights": "^2.9.6", + "@microsoft/feature-management": "2.0.0-preview.3" + } +} + \ No newline at end of file diff --git a/sdk/feature-management-applicationinsights-node/rollup.config.mjs b/sdk/feature-management-applicationinsights-node/rollup.config.mjs new file mode 100644 index 0000000..c95df86 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/rollup.config.mjs @@ -0,0 +1,47 @@ +// rollup.config.js +import typescript from "@rollup/plugin-typescript"; +import dts from "rollup-plugin-dts"; + +export default [ + { + input: "src/index.ts", + output: [ + { + dir: "dist/commonjs/", + format: "cjs", + sourcemap: true, + preserveModules: true, + }, + { + dir: "dist/esm/", + format: "esm", + sourcemap: true, + preserveModules: true, + } + ], + plugins: [ + typescript({ + compilerOptions: { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + } + }) + ], + }, + { + input: "src/index.ts", + output: [{ file: "types/index.d.ts", format: "esm" }], + plugins: [dts()], + }, +]; diff --git a/sdk/feature-management-applicationinsights-node/src/index.ts b/sdk/feature-management-applicationinsights-node/src/index.ts new file mode 100644 index 0000000..6b53335 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { createTelemetryPublisher, trackEvent } from "./telemetry.js"; +export { VERSION } from "./version.js"; diff --git a/sdk/feature-management-applicationinsights-node/src/telemetry.ts b/sdk/feature-management-applicationinsights-node/src/telemetry.ts new file mode 100644 index 0000000..11030e6 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/src/telemetry.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { EvaluationResult, createFeatureEvaluationEventProperties } from "@microsoft/feature-management"; +import { TelemetryClient, Contracts } from "applicationinsights"; + +const TARGETING_ID = "TargetingId"; +const FEATURE_EVALUATION_EVENT_NAME = "FeatureEvaluation"; + +/** + * Creates a telemetry publisher that sends feature evaluation events to Application Insights. + * @param client The Application Insights telemetry client. + * @returns A callback function that takes an evaluation result and tracks an event with the evaluation details. + */ +export function createTelemetryPublisher(client: TelemetryClient): (result: EvaluationResult) => void { + return (result: EvaluationResult) => { + if (result.feature === undefined) { + return; + } + + const eventProperties = createFeatureEvaluationEventProperties(result); + client.trackEvent({ name: FEATURE_EVALUATION_EVENT_NAME, properties: eventProperties }); + }; +} + +/** + * Tracks a custom event using Application Insights, ensuring that the "TargetingId" + * is included in the custom properties. If the "TargetingId" already exists in + * the provided custom properties, it will be overwritten. + * + * @param client The Application Insights client instance used to track the event. + * @param targetingId The unique targeting identifier that will be included in the custom properties. + * @param event The event telemetry object to be tracked, containing event details. + */ +export function trackEvent(client: TelemetryClient, targetingId: string, event: Contracts.EventTelemetry): void { + event.properties = { + ...event.properties, + [TARGETING_ID]: targetingId ? targetingId.toString() : "" + }; + client.trackEvent(event); +} diff --git a/sdk/feature-management-applicationinsights-node/src/version.ts b/sdk/feature-management-applicationinsights-node/src/version.ts new file mode 100644 index 0000000..074200b --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/src/version.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const VERSION = "2.0.0-preview.3"; diff --git a/sdk/feature-management-applicationinsights-node/tsconfig.json b/sdk/feature-management-applicationinsights-node/tsconfig.json new file mode 100644 index 0000000..d71e705 --- /dev/null +++ b/sdk/feature-management-applicationinsights-node/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/.eslintrc b/sdk/feature-management/.eslintrc similarity index 100% rename from .eslintrc rename to sdk/feature-management/.eslintrc diff --git a/sdk/feature-management/LICENSE b/sdk/feature-management/LICENSE new file mode 100644 index 0000000..9e841e7 --- /dev/null +++ b/sdk/feature-management/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/sdk/feature-management/README.md b/sdk/feature-management/README.md new file mode 100644 index 0000000..54aa97a --- /dev/null +++ b/sdk/feature-management/README.md @@ -0,0 +1,84 @@ +# Microsoft Feature Management for JavaScript + +[![feature-management](https://img.shields.io/npm/v/@microsoft/feature-management?label=@microsoft/feature-management)](https://www.npmjs.com/package/@microsoft/feature-management) + +Feature Management is a library for enabling/disabling features at runtime. +Developers can use feature flags in simple use cases like conditional statement to more advanced scenarios like conditionally adding routes. + +## Getting Started + +### Prerequisites + +- Node.js LTS version + +### Usage + +You can use feature flags from the Azure App Configuration service, local files or any other sources. + +#### Use feature flags from Azure App Configuration + +The App Configuration JavaScript provider provides feature flags in as a `Map` object. +A builtin `ConfigurationMapFeatureFlagProvider` helps to load feature flags in this case. + +```js +const appConfig = load(connectionString, {featureFlagOptions}); // load feature flags from Azure App Configuration service +const featureProvider = new ConfigurationMapFeatureFlagProvider(appConfig); +const featureManager = new FeatureManager(featureProvider); +const isAlphaEnabled = await featureManager.isEnabled("Alpha"); +console.log("Feature Alpha is:", isAlphaEnabled); +``` + +#### Use feature flags from a json file + +A sample JSON file with the following format can be used to load feature flags. +The JSON file can be read and parsed as an object as a whole. +A builtin `ConfigurationObjectFeatureFlagProvider` helps to load feature flags in this case. + +Content of `sample.json`: +```json +{ + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "description": "", + "enabled": "true", + "conditions": { + "client_filters": [] + } + } + ] + } +} +``` + +Load feature flags from `sample.json` file. +```js +const config = JSON.parse(await fs.readFile("path/to/sample.json")); +const featureProvider = new ConfigurationObjectFeatureFlagProvider(config); +const featureManager = new FeatureManager(featureProvider); +const isAlphaEnabled = await featureManager.isEnabled("Alpha"); +console.log("Feature Alpha is:", isAlphaEnabled); +``` + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/package-lock.json b/sdk/feature-management/package-lock.json similarity index 99% rename from package-lock.json rename to sdk/feature-management/package-lock.json index 73b3276..57830e9 100644 --- a/package-lock.json +++ b/sdk/feature-management/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/feature-management", - "version": "1.0.0", + "version": "2.0.0-preview.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/feature-management", - "version": "1.0.0", + "version": "2.0.0-preview.1", "license": "MIT", "devDependencies": { "@playwright/test": "^1.46.1", diff --git a/package.json b/sdk/feature-management/package.json similarity index 98% rename from package.json rename to sdk/feature-management/package.json index f7e5e01..3e77c26 100644 --- a/package.json +++ b/sdk/feature-management/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/feature-management", - "version": "1.0.0", + "version": "2.0.0-preview.3", "description": "Feature Management is a library for enabling/disabling features at runtime. Developers can use feature flags in simple use cases like conditional statement to more advanced scenarios like conditionally adding routes.", "main": "./dist/commonjs/index.js", "module": "./dist/esm/index.js", diff --git a/playwright.config.ts b/sdk/feature-management/playwright.config.ts similarity index 100% rename from playwright.config.ts rename to sdk/feature-management/playwright.config.ts diff --git a/rollup.config.mjs b/sdk/feature-management/rollup.config.mjs similarity index 95% rename from rollup.config.mjs rename to sdk/feature-management/rollup.config.mjs index 42018b0..2b19caf 100644 --- a/rollup.config.mjs +++ b/sdk/feature-management/rollup.config.mjs @@ -1,57 +1,57 @@ -// rollup.config.js -import typescript from "@rollup/plugin-typescript"; -import dts from "rollup-plugin-dts"; - -export default [ - { - external: ["crypto"], - input: "src/index.ts", - output: [ - { - dir: "dist/commonjs/", - format: "cjs", - sourcemap: true, - preserveModules: true, - }, - { - dir: "dist/esm/", - format: "esm", - sourcemap: true, - preserveModules: true, - }, - { - file: "dist/umd/index.js", - format: "umd", - name: 'FeatureManagement', - sourcemap: true - } - ], - plugins: [ - typescript({ - compilerOptions: { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, - "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true - }, - "exclude": [ - "test/**/*" - ] - }) - ], - }, - { - input: "src/index.ts", - output: [{ file: "types/index.d.ts", format: "esm" }], - plugins: [dts()], - }, -]; +// rollup.config.js +import typescript from "@rollup/plugin-typescript"; +import dts from "rollup-plugin-dts"; + +export default [ + { + external: ["crypto"], + input: "src/index.ts", + output: [ + { + dir: "dist/commonjs/", + format: "cjs", + sourcemap: true, + preserveModules: true, + }, + { + dir: "dist/esm/", + format: "esm", + sourcemap: true, + preserveModules: true, + }, + { + file: "dist/umd/index.js", + format: "umd", + name: 'FeatureManagement', + sourcemap: true + } + ], + plugins: [ + typescript({ + compilerOptions: { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "exclude": [ + "test/**/*" + ] + }) + ], + }, + { + input: "src/index.ts", + output: [{ file: "types/index.d.ts", format: "esm" }], + plugins: [dts()], + }, +]; diff --git a/sdk/feature-management/src/IFeatureManager.ts b/sdk/feature-management/src/IFeatureManager.ts new file mode 100644 index 0000000..d673dce --- /dev/null +++ b/sdk/feature-management/src/IFeatureManager.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ITargetingContext } from "./common/ITargetingContext"; +import { Variant } from "./variant/Variant"; + +export interface IFeatureManager { + /** + * Get the list of feature names. + */ + listFeatureNames(): Promise; + + /** + * Check if a feature is enabled. + * @param featureName name of the feature. + * @param context an object providing information that can be used to evaluate whether a feature should be on or off. + */ + isEnabled(featureName: string, context?: unknown): Promise; + + /** + * Get the allocated variant of a feature given the targeting context. + * @param featureName name of the feature. + * @param context a targeting context object used to evaluate which variant the user will be assigned. + */ + getVariant(featureName: string, context: ITargetingContext): Promise; +} diff --git a/sdk/feature-management/src/common/ITargetingContext.ts b/sdk/feature-management/src/common/ITargetingContext.ts new file mode 100644 index 0000000..1d5a426 --- /dev/null +++ b/sdk/feature-management/src/common/ITargetingContext.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface ITargetingContext { + userId?: string; + groups?: string[]; +} + diff --git a/sdk/feature-management/src/common/targetingEvaluator.ts b/sdk/feature-management/src/common/targetingEvaluator.ts new file mode 100644 index 0000000..a946fa4 --- /dev/null +++ b/sdk/feature-management/src/common/targetingEvaluator.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Determines if the user is part of the audience, based on the user id and the percentage range. + * + * @param userId user id from app context + * @param hint hint string to be included in the context id + * @param from percentage range start + * @param to percentage range end + * @returns true if the user is part of the audience, false otherwise + */ +export async function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): Promise { + if (from < 0 || from > 100) { + throw new Error("The 'from' value must be between 0 and 100."); + } + if (to < 0 || to > 100) { + throw new Error("The 'to' value must be between 0 and 100."); + } + if (from > to) { + throw new Error("The 'from' value cannot be larger than the 'to' value."); + } + + const audienceContextId = constructAudienceContextId(userId, hint); + + // Cryptographic hashing algorithms ensure adequate entropy across hash values. + const contextMarker = await stringToUint32(audienceContextId); + const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; + + // Handle edge case of exact 100 bucket + if (to === 100) { + return contextPercentage >= from; + } + + return contextPercentage >= from && contextPercentage < to; +} + +/** + * Determines if the user is part of the audience, based on the groups they belong to. + * + * @param sourceGroups user groups from app context + * @param targetedGroups targeted groups from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGroups: string[]): boolean { + if (sourceGroups === undefined) { + return false; + } + + return sourceGroups.some(group => targetedGroups.includes(group)); +} + +/** + * Determines if the user is part of the audience, based on the user id. + * @param userId user id from app context + * @param users targeted users from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedUser(userId: string | undefined, users: string[]): boolean { + if (userId === undefined) { + return false; + } + + return users.includes(userId); +} + +/** + * Constructs the context id for the audience. + * The context id is used to determine if the user is part of the audience for a feature. + * + * @param userId userId from app context + * @param hint hint string to be included in the context id + * @returns a string that represents the context id for the audience + */ +function constructAudienceContextId(userId: string | undefined, hint: string): string { + return `${userId ?? ""}\n${hint}`; +} + +/** + * Converts a string to a uint32 in little-endian encoding. + * @param str the string to convert. + * @returns a uint32 value. + */ +async function stringToUint32(str: string): Promise { + 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; + } + } + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const data = new TextEncoder().encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const dataView = new DataView(hashBuffer); + const uint32 = dataView.getUint32(0, true); + return uint32; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(str).digest(); + const uint32 = hash.readUInt32LE(0); + return uint32; + } +} diff --git a/sdk/feature-management/src/featureManager.ts b/sdk/feature-management/src/featureManager.ts new file mode 100644 index 0000000..a035b5d --- /dev/null +++ b/sdk/feature-management/src/featureManager.ts @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TimeWindowFilter } from "./filter/TimeWindowFilter.js"; +import { IFeatureFilter } from "./filter/FeatureFilter.js"; +import { FeatureFlag, RequirementType, VariantDefinition } from "./schema/model.js"; +import { IFeatureFlagProvider } from "./featureProvider.js"; +import { TargetingFilter } from "./filter/TargetingFilter.js"; +import { Variant } from "./variant/Variant.js"; +import { IFeatureManager } from "./IFeatureManager.js"; +import { ITargetingContext } from "./common/ITargetingContext.js"; +import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator.js"; + +export class FeatureManager implements IFeatureManager { + #provider: IFeatureFlagProvider; + #featureFilters: Map = new Map(); + #onFeatureEvaluated?: (event: EvaluationResult) => void; + + constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { + this.#provider = provider; + + const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; + + // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. + for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { + this.#featureFilters.set(filter.name, filter); + } + + this.#onFeatureEvaluated = options?.onFeatureEvaluated; + } + + async listFeatureNames(): Promise { + const features = await this.#provider.getFeatureFlags(); + const featureNameSet = new Set(features.map((feature) => feature.id)); + return Array.from(featureNameSet); + } + + // If multiple feature flags are found, the first one takes precedence. + async isEnabled(featureName: string, context?: unknown): Promise { + const result = await this.#evaluateFeature(featureName, context); + return result.enabled; + } + + async getVariant(featureName: string, context?: ITargetingContext): Promise { + const result = await this.#evaluateFeature(featureName, context); + return result.variant; + } + + async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise { + // user allocation + if (featureFlag.allocation?.user !== undefined) { + for (const userAllocation of featureFlag.allocation.user) { + if (isTargetedUser(context.userId, userAllocation.users)) { + return getVariantAssignment(featureFlag, userAllocation.variant, VariantAssignmentReason.User); + } + } + } + + // group allocation + if (featureFlag.allocation?.group !== undefined) { + for (const groupAllocation of featureFlag.allocation.group) { + if (isTargetedGroup(context.groups, groupAllocation.groups)) { + return getVariantAssignment(featureFlag, groupAllocation.variant, VariantAssignmentReason.Group); + } + } + } + + // percentile allocation + if (featureFlag.allocation?.percentile !== undefined) { + for (const percentileAllocation of featureFlag.allocation.percentile) { + const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`; + if (await isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) { + return getVariantAssignment(featureFlag, percentileAllocation.variant, VariantAssignmentReason.Percentile); + } + } + } + + return { variant: undefined, reason: VariantAssignmentReason.None }; + } + + async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { + if (featureFlag.enabled !== true) { + // If the feature is not explicitly enabled, then it is disabled by default. + return false; + } + + const clientFilters = featureFlag.conditions?.client_filters; + if (clientFilters === undefined || clientFilters.length <= 0) { + // If there are no client filters, then the feature is enabled. + return true; + } + + const requirementType: RequirementType = featureFlag.conditions?.requirement_type ?? "Any"; // default to any. + + /** + * While iterating through the client filters, we short-circuit the evaluation based on the requirement type. + * - When requirement type is "All", the feature is enabled if all client filters are matched. If any client filter is not matched, the feature is disabled, otherwise it is enabled. `shortCircuitEvaluationResult` is false. + * - When requirement type is "Any", the feature is enabled if any client filter is matched. If any client filter is matched, the feature is enabled, otherwise it is disabled. `shortCircuitEvaluationResult` is true. + */ + const shortCircuitEvaluationResult: boolean = requirementType === "Any"; + + for (const clientFilter of clientFilters) { + const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); + const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; + if (matchedFeatureFilter === undefined) { + console.warn(`Feature filter ${clientFilter.name} is not found.`); + return false; + } + if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { + return shortCircuitEvaluationResult; + } + } + + // If we get here, then we have not found a client filter that matches the requirement type. + return !shortCircuitEvaluationResult; + } + + async #evaluateFeature(featureName: string, context: unknown): Promise { + const featureFlag = await this.#provider.getFeatureFlag(featureName); + const result = new EvaluationResult(featureFlag); + + if (featureFlag === undefined) { + return result; + } + + // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard. + // TODO: move to the feature flag provider implementation. + validateFeatureFlagFormat(featureFlag); + + // Evaluate if the feature is enabled. + result.enabled = await this.#isEnabled(featureFlag, context); + + const targetingContext = context as ITargetingContext; + result.targetingId = targetingContext?.userId; + + // Determine Variant + let variantDef: VariantDefinition | undefined; + let reason: VariantAssignmentReason = VariantAssignmentReason.None; + + // featureFlag.variant not empty + if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { + if (!result.enabled) { + // not enabled, assign default if specified + if (featureFlag.allocation?.default_when_disabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); + reason = VariantAssignmentReason.DefaultWhenDisabled; + } else { + // no default specified + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenDisabled; + } + } else { + // enabled, assign based on allocation + if (context !== undefined && featureFlag.allocation !== undefined) { + const variantAndReason = await this.#assignVariant(featureFlag, targetingContext); + variantDef = variantAndReason.variant; + reason = variantAndReason.reason; + } + + // allocation failed, assign default if specified + if (variantDef === undefined && reason === VariantAssignmentReason.None) { + if (featureFlag.allocation?.default_when_enabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); + reason = VariantAssignmentReason.DefaultWhenEnabled; + } else { + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenEnabled; + } + } + } + } + + result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; + result.variantAssignmentReason = reason; + + // Status override for isEnabled + if (variantDef !== undefined && featureFlag.enabled) { + if (variantDef.status_override === "Enabled") { + result.enabled = true; + } else if (variantDef.status_override === "Disabled") { + result.enabled = false; + } + } + + // The callback will only be executed if telemetry is enabled for the feature flag + if (featureFlag.telemetry?.enabled && this.#onFeatureEvaluated !== undefined) { + this.#onFeatureEvaluated(result); + } + + return result; + } +} + +export interface FeatureManagerOptions { + /** + * The custom filters to be used by the feature manager. + */ + customFilters?: IFeatureFilter[]; + + /** + * The callback function that is called when a feature flag is evaluated. + * The callback function is called only when telemetry is enabled for the feature flag. + */ + onFeatureEvaluated?: (event: EvaluationResult) => void; +} + +export class EvaluationResult { + constructor( + // feature flag definition + public readonly feature: FeatureFlag | undefined, + + // enabled state + public enabled: boolean = false, + + // variant assignment + public targetingId: string | undefined = undefined, + public variant: Variant | undefined = undefined, + public variantAssignmentReason: VariantAssignmentReason = VariantAssignmentReason.None + ) { } +} + +export enum VariantAssignmentReason { + /** + * Variant allocation did not happen. No variant is assigned. + */ + None = "None", + + /** + * The default variant is assigned when a feature flag is disabled. + */ + DefaultWhenDisabled = "DefaultWhenDisabled", + + /** + * The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled. + */ + DefaultWhenEnabled = "DefaultWhenEnabled", + + /** + * The variant is assigned because of the user allocation when a feature flag is enabled. + */ + User = "User", + + /** + * The variant is assigned because of the group allocation when a feature flag is enabled. + */ + Group = "Group", + + /** + * The variant is assigned because of the percentile allocation when a feature flag is enabled. + */ + Percentile = "Percentile" +} + +/** + * Validates the format of the feature flag definition. + * + * FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation. + * Thus the properties are not guaranteed to have the expected types. + * + * @param featureFlag The feature flag definition to validate. + */ +function validateFeatureFlagFormat(featureFlag: any): void { + if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { + throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); + } + // TODO: add more validations. + // TODO: should be moved to the feature flag provider. +} + +/** + * Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None. + * + * @param featureFlag feature flag definition + * @param variantName variant name + * @param reason variant assignment reason + * @returns variant assignment containing the variant definition and the reason + */ +function getVariantAssignment(featureFlag: FeatureFlag, variantName: string, reason: VariantAssignmentReason): VariantAssignment { + const variant = featureFlag.variants?.find(v => v.name == variantName); + if (variant !== undefined) { + return { variant, reason }; + } else { + console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; + } +} + +type VariantAssignment = { + variant: VariantDefinition | undefined; + reason: VariantAssignmentReason; +}; diff --git a/src/featureProvider.ts b/sdk/feature-management/src/featureProvider.ts similarity index 97% rename from src/featureProvider.ts rename to sdk/feature-management/src/featureProvider.ts index 29f0802..4860421 100644 --- a/src/featureProvider.ts +++ b/sdk/feature-management/src/featureProvider.ts @@ -1,67 +1,67 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { IGettable } from "./gettable.js"; -import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./schema/model.js"; -import { validateFeatureFlag } from "./schema/validator.js"; - -export interface IFeatureFlagProvider { - /** - * Get all feature flags. - */ - getFeatureFlags(): Promise; - - /** - * Get a feature flag by name. - * @param featureName The name of the feature flag. - */ - getFeatureFlag(featureName: string): Promise; -} - -/** - * A feature flag provider that uses a map-like configuration to provide feature flags. - */ -export class ConfigurationMapFeatureFlagProvider implements IFeatureFlagProvider { - #configuration: IGettable; - - constructor(configuration: IGettable) { - this.#configuration = configuration; - } - async getFeatureFlag(featureName: string): Promise { - const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); - const featureFlag = featureConfig?.[FEATURE_FLAGS_KEY]?.findLast((feature) => feature.id === featureName); - validateFeatureFlag(featureFlag); - return featureFlag; - } - - async getFeatureFlags(): Promise { - const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); - const featureFlag = featureConfig?.[FEATURE_FLAGS_KEY] ?? []; - validateFeatureFlag(featureFlag); - return featureFlag; - } -} - -/** - * A feature flag provider that uses an object-like configuration to provide feature flags. - */ -export class ConfigurationObjectFeatureFlagProvider implements IFeatureFlagProvider { - #configuration: Record; - - constructor(configuration: Record) { - this.#configuration = configuration; - } - - async getFeatureFlag(featureName: string): Promise { - const featureFlags = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY]; - const featureFlag = featureFlags?.findLast((feature: FeatureFlag) => feature.id === featureName); - validateFeatureFlag(featureFlag); - return featureFlag; - } - - async getFeatureFlags(): Promise { - const featureFlag = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? []; - validateFeatureFlag(featureFlag); - return featureFlag; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IGettable } from "./gettable.js"; +import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./schema/model.js"; +import { validateFeatureFlag } from "./schema/validator.js"; + +export interface IFeatureFlagProvider { + /** + * Get all feature flags. + */ + getFeatureFlags(): Promise; + + /** + * Get a feature flag by name. + * @param featureName The name of the feature flag. + */ + getFeatureFlag(featureName: string): Promise; +} + +/** + * A feature flag provider that uses a map-like configuration to provide feature flags. + */ +export class ConfigurationMapFeatureFlagProvider implements IFeatureFlagProvider { + #configuration: IGettable; + + constructor(configuration: IGettable) { + this.#configuration = configuration; + } + async getFeatureFlag(featureName: string): Promise { + const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); + const featureFlag = featureConfig?.[FEATURE_FLAGS_KEY]?.findLast((feature) => feature.id === featureName); + validateFeatureFlag(featureFlag); + return featureFlag; + } + + async getFeatureFlags(): Promise { + const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); + const featureFlag = featureConfig?.[FEATURE_FLAGS_KEY] ?? []; + validateFeatureFlag(featureFlag); + return featureFlag; + } +} + +/** + * A feature flag provider that uses an object-like configuration to provide feature flags. + */ +export class ConfigurationObjectFeatureFlagProvider implements IFeatureFlagProvider { + #configuration: Record; + + constructor(configuration: Record) { + this.#configuration = configuration; + } + + async getFeatureFlag(featureName: string): Promise { + const featureFlags = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY]; + const featureFlag = featureFlags?.findLast((feature: FeatureFlag) => feature.id === featureName); + validateFeatureFlag(featureFlag); + return featureFlag; + } + + async getFeatureFlags(): Promise { + const featureFlag = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? []; + validateFeatureFlag(featureFlag); + return featureFlag; + } +} diff --git a/src/filter/FeatureFilter.ts b/sdk/feature-management/src/filter/FeatureFilter.ts similarity index 96% rename from src/filter/FeatureFilter.ts rename to sdk/feature-management/src/filter/FeatureFilter.ts index 4c259b9..f7e572b 100644 --- a/src/filter/FeatureFilter.ts +++ b/sdk/feature-management/src/filter/FeatureFilter.ts @@ -1,12 +1,12 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface IFeatureFilter { - name: string; // e.g. Microsoft.TimeWindow - evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): boolean | Promise; -} - -export interface IFeatureFilterEvaluationContext { - featureName: string; - parameters?: unknown; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface IFeatureFilter { + name: string; // e.g. Microsoft.TimeWindow + evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): boolean | Promise; +} + +export interface IFeatureFilterEvaluationContext { + featureName: string; + parameters?: unknown; +} diff --git a/sdk/feature-management/src/filter/TargetingFilter.ts b/sdk/feature-management/src/filter/TargetingFilter.ts new file mode 100644 index 0000000..b675d87 --- /dev/null +++ b/sdk/feature-management/src/filter/TargetingFilter.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IFeatureFilter } from "./FeatureFilter.js"; +import { isTargetedPercentile } from "../common/targetingEvaluator.js"; +import { ITargetingContext } from "../common/ITargetingContext.js"; + +type TargetingFilterParameters = { + Audience: { + DefaultRolloutPercentage: number; + Users?: string[]; + Groups?: { + Name: string; + RolloutPercentage: number; + }[]; + Exclusion?: { + Users?: string[]; + Groups?: string[]; + }; + } +} + +type TargetingFilterEvaluationContext = { + featureName: string; + parameters: TargetingFilterParameters; +} + +export class TargetingFilter implements IFeatureFilter { + name: string = "Microsoft.Targeting"; + + async evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): Promise { + const { featureName, parameters } = context; + TargetingFilter.#validateParameters(parameters); + + if (appContext === undefined) { + throw new Error("The app context is required for targeting filter."); + } + + if (parameters.Audience.Exclusion !== undefined) { + // check if the user is in the exclusion list + if (appContext?.userId !== undefined && + parameters.Audience.Exclusion.Users !== undefined && + parameters.Audience.Exclusion.Users.includes(appContext.userId)) { + return false; + } + // check if the user is in a group within exclusion list + if (appContext?.groups !== undefined && + parameters.Audience.Exclusion.Groups !== undefined) { + for (const excludedGroup of parameters.Audience.Exclusion.Groups) { + if (appContext.groups.includes(excludedGroup)) { + return false; + } + } + } + } + + // check if the user is being targeted directly + if (appContext?.userId !== undefined && + parameters.Audience.Users !== undefined && + parameters.Audience.Users.includes(appContext.userId)) { + return true; + } + + // check if the user is in a group that is being targeted + if (appContext?.groups !== undefined && + parameters.Audience.Groups !== undefined) { + for (const group of parameters.Audience.Groups) { + if (appContext.groups.includes(group.Name)) { + const hint = `${featureName}\n${group.Name}`; + if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { + return true; + } + } + } + } + + // check if the user is being targeted by a default rollout percentage + const hint = featureName; + return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); + } + + static #validateParameters(parameters: TargetingFilterParameters): void { + if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) { + throw new Error("Audience.DefaultRolloutPercentage must be a number between 0 and 100."); + } + // validate RolloutPercentage for each group + if (parameters.Audience.Groups !== undefined) { + for (const group of parameters.Audience.Groups) { + if (group.RolloutPercentage < 0 || group.RolloutPercentage > 100) { + throw new Error(`RolloutPercentage of group ${group.Name} must be a number between 0 and 100.`); + } + } + } + } +} diff --git a/src/filter/TimeWindowFilter.ts b/sdk/feature-management/src/filter/TimeWindowFilter.ts similarity index 97% rename from src/filter/TimeWindowFilter.ts rename to sdk/feature-management/src/filter/TimeWindowFilter.ts index e1442c8..3cd0ead 100644 --- a/src/filter/TimeWindowFilter.ts +++ b/sdk/feature-management/src/filter/TimeWindowFilter.ts @@ -1,33 +1,33 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { IFeatureFilter } from "./FeatureFilter.js"; - -// [Start, End) -type TimeWindowParameters = { - Start?: string; - End?: string; -} - -type TimeWindowFilterEvaluationContext = { - featureName: string; - parameters: TimeWindowParameters; -} - -export class TimeWindowFilter implements IFeatureFilter { - name: string = "Microsoft.TimeWindow"; - - evaluate(context: TimeWindowFilterEvaluationContext): boolean { - const {featureName, parameters} = context; - const startTime = parameters.Start !== undefined ? new Date(parameters.Start) : undefined; - const endTime = parameters.End !== undefined ? new Date(parameters.End) : undefined; - - if (startTime === undefined && endTime === undefined) { - // If neither start nor end time is specified, then the filter is not applicable. - console.warn(`The ${this.name} feature filter is not valid for feature ${featureName}. It must specify either 'Start', 'End', or both.`); - return false; - } - const now = new Date(); - return (startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime); - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IFeatureFilter } from "./FeatureFilter.js"; + +// [Start, End) +type TimeWindowParameters = { + Start?: string; + End?: string; +} + +type TimeWindowFilterEvaluationContext = { + featureName: string; + parameters: TimeWindowParameters; +} + +export class TimeWindowFilter implements IFeatureFilter { + name: string = "Microsoft.TimeWindow"; + + evaluate(context: TimeWindowFilterEvaluationContext): boolean { + const {featureName, parameters} = context; + const startTime = parameters.Start !== undefined ? new Date(parameters.Start) : undefined; + const endTime = parameters.End !== undefined ? new Date(parameters.End) : undefined; + + if (startTime === undefined && endTime === undefined) { + // If neither start nor end time is specified, then the filter is not applicable. + console.warn(`The ${this.name} feature filter is not valid for feature ${featureName}. It must specify either 'Start', 'End', or both.`); + return false; + } + const now = new Date(); + return (startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime); + } +} diff --git a/src/gettable.ts b/sdk/feature-management/src/gettable.ts similarity index 97% rename from src/gettable.ts rename to sdk/feature-management/src/gettable.ts index 98dc1f6..9597211 100644 --- a/src/gettable.ts +++ b/sdk/feature-management/src/gettable.ts @@ -1,10 +1,10 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface IGettable { - get(key: string): T | undefined; -} - -export function isGettable(object: unknown): object is IGettable { - return typeof object === "object" && object !== null && typeof (object as IGettable).get === "function"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface IGettable { + get(key: string): T | undefined; +} + +export function isGettable(object: unknown): object is IGettable { + return typeof object === "object" && object !== null && typeof (object as IGettable).get === "function"; } diff --git a/src/index.ts b/sdk/feature-management/src/index.ts similarity index 59% rename from src/index.ts rename to sdk/feature-management/src/index.ts index 5b3db29..77d18c5 100644 --- a/src/index.ts +++ b/sdk/feature-management/src/index.ts @@ -1,7 +1,8 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export { FeatureManager } from "./featureManager.js"; -export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js"; -export { IFeatureFilter } from "./filter/FeatureFilter.js"; -export { VERSION } from "./version.js"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { FeatureManager, FeatureManagerOptions, EvaluationResult, VariantAssignmentReason } from "./featureManager.js"; +export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js"; +export { createFeatureEvaluationEventProperties } from "./telemetry/featureEvaluationEvent.js"; +export { IFeatureFilter } from "./filter/FeatureFilter.js"; +export { VERSION } from "./version.js"; diff --git a/sdk/feature-management/src/schema/model.ts b/sdk/feature-management/src/schema/model.ts new file mode 100644 index 0000000..21749a0 --- /dev/null +++ b/sdk/feature-management/src/schema/model.ts @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Converted from: +// https://github.com/Azure/AppConfiguration/blob/6e544296a5607f922a423df165f60801717c7800/docs/FeatureManagement/FeatureFlag.v2.0.0.schema.json + +/** + * A feature flag is a named property that can be toggled to enable or disable some feature of an application. + */ +export interface FeatureFlag { + /** + * An ID used to uniquely identify and reference the feature. + */ + id: string; + /** + * A feature is OFF if enabled is false. If enabled is true, then the feature is ON if there are no conditions (null or empty) or if the conditions are satisfied. + */ + enabled?: boolean; + /** + * The declaration of conditions used to dynamically enable the feature. + */ + conditions?: FeatureEnablementConditions; + /** + * The list of variants defined for this feature. A variant represents a configuration value of a feature flag that can be a string, a number, a boolean, or a JSON object. + */ + variants?: VariantDefinition[]; + /** + * Determines how variants should be allocated for the feature to various users. + */ + allocation?: VariantAllocation; + /** + * The declaration of options used to configure telemetry for this feature. + */ + telemetry?: TelemetryOptions + } + + /** + * The declaration of conditions used to dynamically enable the feature + */ + interface FeatureEnablementConditions { + /** + * Determines whether any or all registered client filters must be evaluated as true for the feature to be considered enabled. + */ + requirement_type?: RequirementType; + /** + * Filters that must run on the client and be evaluated as true for the feature to be considered enabled. + */ + client_filters?: ClientFilter[]; + } + + export type RequirementType = "Any" | "All"; + + interface ClientFilter { + /** + * The name used to refer to a client filter. + */ + name: string; + /** + * Parameters for a given client filter. A client filter can require any set of parameters of any type. + */ + parameters?: Record; + } + + export interface VariantDefinition { + /** + * The name used to refer to a feature variant. + */ + name: string; + /** + * The configuration value for this feature variant. + */ + configuration_value?: unknown; + /** + * Overrides the enabled state of the feature if the given variant is assigned. Does not override the state if value is None. + */ + status_override?: "None" | "Enabled" | "Disabled"; + } + + /** + * Determines how variants should be allocated for the feature to various users. + */ + interface VariantAllocation { + /** + * Specifies which variant should be used when the feature is considered disabled. + */ + default_when_disabled?: string; + /** + * Specifies which variant should be used when the feature is considered enabled and no other allocation rules are applicable. + */ + default_when_enabled?: string; + /** + * A list of objects, each containing a variant name and list of users for whom that variant should be used. + */ + user?: UserAllocation[]; + /** + * A list of objects, each containing a variant name and list of groups for which that variant should be used. + */ + group?: GroupAllocation[]; + /** + * A list of objects, each containing a variant name and percentage range for which that variant should be used. + */ + percentile?: PercentileAllocation[] + /** + * The value percentile calculations are based on. The calculated percentile is consistent across features for a given user if the same nonempty seed is used. + */ + seed?: string; + } + + interface UserAllocation { + /** + * The name of the variant to use if the user allocation matches the current user. + */ + variant: string; + /** + * Collection of users where if any match the current user, the variant specified in the user allocation is used. + */ + users: string[]; + } + + interface GroupAllocation { + /** + * The name of the variant to use if the group allocation matches a group the current user is in. + */ + variant: string; + /** + * Collection of groups where if the current user is in any of these groups, the variant specified in the group allocation is used. + */ + groups: string[]; + } + + interface PercentileAllocation { + /** + * The name of the variant to use if the calculated percentile for the current user falls in the provided range. + */ + variant: string; + /** + * The lower end of the percentage range for which this variant will be used. + */ + from: number; + /** + * The upper end of the percentage range for which this variant will be used. + */ + to: number; + } + + /** + * The declaration of options used to configure telemetry for this feature. + */ + interface TelemetryOptions { + /** + * Indicates if telemetry is enabled. + */ + enabled?: boolean; + /** + * A container for metadata that should be bundled with flag telemetry. + */ + metadata?: Record; + } + + // Feature Management Section fed into feature manager. + // Converted from https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v1.0.0.schema.json + + export const FEATURE_MANAGEMENT_KEY = "feature_management"; + export const FEATURE_FLAGS_KEY = "feature_flags"; + + export interface FeatureManagementConfiguration { + feature_management: FeatureManagement + } + + /** + * Declares feature management configuration. + */ + export interface FeatureManagement { + feature_flags: FeatureFlag[]; + } diff --git a/src/schema/validator.ts b/sdk/feature-management/src/schema/validator.ts similarity index 97% rename from src/schema/validator.ts rename to sdk/feature-management/src/schema/validator.ts index 1d63d72..97bf5ed 100644 --- a/src/schema/validator.ts +++ b/sdk/feature-management/src/schema/validator.ts @@ -1,186 +1,186 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/** - * Validates a feature flag object, checking if it conforms to the schema. - * @param featureFlag The feature flag object to validate. - */ -export function validateFeatureFlag(featureFlag: any): void { - if (featureFlag === undefined) { - return; // no-op if feature flag is undefined, indicating that the feature flag is not found - } - if (featureFlag === null || typeof featureFlag !== "object") { // Note: typeof null = "object" - throw new TypeError("Feature flag must be an object."); - } - if (typeof featureFlag.id !== "string") { - throw new TypeError("Feature flag 'id' must be a string."); - } - if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { - throw new TypeError("Feature flag 'enabled' must be a boolean."); - } - if (featureFlag.conditions !== undefined) { - validateFeatureEnablementConditions(featureFlag.conditions); - } - if (featureFlag.variants !== undefined) { - validateVariants(featureFlag.variants); - } - if (featureFlag.allocation !== undefined) { - validateVariantAllocation(featureFlag.allocation); - } - if (featureFlag.telemetry !== undefined) { - validateTelemetryOptions(featureFlag.telemetry); - } -} - -function validateFeatureEnablementConditions(conditions: any) { - if (typeof conditions !== "object") { - throw new TypeError("Feature flag 'conditions' must be an object."); - } - if (conditions.requirement_type !== undefined && conditions.requirement_type !== "Any" && conditions.requirement_type !== "All") { - throw new TypeError("'requirement_type' must be 'Any' or 'All'."); - } - if (conditions.client_filters !== undefined) { - validateClientFilters(conditions.client_filters); - } -} - -function validateClientFilters(client_filters: any) { - if (!Array.isArray(client_filters)) { - throw new TypeError("Feature flag conditions 'client_filters' must be an array."); - } - - for (const filter of client_filters) { - if (typeof filter.name !== "string") { - throw new TypeError("Client filter 'name' must be a string."); - } - if (filter.parameters !== undefined && typeof filter.parameters !== "object") { - throw new TypeError("Client filter 'parameters' must be an object."); - } - } -} - -function validateVariants(variants: any) { - if (!Array.isArray(variants)) { - throw new TypeError("Feature flag 'variants' must be an array."); - } - - for (const variant of variants) { - if (typeof variant.name !== "string") { - throw new TypeError("Variant 'name' must be a string."); - } - // skip configuration_value validation as it accepts any type - if (variant.status_override !== undefined && typeof variant.status_override !== "string") { - throw new TypeError("Variant 'status_override' must be a string."); - } - if (variant.status_override !== undefined && variant.status_override !== "None" && variant.status_override !== "Enabled" && variant.status_override !== "Disabled") { - throw new TypeError("Variant 'status_override' must be 'None', 'Enabled', or 'Disabled'."); - } - } -} - -function validateVariantAllocation(allocation: any) { - if (typeof allocation !== "object") { - throw new TypeError("Variant 'allocation' must be an object."); - } - - if (allocation.default_when_disabled !== undefined && typeof allocation.default_when_disabled !== "string") { - throw new TypeError("Variant allocation 'default_when_disabled' must be a string."); - } - if (allocation.default_when_enabled !== undefined && typeof allocation.default_when_enabled !== "string") { - throw new TypeError("Variant allocation 'default_when_enabled' must be a string."); - } - if (allocation.user !== undefined) { - validateUserVariantAllocation(allocation.user); - } - if (allocation.group !== undefined) { - validateGroupVariantAllocation(allocation.group); - } - if (allocation.percentile !== undefined) { - validatePercentileVariantAllocation(allocation.percentile); - } - if (allocation.seed !== undefined && typeof allocation.seed !== "string") { - throw new TypeError("Variant allocation 'seed' must be a string."); - } -} - -function validateUserVariantAllocation(UserAllocations: any) { - if (!Array.isArray(UserAllocations)) { - throw new TypeError("Variant 'user' allocation must be an array."); - } - - for (const allocation of UserAllocations) { - if (typeof allocation !== "object") { - throw new TypeError("Elements in variant 'user' allocation must be an object."); - } - if (typeof allocation.variant !== "string") { - throw new TypeError("User allocation 'variant' must be a string."); - } - if (!Array.isArray(allocation.users)) { - throw new TypeError("User allocation 'users' must be an array."); - } - for (const user of allocation.users) { - if (typeof user !== "string") { - throw new TypeError("Elements in user allocation 'users' must be strings."); - } - } - } -} - -function validateGroupVariantAllocation(groupAllocations: any) { - if (!Array.isArray(groupAllocations)) { - throw new TypeError("Variant 'group' allocation must be an array."); - } - - for (const allocation of groupAllocations) { - if (typeof allocation !== "object") { - throw new TypeError("Elements in variant 'group' allocation must be an object."); - } - if (typeof allocation.variant !== "string") { - throw new TypeError("Group allocation 'variant' must be a string."); - } - if (!Array.isArray(allocation.groups)) { - throw new TypeError("Group allocation 'groups' must be an array."); - } - for (const group of allocation.groups) { - if (typeof group !== "string") { - throw new TypeError("Elements in group allocation 'groups' must be strings."); - } - } - } -} - -function validatePercentileVariantAllocation(percentileAllocations: any) { - if (!Array.isArray(percentileAllocations)) { - throw new TypeError("Variant 'percentile' allocation must be an array."); - } - - for (const allocation of percentileAllocations) { - if (typeof allocation !== "object") { - throw new TypeError("Elements in variant 'percentile' allocation must be an object."); - } - if (typeof allocation.variant !== "string") { - throw new TypeError("Percentile allocation 'variant' must be a string."); - } - if (typeof allocation.from !== "number" || allocation.from < 0 || allocation.from > 100) { - throw new TypeError("Percentile allocation 'from' must be a number between 0 and 100."); - } - if (typeof allocation.to !== "number" || allocation.to < 0 || allocation.to > 100) { - throw new TypeError("Percentile allocation 'to' must be a number between 0 and 100."); - } - } -} -// #endregion - -// #region Telemetry -function validateTelemetryOptions(telemetry: any) { - if (typeof telemetry !== "object") { - throw new TypeError("Feature flag 'telemetry' must be an object."); - } - if (telemetry.enabled !== undefined && typeof telemetry.enabled !== "boolean") { - throw new TypeError("Telemetry 'enabled' must be a boolean."); - } - if (telemetry.metadata !== undefined && typeof telemetry.metadata !== "object") { - throw new TypeError("Telemetry 'metadata' must be an object."); - } -} -// #endregion +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Validates a feature flag object, checking if it conforms to the schema. + * @param featureFlag The feature flag object to validate. + */ +export function validateFeatureFlag(featureFlag: any): void { + if (featureFlag === undefined) { + return; // no-op if feature flag is undefined, indicating that the feature flag is not found + } + if (featureFlag === null || typeof featureFlag !== "object") { // Note: typeof null = "object" + throw new TypeError("Feature flag must be an object."); + } + if (typeof featureFlag.id !== "string") { + throw new TypeError("Feature flag 'id' must be a string."); + } + if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { + throw new TypeError("Feature flag 'enabled' must be a boolean."); + } + if (featureFlag.conditions !== undefined) { + validateFeatureEnablementConditions(featureFlag.conditions); + } + if (featureFlag.variants !== undefined) { + validateVariants(featureFlag.variants); + } + if (featureFlag.allocation !== undefined) { + validateVariantAllocation(featureFlag.allocation); + } + if (featureFlag.telemetry !== undefined) { + validateTelemetryOptions(featureFlag.telemetry); + } +} + +function validateFeatureEnablementConditions(conditions: any) { + if (typeof conditions !== "object") { + throw new TypeError("Feature flag 'conditions' must be an object."); + } + if (conditions.requirement_type !== undefined && conditions.requirement_type !== "Any" && conditions.requirement_type !== "All") { + throw new TypeError("'requirement_type' must be 'Any' or 'All'."); + } + if (conditions.client_filters !== undefined) { + validateClientFilters(conditions.client_filters); + } +} + +function validateClientFilters(client_filters: any) { + if (!Array.isArray(client_filters)) { + throw new TypeError("Feature flag conditions 'client_filters' must be an array."); + } + + for (const filter of client_filters) { + if (typeof filter.name !== "string") { + throw new TypeError("Client filter 'name' must be a string."); + } + if (filter.parameters !== undefined && typeof filter.parameters !== "object") { + throw new TypeError("Client filter 'parameters' must be an object."); + } + } +} + +function validateVariants(variants: any) { + if (!Array.isArray(variants)) { + throw new TypeError("Feature flag 'variants' must be an array."); + } + + for (const variant of variants) { + if (typeof variant.name !== "string") { + throw new TypeError("Variant 'name' must be a string."); + } + // skip configuration_value validation as it accepts any type + if (variant.status_override !== undefined && typeof variant.status_override !== "string") { + throw new TypeError("Variant 'status_override' must be a string."); + } + if (variant.status_override !== undefined && variant.status_override !== "None" && variant.status_override !== "Enabled" && variant.status_override !== "Disabled") { + throw new TypeError("Variant 'status_override' must be 'None', 'Enabled', or 'Disabled'."); + } + } +} + +function validateVariantAllocation(allocation: any) { + if (typeof allocation !== "object") { + throw new TypeError("Variant 'allocation' must be an object."); + } + + if (allocation.default_when_disabled !== undefined && typeof allocation.default_when_disabled !== "string") { + throw new TypeError("Variant allocation 'default_when_disabled' must be a string."); + } + if (allocation.default_when_enabled !== undefined && typeof allocation.default_when_enabled !== "string") { + throw new TypeError("Variant allocation 'default_when_enabled' must be a string."); + } + if (allocation.user !== undefined) { + validateUserVariantAllocation(allocation.user); + } + if (allocation.group !== undefined) { + validateGroupVariantAllocation(allocation.group); + } + if (allocation.percentile !== undefined) { + validatePercentileVariantAllocation(allocation.percentile); + } + if (allocation.seed !== undefined && typeof allocation.seed !== "string") { + throw new TypeError("Variant allocation 'seed' must be a string."); + } +} + +function validateUserVariantAllocation(UserAllocations: any) { + if (!Array.isArray(UserAllocations)) { + throw new TypeError("Variant 'user' allocation must be an array."); + } + + for (const allocation of UserAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Elements in variant 'user' allocation must be an object."); + } + if (typeof allocation.variant !== "string") { + throw new TypeError("User allocation 'variant' must be a string."); + } + if (!Array.isArray(allocation.users)) { + throw new TypeError("User allocation 'users' must be an array."); + } + for (const user of allocation.users) { + if (typeof user !== "string") { + throw new TypeError("Elements in user allocation 'users' must be strings."); + } + } + } +} + +function validateGroupVariantAllocation(groupAllocations: any) { + if (!Array.isArray(groupAllocations)) { + throw new TypeError("Variant 'group' allocation must be an array."); + } + + for (const allocation of groupAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Elements in variant 'group' allocation must be an object."); + } + if (typeof allocation.variant !== "string") { + throw new TypeError("Group allocation 'variant' must be a string."); + } + if (!Array.isArray(allocation.groups)) { + throw new TypeError("Group allocation 'groups' must be an array."); + } + for (const group of allocation.groups) { + if (typeof group !== "string") { + throw new TypeError("Elements in group allocation 'groups' must be strings."); + } + } + } +} + +function validatePercentileVariantAllocation(percentileAllocations: any) { + if (!Array.isArray(percentileAllocations)) { + throw new TypeError("Variant 'percentile' allocation must be an array."); + } + + for (const allocation of percentileAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Elements in variant 'percentile' allocation must be an object."); + } + if (typeof allocation.variant !== "string") { + throw new TypeError("Percentile allocation 'variant' must be a string."); + } + if (typeof allocation.from !== "number" || allocation.from < 0 || allocation.from > 100) { + throw new TypeError("Percentile allocation 'from' must be a number between 0 and 100."); + } + if (typeof allocation.to !== "number" || allocation.to < 0 || allocation.to > 100) { + throw new TypeError("Percentile allocation 'to' must be a number between 0 and 100."); + } + } +} +// #endregion + +// #region Telemetry +function validateTelemetryOptions(telemetry: any) { + if (typeof telemetry !== "object") { + throw new TypeError("Feature flag 'telemetry' must be an object."); + } + if (telemetry.enabled !== undefined && typeof telemetry.enabled !== "boolean") { + throw new TypeError("Telemetry 'enabled' must be a boolean."); + } + if (telemetry.metadata !== undefined && typeof telemetry.metadata !== "object") { + throw new TypeError("Telemetry 'metadata' must be an object."); + } +} +// #endregion diff --git a/sdk/feature-management/src/telemetry/featureEvaluationEvent.ts b/sdk/feature-management/src/telemetry/featureEvaluationEvent.ts new file mode 100644 index 0000000..2c195c6 --- /dev/null +++ b/sdk/feature-management/src/telemetry/featureEvaluationEvent.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { EvaluationResult, VariantAssignmentReason } from "../featureManager"; +import { EVALUATION_EVENT_VERSION } from "../version.js"; + +const VERSION = "Version"; +const FEATURE_NAME = "FeatureName"; +const ENABLED = "Enabled"; +const TARGETING_ID = "TargetingId"; +const VARIANT = "Variant"; +const VARIANT_ASSIGNMENT_REASON = "VariantAssignmentReason"; +const DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"; +const VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage"; + +export function createFeatureEvaluationEventProperties(result: EvaluationResult): any { + if (result.feature === undefined) { + return undefined; + } + + const eventProperties = { + [VERSION]: EVALUATION_EVENT_VERSION, + [FEATURE_NAME]: result.feature ? result.feature.id : "", + [ENABLED]: result.enabled ? "True" : "False", + // Ensure targetingId is string so that it will be placed in customDimensions + [TARGETING_ID]: result.targetingId ? result.targetingId.toString() : "", + [VARIANT]: result.variant ? result.variant.name : "", + [VARIANT_ASSIGNMENT_REASON]: result.variantAssignmentReason, + }; + + if (result.feature.allocation?.default_when_enabled) { + eventProperties[DEFAULT_WHEN_ENABLED] = result.feature.allocation.default_when_enabled; + } + + if (result.variantAssignmentReason === VariantAssignmentReason.DefaultWhenEnabled) { + let percentileAllocationPercentage = 0; + if (result.variant !== undefined && result.feature.allocation !== undefined && result.feature.allocation.percentile !== undefined) { + for (const percentile of result.feature.allocation.percentile) { + percentileAllocationPercentage += percentile.to - percentile.from; + } + } + eventProperties[VARIANT_ASSIGNMENT_PERCENTAGE] = (100 - percentileAllocationPercentage).toString(); + } + else if (result.variantAssignmentReason === VariantAssignmentReason.Percentile) { + let percentileAllocationPercentage = 0; + if (result.variant !== undefined && result.feature.allocation !== undefined && result.feature.allocation.percentile !== undefined) { + for (const percentile of result.feature.allocation.percentile) { + if (percentile.variant === result.variant.name) { + percentileAllocationPercentage += percentile.to - percentile.from; + } + } + } + eventProperties[VARIANT_ASSIGNMENT_PERCENTAGE] = percentileAllocationPercentage.toString(); + } + + const metadata = result.feature.telemetry?.metadata; + if (metadata) { + for (const key in metadata) { + if (!(key in eventProperties)) { + eventProperties[key] = metadata[key]; + } + } + } + + return eventProperties; +} diff --git a/sdk/feature-management/src/variant/Variant.ts b/sdk/feature-management/src/variant/Variant.ts new file mode 100644 index 0000000..30ce195 --- /dev/null +++ b/sdk/feature-management/src/variant/Variant.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Variant { + constructor( + public name: string, + public configuration: unknown + ) {} +} diff --git a/sdk/feature-management/src/version.ts b/sdk/feature-management/src/version.ts new file mode 100644 index 0000000..65fbbf3 --- /dev/null +++ b/sdk/feature-management/src/version.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const VERSION = "2.0.0-preview.3"; +export const EVALUATION_EVENT_VERSION = "1.0.0"; diff --git a/test/browser/browser.test.ts b/sdk/feature-management/test/browser/browser.test.ts similarity index 100% rename from test/browser/browser.test.ts rename to sdk/feature-management/test/browser/browser.test.ts diff --git a/test/browser/index.html b/sdk/feature-management/test/browser/index.html similarity index 100% rename from test/browser/index.html rename to sdk/feature-management/test/browser/index.html diff --git a/test/browser/testcases.js b/sdk/feature-management/test/browser/testcases.js similarity index 100% rename from test/browser/testcases.js rename to sdk/feature-management/test/browser/testcases.js diff --git a/sdk/feature-management/test/featureEvaluation.test.ts b/sdk/feature-management/test/featureEvaluation.test.ts new file mode 100644 index 0000000..2729a2e --- /dev/null +++ b/sdk/feature-management/test/featureEvaluation.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider, EvaluationResult, VariantAssignmentReason } from "../"; +const expect = chai.expect; + +let called: number = 0; +const dummyCallback = () => { + called += 1; +}; + +let evaluationResult: EvaluationResult | undefined; +const setEvaluationResult = (result: EvaluationResult) => { + evaluationResult = result; +}; + +describe("feature evaluation", () => { + beforeEach(() => { + called = 0; + evaluationResult = undefined; + }); + + it("should not call onFeatureEvaluated when telemetry is not enabled", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature", + "enabled": true, + "telemetry": { "enabled": false } + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: dummyCallback}); + + await featureManager.isEnabled("TestFeature"); + expect(called).to.eq(0); + }); + + it("should only call onFeatureEvaluated once", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature", + "enabled": true, + "telemetry": { "enabled": true } + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: dummyCallback}); + + await featureManager.isEnabled("TestFeature"); + expect(called).to.eq(1); + }); + + it("should not assign variant when there is no variants", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature", + "enabled": true, + "allocation": { "default_when_enabled": "Big" }, + "telemetry": { "enabled": true} + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: setEvaluationResult}); + + await featureManager.isEnabled("TestFeature"); + expect(evaluationResult?.feature?.id).to.eq("TestFeature"); + expect(evaluationResult?.enabled).to.eq(true); + expect(evaluationResult?.targetingId).to.eq(undefined); + expect(evaluationResult?.variant).to.eq(undefined); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.None); + }); + + it("should assign variant for reason DefaultWhenEnabled", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature1", + "enabled": true, + "variants": [ { "name": "Big", "status_override": "Disabled" }, { "name": "Small" } ], + "allocation": { + "default_when_enabled": "Big", + "user": [ { "variant": "Small", "users": [ "Jeff" ] } ] + }, + "telemetry": { "enabled": true} + }, + { + "id": "TestFeature2", + "enabled": true, + "variants": [ { "name": "Big" } ], + "telemetry": { "enabled": true} + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: setEvaluationResult}); + + await featureManager.getVariant("TestFeature1", { userId: "Jim" }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature1"); + expect(evaluationResult?.enabled).to.eq(false); // status override + expect(evaluationResult?.targetingId).to.eq("Jim"); + expect(evaluationResult?.variant?.name).to.eq("Big"); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.DefaultWhenEnabled); + + await featureManager.getVariant("TestFeature2", { userId: "Jim" }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature2"); + expect(evaluationResult?.enabled).to.eq(true); + expect(evaluationResult?.targetingId).to.eq("Jim"); + expect(evaluationResult?.variant).to.eq(undefined); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.DefaultWhenEnabled); + }); + + it("should assign variant for reason DefaultWhenDisabled", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature1", + "enabled": false, + "variants": [ { "name": "Small", "status_override": "Enabled" }, { "name": "Big" } ], + "allocation": { + "default_when_disabled": "Small", + "user": [ { "variant": "Big", "users": [ "Jeff" ] } ] + }, + "telemetry": { "enabled": true} + }, + { + "id": "TestFeature2", + "enabled": false, + "variants": [ { "name": "Small" } ], + "telemetry": { "enabled": true} + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: setEvaluationResult}); + + await featureManager.getVariant("TestFeature1", { userId: "Jeff" }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature1"); + expect(evaluationResult?.enabled).to.eq(false); // status oveerride won't work when feature's enabled is false + expect(evaluationResult?.targetingId).to.eq("Jeff"); + expect(evaluationResult?.variant?.name).to.eq("Small"); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.DefaultWhenDisabled); + + await featureManager.getVariant("TestFeature2", { userId: "Jeff" }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature2"); + expect(evaluationResult?.enabled).to.eq(false); + expect(evaluationResult?.targetingId).to.eq("Jeff"); + expect(evaluationResult?.variant).to.eq(undefined); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.DefaultWhenDisabled); + }); + + it("should assign variant for reason User", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature", + "enabled": true, + "variants": [ { "name": "Big" } ], + "allocation": { "user": [ { "variant": "Big", "users": [ "Jeff" ] } ] }, + "telemetry": { "enabled": true} + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: setEvaluationResult}); + + await featureManager.getVariant("TestFeature", { userId: "Jeff" }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature"); + expect(evaluationResult?.enabled).to.eq(true); + expect(evaluationResult?.targetingId).to.eq("Jeff"); + expect(evaluationResult?.variant?.name).to.eq("Big"); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.User); + }); + + it("should assign variant for reason Group", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature", + "enabled": true, + "variants": [ { "name": "Big" } ], + "allocation": { "group": [ { "variant": "Big", "groups": [ "admin" ] } ] }, + "telemetry": { "enabled": true} + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: setEvaluationResult}); + + await featureManager.getVariant("TestFeature", { userId: "Jeff", groups: ["admin"] }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature"); + expect(evaluationResult?.enabled).to.eq(true); + expect(evaluationResult?.targetingId).to.eq("Jeff"); + expect(evaluationResult?.variant?.name).to.eq("Big"); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.Group); + }); + + it("should assign variant for reason Percentile", async () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { + "id": "TestFeature", + "enabled": true, + "variants": [ { "name": "Big", "status_override": "Disabled" } ], + "allocation": { "percentile": [ { "variant": "Big", "from": 0, "to": 50 } ], "seed": "1234" }, + "telemetry": { "enabled": true} + } + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider, { onFeatureEvaluated: setEvaluationResult}); + + await featureManager.getVariant("TestFeature", { userId: "Marsha" }); + expect(evaluationResult?.feature?.id).to.eq("TestFeature"); + expect(evaluationResult?.enabled).to.eq(false); // status override + expect(evaluationResult?.targetingId).to.eq("Marsha"); + expect(evaluationResult?.variant?.name).to.eq("Big"); + expect(evaluationResult?.variantAssignmentReason).to.eq(VariantAssignmentReason.Percentile); + }); +}); diff --git a/test/featureManager.test.ts b/sdk/feature-management/test/featureManager.test.ts similarity index 97% rename from test/featureManager.test.ts rename to sdk/feature-management/test/featureManager.test.ts index 0bfa331..37243a9 100644 --- a/test/featureManager.test.ts +++ b/sdk/feature-management/test/featureManager.test.ts @@ -1,144 +1,144 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationObjectFeatureFlagProvider, ConfigurationMapFeatureFlagProvider } from "../"; - -describe("feature manager", () => { - it("should load from json string", () => { - const jsonObject = { - "feature_management": { - "feature_flags": [ - { "id": "Alpha", "description": "", "enabled": true} - ] - } - }; - - const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); - const featureManager = new FeatureManager(provider); - return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); - }); - - it("should load from map", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { id: "Alpha", enabled: true } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); - }); - - it("should load latest data if source is updated after initialization", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { id: "Alpha", enabled: true } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - dataSource.set("feature_management", { - feature_flags: [ - { id: "Alpha", enabled: false } - ], - }); - - return expect(featureManager.isEnabled("Alpha")).eventually.eq(false); - }); - - it("should evaluate features without conditions", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } }, - { "id": "Beta", "description": "", "enabled": false, "conditions": { "client_filters": [] } } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - return Promise.all([ - expect(featureManager.isEnabled("Alpha")).eventually.eq(true), - expect(featureManager.isEnabled("Beta")).eventually.eq(false) - ]); - }); - - it("should evaluate features with conditions", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { - "id": "Gamma", - "description": "", - "enabled": true, - "conditions": { - "requirement_type": "invalid type", - "client_filters": [ - { "name": "Microsoft.Targeting", "parameters": { "Audience": { "DefaultRolloutPercentage": 50 } } } - ] - } - }, - { - "id": "Delta", - "description": "", - "enabled": true, - "conditions": { - "requirement_type": "Any", - "client_filters": [ - { "name": "Microsoft.Targeting", "parameters": "invalid parameter" } - ] - } - } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - return Promise.all([ - expect(featureManager.isEnabled("Gamma")).eventually.rejectedWith("'requirement_type' must be 'Any' or 'All'."), - expect(featureManager.isEnabled("Delta")).eventually.rejectedWith("Client filter 'parameters' must be an object.") - ]); - }); - - it("should let the last feature flag win", () => { - const jsonObject = { - "feature_management": { - "feature_flags": [ - { "id": "Alpha", "description": "", "enabled": false, "conditions": { "client_filters": [] } }, - { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } } - ] - } - }; - - const provider1 = new ConfigurationObjectFeatureFlagProvider(jsonObject); - const featureManager1 = new FeatureManager(provider1); - - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { "id": "Alpha", "description": "", "enabled": false, "conditions": { "client_filters": [] } }, - { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } } - ], - }); - - const provider2 = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager2 = new FeatureManager(provider2); - - return Promise.all([ - expect(featureManager1.isEnabled("Alpha")).eventually.eq(true), - expect(featureManager2.isEnabled("Alpha")).eventually.eq(true) - ]); - }); - - it("should evaluate features with conditions"); - it("should override default filters with custom filters"); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider, ConfigurationMapFeatureFlagProvider } from "../"; + +describe("feature manager", () => { + it("should load from json string", () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { "id": "Alpha", "description": "", "enabled": true} + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider); + return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); + }); + + it("should load from map", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { id: "Alpha", enabled: true } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); + }); + + it("should load latest data if source is updated after initialization", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { id: "Alpha", enabled: true } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + dataSource.set("feature_management", { + feature_flags: [ + { id: "Alpha", enabled: false } + ], + }); + + return expect(featureManager.isEnabled("Alpha")).eventually.eq(false); + }); + + it("should evaluate features without conditions", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } }, + { "id": "Beta", "description": "", "enabled": false, "conditions": { "client_filters": [] } } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + return Promise.all([ + expect(featureManager.isEnabled("Alpha")).eventually.eq(true), + expect(featureManager.isEnabled("Beta")).eventually.eq(false) + ]); + }); + + it("should evaluate features with conditions", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { + "id": "Gamma", + "description": "", + "enabled": true, + "conditions": { + "requirement_type": "invalid type", + "client_filters": [ + { "name": "Microsoft.Targeting", "parameters": { "Audience": { "DefaultRolloutPercentage": 50 } } } + ] + } + }, + { + "id": "Delta", + "description": "", + "enabled": true, + "conditions": { + "requirement_type": "Any", + "client_filters": [ + { "name": "Microsoft.Targeting", "parameters": "invalid parameter" } + ] + } + } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + return Promise.all([ + expect(featureManager.isEnabled("Gamma")).eventually.rejectedWith("'requirement_type' must be 'Any' or 'All'."), + expect(featureManager.isEnabled("Delta")).eventually.rejectedWith("Client filter 'parameters' must be an object.") + ]); + }); + + it("should let the last feature flag win", () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { "id": "Alpha", "description": "", "enabled": false, "conditions": { "client_filters": [] } }, + { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } } + ] + } + }; + + const provider1 = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager1 = new FeatureManager(provider1); + + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { "id": "Alpha", "description": "", "enabled": false, "conditions": { "client_filters": [] } }, + { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } } + ], + }); + + const provider2 = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager2 = new FeatureManager(provider2); + + return Promise.all([ + expect(featureManager1.isEnabled("Alpha")).eventually.eq(true), + expect(featureManager2.isEnabled("Alpha")).eventually.eq(true) + ]); + }); + + it("should evaluate features with conditions"); + it("should override default filters with custom filters"); +}); diff --git a/test/noFilters.test.ts b/sdk/feature-management/test/noFilters.test.ts similarity index 97% rename from test/noFilters.test.ts rename to sdk/feature-management/test/noFilters.test.ts index aaac209..efba770 100644 --- a/test/noFilters.test.ts +++ b/sdk/feature-management/test/noFilters.test.ts @@ -1,70 +1,70 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; - -const featureFlagsDataObject = { - "feature_management": { - "feature_flags": [ - { - "id": "BooleanTrue", - "description": "A feature flag with no Filters, that returns true.", - "enabled": true, - "conditions": { - "client_filters": [] - } - }, - { - "id": "BooleanFalse", - "description": "A feature flag with no Filters, that returns false.", - "enabled": false, - "conditions": { - "client_filters": [] - } - }, - { - "id": "InvalidEnabled", - "description": "A feature flag with an invalid 'enabled' value, that throws an exception.", - "enabled": "invalid", - "conditions": { - "client_filters": [] - } - }, - { - "id": "Minimal", - "enabled": true - }, - { - "id": "NoEnabled" - }, - { - "id": "EmptyConditions", - "description": "A feature flag with no values in conditions, that returns true.", - "enabled": true, - "conditions": { - } - } - ] - } -}; - -describe("feature flags with no filters", () => { - it("should validate feature flags without filters", () => { - const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsDataObject); - const featureManager = new FeatureManager(provider); - - return Promise.all([ - expect(featureManager.isEnabled("BooleanTrue")).eventually.eq(true), - expect(featureManager.isEnabled("BooleanFalse")).eventually.eq(false), - expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag 'enabled' must be a boolean."), - expect(featureManager.isEnabled("Minimal")).eventually.eq(true), - expect(featureManager.isEnabled("NoEnabled")).eventually.eq(false), - expect(featureManager.isEnabled("EmptyConditions")).eventually.eq(true) - ]); - }); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; + +const featureFlagsDataObject = { + "feature_management": { + "feature_flags": [ + { + "id": "BooleanTrue", + "description": "A feature flag with no Filters, that returns true.", + "enabled": true, + "conditions": { + "client_filters": [] + } + }, + { + "id": "BooleanFalse", + "description": "A feature flag with no Filters, that returns false.", + "enabled": false, + "conditions": { + "client_filters": [] + } + }, + { + "id": "InvalidEnabled", + "description": "A feature flag with an invalid 'enabled' value, that throws an exception.", + "enabled": "invalid", + "conditions": { + "client_filters": [] + } + }, + { + "id": "Minimal", + "enabled": true + }, + { + "id": "NoEnabled" + }, + { + "id": "EmptyConditions", + "description": "A feature flag with no values in conditions, that returns true.", + "enabled": true, + "conditions": { + } + } + ] + } +}; + +describe("feature flags with no filters", () => { + it("should validate feature flags without filters", () => { + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsDataObject); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + expect(featureManager.isEnabled("BooleanTrue")).eventually.eq(true), + expect(featureManager.isEnabled("BooleanFalse")).eventually.eq(false), + expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag 'enabled' must be a boolean."), + expect(featureManager.isEnabled("Minimal")).eventually.eq(true), + expect(featureManager.isEnabled("NoEnabled")).eventually.eq(false), + expect(featureManager.isEnabled("EmptyConditions")).eventually.eq(true) + ]); + }); +}); diff --git a/sdk/feature-management/test/sampleFeatureFlags.ts b/sdk/feature-management/test/sampleFeatureFlags.ts new file mode 100644 index 0000000..85f69b0 --- /dev/null +++ b/sdk/feature-management/test/sampleFeatureFlags.ts @@ -0,0 +1,532 @@ +export enum Features { + VariantFeatureDefaultDisabled = "VariantFeatureDefaultDisabled", + VariantFeatureDefaultEnabled = "VariantFeatureDefaultEnabled", + VariantFeaturePercentileOn = "VariantFeaturePercentileOn", + VariantFeaturePercentileOff = "VariantFeaturePercentileOff", + VariantFeatureAlwaysOff = "VariantFeatureAlwaysOff", + VariantFeatureUser = "VariantFeatureUser", + VariantFeatureGroup = "VariantFeatureGroup", + VariantFeatureNoVariants = "VariantFeatureNoVariants", + VariantFeatureNoAllocation = "VariantFeatureNoAllocation", + VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation", + VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations", + VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride", + VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo", + VariantImplementationFeature = "VariantImplementationFeature", +} + +export const featureFlagsConfigurationObject = { + "feature_management": { + "feature_flags": [ + { + "id": "OnTestFeature", + "enabled": true + }, + { + "id": "OffTestFeature", + "enabled": false + }, + { + "id": "ConditionalFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Test", + "parameters": { + "P1": "V1" + } + } + ] + } + }, + { + "id": "ContextualFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "ContextualTest", + "parameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + } + }, + { + "id": "AnyFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "Any", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "AllFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "All", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "FeatureUsesFiltersWithDuplicatedAlias", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "DuplicatedFilterName" + }, + { + "name": "Percentage", + "parameters": { + "Value": 100 + } + } + ] + } + }, + { + "id": "TargetingTestFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } + } + ] + } + }, + { + "id": "TargetingTestFeatureWithExclusion", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20, + "Exclusion": { + "Users": [ + "Jeff" + ], + "Groups": [ + "Ring0", + "Ring2" + ] + } + } + } + } + ] + } + }, + { + "id": "CustomFilterFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "CustomTargetingFilter", + "parameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + } + }, + { + "id": "VariantFeaturePercentileOn", + "enabled": true, + "variants": [ + { + "name": "Big", + "status_override": "Disabled" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": "1234" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeaturePercentileOff", + "enabled": true, + "variants": [ + { + "name": "Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": "12345" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOff", + "enabled": false, + "variants": [ + { + "name": "Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 100 + } + ], + "seed": "12345" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultDisabled", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_disabled": "Small" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultEnabled", + "enabled": true, + "variants": [ + { + "name": "Medium", + "configuration_value": { + "Size": "450px", + "Color": "Purple" + } + }, + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_enabled": "Medium", + "user": [ + { + "variant": "Small", + "users": [ + "Jeff" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureUser", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureGroup", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "group": [ + { + "variant": "Small", + "groups": [ + "Group1" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoVariants", + "enabled": true, + "variants": [], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoAllocation", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOffNoAllocation", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureBothConfigurations", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "600px" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidStatusOverride", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px", + "status_override": "InvalidValue" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidFromTo", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Small", + "from": "Invalid", + "to": "Invalid" + } + ] + } + }, + { + "id": "VariantImplementationFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "UserOmega", + "UserSigma", + "UserBeta" + ] + } + } + } + ] + }, + "variants": [ + { + "name": "AlgorithmBeta" + }, + { + "name": "Sigma", + "configuration_value": "AlgorithmSigma" + }, + { + "name": "Omega" + } + ], + "allocation": { + "user": [ + { + "variant": "AlgorithmBeta", + "users": [ + "UserBeta" + ] + }, + { + "variant": "Omega", + "users": [ + "UserOmega" + ] + }, + { + "variant": "Sigma", + "users": [ + "UserSigma" + ] + } + ] + } + }, + { + "id": "OnTelemetryTestFeature", + "enabled": true, + "telemetry": { + "enabled": true, + "metadata": { + "Tags.Tag1": "Tag1Value", + "Tags.Tag2": "Tag2Value", + "Etag": "EtagValue", + "Label": "LabelValue" + } + } + }, + { + "id": "OffTelemetryTestFeature", + "enabled": false, + "telemetry": { + "enabled": true + } + } + ] + } +}; + diff --git a/test/targetingFilter.test.ts b/sdk/feature-management/test/targetingFilter.test.ts similarity index 98% rename from test/targetingFilter.test.ts rename to sdk/feature-management/test/targetingFilter.test.ts index ac33f88..c752f67 100644 --- a/test/targetingFilter.test.ts +++ b/sdk/feature-management/test/targetingFilter.test.ts @@ -1,146 +1,145 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../"; - -const complexTargetingFeature = { - "id": "ComplexTargeting", - "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Microsoft.Targeting", - "parameters": { - "Audience": { - "Users": [ - "Alice" - ], - "Groups": [ - { - "Name": "Stage1", - "RolloutPercentage": 100 - }, - { - "Name": "Stage2", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 25, - "Exclusion": { - "Users": ["Dave"], - "Groups": ["Stage3"] - } - } - } - } - ] - } -}; - -const createTargetingFeatureWithRolloutPercentage = (name: string, defaultRolloutPercentage: number, groups?: { Name: string, RolloutPercentage: number }[]) => { - const featureFlag = { - "id": name, - "description": "A feature flag using a targeting filter with invalid parameters.", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Microsoft.Targeting", - "parameters": { - "Audience": { - "DefaultRolloutPercentage": defaultRolloutPercentage - } - } - } - ] - } - }; - if (groups && groups.length > 0) { - (featureFlag.conditions.client_filters[0].parameters.Audience as any).Groups = groups; - } - return featureFlag; -}; - -describe("targeting filter", () => { - it("should validate parameters", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - createTargetingFeatureWithRolloutPercentage("InvalidTargeting1", -1), - createTargetingFeatureWithRolloutPercentage("InvalidTargeting2", 101), - // invalid group rollout percentage - createTargetingFeatureWithRolloutPercentage("InvalidTargeting3", 25, [{ Name: "Stage1", RolloutPercentage: -1 }]), - createTargetingFeatureWithRolloutPercentage("InvalidTargeting4", 25, [{ Name: "Stage1", RolloutPercentage: 101 }]), - ] - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return Promise.all([ - expect(featureManager.isEnabled("InvalidTargeting1", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting2", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting3", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting4", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), - ]); - }); - - it("should evaluate feature with targeting filter", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [complexTargetingFeature] - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return Promise.all([ - // default rollout 25% - // - "Aiden\nComplexTargeting": ~62.9% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"), - - // - "Blossom\nComplexTargeting": ~20.2% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"), - - // Stage1 group is 100% rollout - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"), - - // Stage2 group is 50% rollout - // - "\nComplexTargeting\nStage2": ~78.7% >= 50% (Stage2 is 50% rollout) - // - "\nComplexTargeting": ~38.9% >= 25% (default rollout percentage) - expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user is not in the 50% rollout of group Stage2"), - - // - "Aiden\nComplexTargeting\nStage2": ~15.6% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout of group Stage2"), - - // - "Chris\nComplexTargeting\nStage2": 55.3% >= 50% (Stage2 is 50% rollout) - // - "Chris\nComplexTargeting": 72.3% >= 25% (default rollout percentage) - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).eventually.eq(false, "Chris is not in the 50% rollout of group Stage2"), - - // exclusions - expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).eventually.eq(false, "Alice is excluded because she is part of Stage3 group"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).eventually.eq(false, "Blossom is excluded because she is part of Stage3 group"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), - ]); - }); - - it("should throw error if app context is not provided", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [complexTargetingFeature] - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter."); - }); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +const complexTargetingFeature = { + "id": "ComplexTargeting", + "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Alice" + ], + "Groups": [ + { + "Name": "Stage1", + "RolloutPercentage": 100 + }, + { + "Name": "Stage2", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 25, + "Exclusion": { + "Users": ["Dave"], + "Groups": ["Stage3"] + } + } + } + } + ] + } +}; + +const createTargetingFeatureWithRolloutPercentage = (name: string, defaultRolloutPercentage: number, groups?: { Name: string, RolloutPercentage: number }[]) => { + const featureFlag = { + "id": name, + "description": "A feature flag using a targeting filter with invalid parameters.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "DefaultRolloutPercentage": defaultRolloutPercentage + } + } + } + ] + } + }; + if (groups && groups.length > 0) { + (featureFlag.conditions.client_filters[0].parameters.Audience as any).Groups = groups; + } + return featureFlag; +}; + +describe("targeting filter", () => { + it("should validate parameters", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + createTargetingFeatureWithRolloutPercentage("InvalidTargeting1", -1), + createTargetingFeatureWithRolloutPercentage("InvalidTargeting2", 101), + // invalid group rollout percentage + createTargetingFeatureWithRolloutPercentage("InvalidTargeting3", 25, [{ Name: "Stage1", RolloutPercentage: -1 }]), + createTargetingFeatureWithRolloutPercentage("InvalidTargeting4", 25, [{ Name: "Stage1", RolloutPercentage: 101 }]), + ] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + expect(featureManager.isEnabled("InvalidTargeting1", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting2", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting3", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting4", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + ]); + }); + + it("should evaluate feature with targeting filter", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [complexTargetingFeature] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + // default rollout 25% + // - "Aiden\nComplexTargeting": ~62.9% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"), + + // - "Blossom\nComplexTargeting": ~20.2% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"), + + // Stage1 group is 100% rollout + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"), + + // Stage2 group is 50% rollout + // - "\nComplexTargeting\nStage2": ~78.7% >= 50% (Stage2 is 50% rollout) + // - "\nComplexTargeting": ~38.9% >= 25% (default rollout percentage) + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user is not in the 50% rollout of group Stage2"), + + // - "Aiden\nComplexTargeting\nStage2": ~15.6% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout of group Stage2"), + + // - "Chris\nComplexTargeting\nStage2": 55.3% >= 50% (Stage2 is 50% rollout) + // - "Chris\nComplexTargeting": 72.3% >= 25% (default rollout percentage) + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).eventually.eq(false, "Chris is not in the 50% rollout of group Stage2"), + + // exclusions + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).eventually.eq(false, "Alice is excluded because she is part of Stage3 group"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).eventually.eq(false, "Blossom is excluded because she is part of Stage3 group"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), + ]); + }); + + it("should throw error if app context is not provided", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [complexTargetingFeature] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter."); + }); +}); diff --git a/sdk/feature-management/test/variant.test.ts b/sdk/feature-management/test/variant.test.ts new file mode 100644 index 0000000..ddfd90f --- /dev/null +++ b/sdk/feature-management/test/variant.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; +import { Features, featureFlagsConfigurationObject } from "./sampleFeatureFlags.js"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +describe("feature variant", () => { + + let featureManager: FeatureManager; + + before(() => { + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); + featureManager = new FeatureManager(provider); + }); + + describe("valid scenarios", () => { + const context = { userId: "Marsha", groups: ["Group1"] }; + + it("default allocation with disabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultDisabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("default allocation with enabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultEnabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Medium"); + expect(variant?.configuration).deep.eq({ Size: "450px", Color: "Purple" }); + }); + + it("user allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureUser, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("group allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureGroup, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("percentile allocation with seed", async () => { + const variant = await featureManager.getVariant(Features.VariantFeaturePercentileOn, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Big"); + + const variant2 = await featureManager.getVariant(Features.VariantFeaturePercentileOff, context); + expect(variant2).to.be.undefined; + }); + + it("overwrite enabled status", async () => { + const enabledStatus = await featureManager.isEnabled(Features.VariantFeaturePercentileOn, context); + expect(enabledStatus).to.be.false; // featureFlag.enabled = true, overridden to false by variant `Big`. + }); + + }); + + describe("invalid scenarios", () => { + const context = { userId: "Jeff" }; + + it("return undefined when no variants are specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoVariants, context); + expect(variant).to.be.undefined; + }); + + it("return undefined when no allocation is specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoAllocation, context); + expect(variant).to.be.undefined; + }); + + it("only support configuration value", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureBothConfigurations, context); + expect(variant).not.to.be.undefined; + expect(variant?.configuration).eq("600px"); + }); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid StatusOverride value"); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid doubles From and To in the Percentile section"); + + }); + +}); diff --git a/sdk/feature-management/tsconfig.json b/sdk/feature-management/tsconfig.json new file mode 100644 index 0000000..d71e705 --- /dev/null +++ b/sdk/feature-management/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/tsconfig.test.json b/sdk/feature-management/tsconfig.test.json similarity index 73% rename from tsconfig.test.json rename to sdk/feature-management/tsconfig.test.json index 8a81b80..f717b9f 100644 --- a/tsconfig.test.json +++ b/sdk/feature-management/tsconfig.test.json @@ -1,10 +1,10 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "module": "CommonJS", - "outDir": "./out" - }, - "include": [ - "test/*" - ] +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./out" + }, + "include": [ + "test/*" + ] } \ No newline at end of file diff --git a/src/featureManager.ts b/src/featureManager.ts deleted file mode 100644 index 6c02faa..0000000 --- a/src/featureManager.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { TimeWindowFilter } from "./filter/TimeWindowFilter.js"; -import { IFeatureFilter } from "./filter/FeatureFilter.js"; -import { RequirementType } from "./schema/model.js"; -import { IFeatureFlagProvider } from "./featureProvider.js"; -import { TargetingFilter } from "./filter/TargetingFilter.js"; - -export class FeatureManager { - #provider: IFeatureFlagProvider; - #featureFilters: Map = new Map(); - - constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { - this.#provider = provider; - - const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; - - // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. - for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { - this.#featureFilters.set(filter.name, filter); - } - } - - async listFeatureNames(): Promise { - const features = await this.#provider.getFeatureFlags(); - const featureNameSet = new Set(features.map((feature) => feature.id)); - return Array.from(featureNameSet); - } - - // If multiple feature flags are found, the first one takes precedence. - async isEnabled(featureName: string, context?: unknown): Promise { - const featureFlag = await this.#provider.getFeatureFlag(featureName); - if (featureFlag === undefined) { - // If the feature is not found, then it is disabled. - return false; - } - - if (featureFlag.enabled !== true) { - // If the feature is not explicitly enabled, then it is disabled by default. - return false; - } - - const clientFilters = featureFlag.conditions?.client_filters; - if (clientFilters === undefined || clientFilters.length <= 0) { - // If there are no client filters, then the feature is enabled. - return true; - } - - const requirementType: RequirementType = featureFlag.conditions?.requirement_type ?? "Any"; // default to any. - - /** - * While iterating through the client filters, we short-circuit the evaluation based on the requirement type. - * - When requirement type is "All", the feature is enabled if all client filters are matched. If any client filter is not matched, the feature is disabled, otherwise it is enabled. `shortCircuitEvaluationResult` is false. - * - When requirement type is "Any", the feature is enabled if any client filter is matched. If any client filter is matched, the feature is enabled, otherwise it is disabled. `shortCircuitEvaluationResult` is true. - */ - const shortCircuitEvaluationResult: boolean = requirementType === "Any"; - - for (const clientFilter of clientFilters) { - const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); - const contextWithFeatureName = { featureName, parameters: clientFilter.parameters }; - if (matchedFeatureFilter === undefined) { - console.warn(`Feature filter ${clientFilter.name} is not found.`); - return false; - } - if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { - return shortCircuitEvaluationResult; - } - } - - // If we get here, then we have not found a client filter that matches the requirement type. - return !shortCircuitEvaluationResult; - } -} - -interface FeatureManagerOptions { - customFilters?: IFeatureFilter[]; -} - diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts deleted file mode 100644 index 406d846..0000000 --- a/src/filter/TargetingFilter.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { IFeatureFilter } from "./FeatureFilter.js"; - -type TargetingFilterParameters = { - Audience: { - DefaultRolloutPercentage: number; - Users?: string[]; - Groups?: { - Name: string; - RolloutPercentage: number; - }[]; - Exclusion?: { - Users?: string[]; - Groups?: string[]; - }; - } -} - -type TargetingFilterEvaluationContext = { - featureName: string; - parameters: TargetingFilterParameters; -} - -type TargetingFilterAppContext = { - userId?: string; - groups?: string[]; -} - -export class TargetingFilter implements IFeatureFilter { - name: string = "Microsoft.Targeting"; - - async evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): Promise { - const { featureName, parameters } = context; - TargetingFilter.#validateParameters(parameters); - - if (appContext === undefined) { - throw new Error("The app context is required for targeting filter."); - } - - if (parameters.Audience.Exclusion !== undefined) { - // check if the user is in the exclusion list - if (appContext?.userId !== undefined && - parameters.Audience.Exclusion.Users !== undefined && - parameters.Audience.Exclusion.Users.includes(appContext.userId)) { - return false; - } - // check if the user is in a group within exclusion list - if (appContext?.groups !== undefined && - parameters.Audience.Exclusion.Groups !== undefined) { - for (const excludedGroup of parameters.Audience.Exclusion.Groups) { - if (appContext.groups.includes(excludedGroup)) { - return false; - } - } - } - } - - // check if the user is being targeted directly - if (appContext?.userId !== undefined && - parameters.Audience.Users !== undefined && - parameters.Audience.Users.includes(appContext.userId)) { - return true; - } - - // check if the user is in a group that is being targeted - if (appContext?.groups !== undefined && - parameters.Audience.Groups !== undefined) { - for (const group of parameters.Audience.Groups) { - if (appContext.groups.includes(group.Name)) { - const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name); - const rolloutPercentage = group.RolloutPercentage; - if (await TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) { - return true; - } - } - } - } - - // check if the user is being targeted by a default rollout percentage - const defaultContextId = constructAudienceContextId(featureName, appContext?.userId); - return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage); - } - - static async #isTargeted(audienceContextId: string, rolloutPercentage: number): Promise { - if (rolloutPercentage === 100) { - return true; - } - // Cryptographic hashing algorithms ensure adequate entropy across hash values. - const contextMarker = await stringToUint32(audienceContextId); - const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; - return contextPercentage < rolloutPercentage; - } - - static #validateParameters(parameters: TargetingFilterParameters): void { - if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) { - throw new Error("Audience.DefaultRolloutPercentage must be a number between 0 and 100."); - } - // validate RolloutPercentage for each group - if (parameters.Audience.Groups !== undefined) { - for (const group of parameters.Audience.Groups) { - if (group.RolloutPercentage < 0 || group.RolloutPercentage > 100) { - throw new Error(`RolloutPercentage of group ${group.Name} must be a number between 0 and 100.`); - } - } - } - } -} - -/** - * Constructs the context id for the audience. - * The context id is used to determine if the user is part of the audience for a feature. - * If groupName is provided, the context id is constructed as follows: - * userId + "\n" + featureName + "\n" + groupName - * Otherwise, the context id is constructed as follows: - * userId + "\n" + featureName - * - * @param featureName name of the feature - * @param userId userId from app context - * @param groupName group name from app context - * @returns a string that represents the context id for the audience - */ -function constructAudienceContextId(featureName: string, userId: string | undefined, groupName?: string) { - let contextId = `${userId ?? ""}\n${featureName}`; - if (groupName !== undefined) { - contextId += `\n${groupName}`; - } - return contextId; -} - -async function stringToUint32(str: string): Promise { - 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; - } - } - - // In the browser, use crypto.subtle.digest - if (crypto.subtle) { - const data = new TextEncoder().encode(str); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const dataView = new DataView(hashBuffer); - const uint32 = dataView.getUint32(0, true); - return uint32; - } - // In Node.js, use the crypto module's hash function - else { - const hash = crypto.createHash("sha256").update(str).digest(); - const uint32 = hash.readUInt32LE(0); - return uint32; - } -} diff --git a/src/schema/model.ts b/src/schema/model.ts deleted file mode 100644 index c4ff360..0000000 --- a/src/schema/model.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -// Converted from: -// https://github.com/Azure/AppConfiguration/blob/6e544296a5607f922a423df165f60801717c7800/docs/FeatureManagement/FeatureFlag.v2.0.0.schema.json - -/** - * A feature flag is a named property that can be toggled to enable or disable some feature of an application. - */ -export interface FeatureFlag { - /** - * An ID used to uniquely identify and reference the feature. - */ - id: string; - /** - * A feature is OFF if enabled is false. If enabled is true, then the feature is ON if there are no conditions (null or empty) or if the conditions are satisfied. - */ - enabled?: boolean; - /** - * The declaration of conditions used to dynamically enable the feature. - */ - conditions?: FeatureEnablementConditions; - /** - * The list of variants defined for this feature. A variant represents a configuration value of a feature flag that can be a string, a number, a boolean, or a JSON object. - */ - variants?: Variant[]; - /** - * Determines how variants should be allocated for the feature to various users. - */ - allocation?: VariantAllocation; - /** - * The declaration of options used to configure telemetry for this feature. - */ - telemetry?: TelemetryOptions -} - -/** -* The declaration of conditions used to dynamically enable the feature -*/ -interface FeatureEnablementConditions { - /** - * Determines whether any or all registered client filters must be evaluated as true for the feature to be considered enabled. - */ - requirement_type?: RequirementType; - /** - * Filters that must run on the client and be evaluated as true for the feature to be considered enabled. - */ - client_filters?: ClientFilter[]; -} - -export type RequirementType = "Any" | "All"; - -interface ClientFilter { - /** - * The name used to refer to a client filter. - */ - name: string; - /** - * Parameters for a given client filter. A client filter can require any set of parameters of any type. - */ - parameters?: Record; -} - -interface Variant { - /** - * The name used to refer to a feature variant. - */ - name: string; - /** - * The configuration value for this feature variant. - */ - configuration_value?: unknown; - /** - * Overrides the enabled state of the feature if the given variant is assigned. Does not override the state if value is None. - */ - status_override?: "None" | "Enabled" | "Disabled"; -} - -/** -* Determines how variants should be allocated for the feature to various users. -*/ -interface VariantAllocation { - /** - * Specifies which variant should be used when the feature is considered disabled. - */ - default_when_disabled?: string; - /** - * Specifies which variant should be used when the feature is considered enabled and no other allocation rules are applicable. - */ - default_when_enabled?: string; - /** - * A list of objects, each containing a variant name and list of users for whom that variant should be used. - */ - user?: UserAllocation[]; - /** - * A list of objects, each containing a variant name and list of groups for which that variant should be used. - */ - group?: GroupAllocation[]; - /** - * A list of objects, each containing a variant name and percentage range for which that variant should be used. - */ - percentile?: PercentileAllocation[] - /** - * The value percentile calculations are based on. The calculated percentile is consistent across features for a given user if the same nonempty seed is used. - */ - seed?: string; -} - -interface UserAllocation { - /** - * The name of the variant to use if the user allocation matches the current user. - */ - variant: string; - /** - * Collection of users where if any match the current user, the variant specified in the user allocation is used. - */ - users: string[]; -} - -interface GroupAllocation { - /** - * The name of the variant to use if the group allocation matches a group the current user is in. - */ - variant: string; - /** - * Collection of groups where if the current user is in any of these groups, the variant specified in the group allocation is used. - */ - groups: string[]; -} - -interface PercentileAllocation { - /** - * The name of the variant to use if the calculated percentile for the current user falls in the provided range. - */ - variant: string; - /** - * The lower end of the percentage range for which this variant will be used. - */ - from: number; - /** - * The upper end of the percentage range for which this variant will be used. - */ - to: number; -} - -/** -* The declaration of options used to configure telemetry for this feature. -*/ -interface TelemetryOptions { - /** - * Indicates if telemetry is enabled. - */ - enabled?: boolean; - /** - * A container for metadata that should be bundled with flag telemetry. - */ - metadata?: Record; -} - -// Feature Management Section fed into feature manager. -// Converted from https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v1.0.0.schema.json - -export const FEATURE_MANAGEMENT_KEY = "feature_management"; -export const FEATURE_FLAGS_KEY = "feature_flags"; - -export interface FeatureManagementConfiguration { - feature_management: FeatureManagement -} - -/** - * Declares feature management configuration. - */ -export interface FeatureManagement { - feature_flags: FeatureFlag[]; -}