diff --git a/.gitignore b/.gitignore index 250bd47..e8b8fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -413,3 +413,5 @@ examples/**/**/package-lock.json # playwright test result test-results + +**/public \ No newline at end of file diff --git a/examples/express-app/README.md b/examples/express-app/README.md new file mode 100644 index 0000000..a8ba572 --- /dev/null +++ b/examples/express-app/README.md @@ -0,0 +1,76 @@ +# Examples for Microsoft Feature Management for JavaScript + +These examples show how to use the Microsoft Feature Management in an express application. + +## Prerequisites + +The examples are compatible with [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule). + +## Setup & Run + +1. Install the dependencies using `npm`: + + ```bash + npm install + ``` + +1. Run the examples: + + ```bash + node server.mjs + ``` + +1. Visit `http://localhost:3000/Beta` and use `userId` and `groups` query to specify the targeting context (e.g. /Beta?userId=Jeff or /Beta?groups=Admin). + + - If you are not targeted, you will get the message "Page not found". + + - If you are targeted, you will get the message "Welcome to the Beta page!". + +## Targeting + +The targeting mechanism uses the `exampleTargetingContextAccessor` to extract the targeting context from the request. This function retrieves the userId and groups from the query parameters of the request. + +```javascript +const exampleTargetingContextAccessor = { + getTargetingContext: () => { + const req = requestAccessor.getStore(); + if (req === undefined) { + return undefined; + } + // read user and groups from request query data + const { userId, groups } = req.query; + // return aa ITargetingContext with the appropriate user info + return { userId: userId, groups: groups ? groups.split(",") : [] }; + } +}; +``` + +The `FeatureManager` is configured with this targeting context accessor: + +```javascript +const featureManager = new FeatureManager( + featureProvider, + { + targetingContextAccessor: exampleTargetingContextAccessor + } +); +``` + +This allows you to get ambient targeting context while doing feature flag evaluation. + +### Request Accessor + +The `requestAccessor` is an instance of `AsyncLocalStorage` from the `async_hooks` module. It is used to store the request object in asynchronous local storage, allowing it to be accessed throughout the lifetime of the request. This is particularly useful for accessing request-specific data in asynchronous operations. For more information, please go to https://nodejs.org/api/async_context.html + +```javascript +import { AsyncLocalStorage } from "async_hooks"; +const requestAccessor = new AsyncLocalStorage(); +``` + +Middleware is used to store the request object in the AsyncLocalStorage: + +```javascript +server.use((req, res, next) => { + requestAccessor.run(req, next); +}); +``` \ No newline at end of file diff --git a/examples/express-app/config.json b/examples/express-app/config.json new file mode 100644 index 0000000..f085efe --- /dev/null +++ b/examples/express-app/config.json @@ -0,0 +1,31 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "Beta", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff" + ], + "Groups": [ + { + "Name": "Admin", + "RolloutPercentage": 100 + } + ], + "DefaultRolloutPercentage": 40 + } + } + } + ] + } + } + ] + } +} diff --git a/examples/express-app/package.json b/examples/express-app/package.json new file mode 100644 index 0000000..e1086a2 --- /dev/null +++ b/examples/express-app/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "start": "node server.mjs" + }, + "dependencies": { + "@microsoft/feature-management": "2.1.0-preview.1", + "express": "^4.21.2" + } +} \ No newline at end of file diff --git a/examples/express-app/server.mjs b/examples/express-app/server.mjs new file mode 100644 index 0000000..0404b33 --- /dev/null +++ b/examples/express-app/server.mjs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import fs from "fs/promises"; +import { ConfigurationObjectFeatureFlagProvider, FeatureManager } from "@microsoft/feature-management"; +// You can also use Azure App Configuration as the source of feature flags. +// For more information, please go to quickstart: https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-javascript + +const config = JSON.parse(await fs.readFile("config.json")); +const featureProvider = new ConfigurationObjectFeatureFlagProvider(config); + +// https://nodejs.org/api/async_context.html +import { AsyncLocalStorage } from "async_hooks"; +const requestAccessor = new AsyncLocalStorage(); +const exampleTargetingContextAccessor = { + getTargetingContext: () => { + const req = requestAccessor.getStore(); + if (req === undefined) { + return undefined; + } + // read user and groups from request query data + const { userId, groups } = req.query; + // return an ITargetingContext with the appropriate user info + return { userId: userId, groups: groups ? groups.split(",") : [] }; + } +}; + +const featureManager = new FeatureManager( + featureProvider, + { + targetingContextAccessor: exampleTargetingContextAccessor + } +); + +import express from "express"; +const server = express(); +const PORT = 3000; + +// Use a middleware to store the request object in async local storage. +// The async local storage allows the targeting context accessor to access the current request throughout its lifetime. +// Middleware 1 (request object is stored in async local storage here and it will be available across the following chained async operations) +// Middleware 2 +// Request Handler (feature flag evaluation happens here) +server.use((req, res, next) => { + requestAccessor.run(req, next); +}); + +server.get("/", (req, res) => { + res.send("Hello World!"); +}); + +server.get("/Beta", async (req, res) => { + if (await featureManager.isEnabled("Beta")) { + res.send("Welcome to the Beta page!"); + } else { + res.status(404).send("Page not found"); + } +}); + +// Start the server +server.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/examples/quote-of-the-day/.env.temlate b/examples/quote-of-the-day/.env.temlate new file mode 100644 index 0000000..2f14536 --- /dev/null +++ b/examples/quote-of-the-day/.env.temlate @@ -0,0 +1,6 @@ +# You can define environment variables in .env file and load them with 'dotenv' package. +# This is a template of related environment variables in examples. +# To use this file directly, please rename it to .env +APPCONFIG_CONNECTION_STRING= +APPLICATIONINSIGHTS_CONNECTION_STRING= +USE_APP_CONFIG=true \ No newline at end of file diff --git a/examples/quote-of-the-day/README.md b/examples/quote-of-the-day/README.md new file mode 100644 index 0000000..9cdef0a --- /dev/null +++ b/examples/quote-of-the-day/README.md @@ -0,0 +1,155 @@ +# Quote of the day - JavaScript + +These examples show how to use the Microsoft Feature Management in an express application. + +## Setup & Run + +1. Build the project. + +```cmd +npm run build +``` + +1. Start the application. + +```cmd +npm run start +``` + +## Telemetry + +The Quote of the Day example implements telemetry using Azure Application Insights to track feature flag evaluations. This helps monitor and analyze how feature flags are being used in your application. + +### Application Insights Integration + +The application uses the `@microsoft/feature-management-applicationinsights-node` package to integrate Feature Management with Application Insights: + +```javascript +const { createTelemetryPublisher } = require("@microsoft/feature-management-applicationinsights-node"); + +// When initializing Feature Management +const publishTelemetry = createTelemetryPublisher(appInsightsClient); +featureManager = new FeatureManager(featureFlagProvider, { + onFeatureEvaluated: publishTelemetry, + targetingContextAccessor: targetingContextAccessor +}); +``` + +The `onFeatureEvaluated` option registers a callback that automatically sends telemetry events to Application Insights whenever a feature flag is evaluated. + +### Targeting Context in Telemetry + +`createTargetingTelemetryProcessor` method creates a built-in Application Insights telemetry processor which gets targeting context from the targeting context accessor and attaches the targeting id to telemetry. + +```javascript +// Initialize Application Insights with targeting context +applicationInsights.defaultClient.addTelemetryProcessor( + createTargetingTelemetryProcessor(targetingContextAccessor) +); +``` + +This ensures that every telemetry sent to Application Insights includes the targeting id information, allowing you to correlate feature flag usage with specific users or groups in your analytics. + +### Experimentation and A/B Testing + +Telemetry is particularly valuable for running experiments like A/B tests. Here's how you can use telemetry to track whether different variants of a feature influence user behavior. + +In this example, a variant feature flag is used to track the like button click rate of a web application: + +```json +{ + "id": "Greeting", + "enabled": true, + "variants": [ + { + "name": "Default" + }, + { + "name": "Simple", + "configuration_value": "Hello!" + }, + { + "name": "Long", + "configuration_value": "I hope this makes your day!" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Default", + "from": 0, + "to": 50 + }, + { + "variant": "Simple", + "from": 50, + "to": 75 + }, + { + "variant": "Long", + "from": 75, + "to": 100 + } + ], + "default_when_enabled": "Default", + "default_when_disabled": "Default" + }, + "telemetry": { + "enabled": true + } +} +``` + +## Targeting + +The targeting mechanism uses the `exampleTargetingContextAccessor` to extract the targeting context from the request. This function retrieves the userId and groups from the query parameters of the request. + +```javascript +const targetingContextAccessor = { + getTargetingContext: () => { + const req = requestAccessor.getStore(); + if (req === undefined) { + return undefined; + } + // read user and groups from request + const userId = req.query.userId ?? req.body.userId; + const groups = req.query.groups ?? req.body.groups; + // return an ITargetingContext with the appropriate user info + return { userId: userId, groups: groups ? groups.split(",") : [] }; + } +}; +``` + +The `FeatureManager` is configured with this targeting context accessor: + +```javascript +const featureManager = new FeatureManager( + featureProvider, + { + targetingContextAccessor: exampleTargetingContextAccessor + } +); +``` + +This allows you to get ambient targeting context while doing feature flag evaluation and variant allocation. + +### Request Accessor + +The `requestAccessor` is an instance of `AsyncLocalStorage` from the `async_hooks` module. It is used to store the request object in asynchronous local storage, allowing it to be accessed throughout the lifetime of the request. This is particularly useful for accessing request-specific data in asynchronous operations. For more information, please go to https://nodejs.org/api/async_context.html + +```javascript +import { AsyncLocalStorage } from "async_hooks"; +const requestAccessor = new AsyncLocalStorage(); +``` + +Middleware is used to store the request object in the AsyncLocalStorage: + +```javascript +const requestStorageMiddleware = (req, res, next) => { + requestAccessor.run(req, next); +}; + +... + +server.use(requestStorageMiddleware); +``` diff --git a/examples/quote-of-the-day/client/index.html b/examples/quote-of-the-day/client/index.html new file mode 100644 index 0000000..149e009 --- /dev/null +++ b/examples/quote-of-the-day/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Quote of the Day + + +
+ + + diff --git a/examples/quote-of-the-day/client/package.json b/examples/quote-of-the-day/client/package.json new file mode 100644 index 0000000..e3dabf3 --- /dev/null +++ b/examples/quote-of-the-day/client/package.json @@ -0,0 +1,17 @@ +{ + "name": "quoteoftheday", + "type": "module", + "scripts": { + "build": "vite build --emptyOutDir" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "react-icons": "5.3.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.1" + } +} diff --git a/examples/quote-of-the-day/client/src/App.css b/examples/quote-of-the-day/client/src/App.css new file mode 100644 index 0000000..0df6d08 --- /dev/null +++ b/examples/quote-of-the-day/client/src/App.css @@ -0,0 +1,198 @@ +body { + margin: 0; + font-family: 'Georgia', serif; +} + +.quote-page { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #f4f4f4; +} + +.navbar { + background-color: white; + border-bottom: 1px solid #eaeaea; + display: flex; + justify-content: space-between; + padding: 10px 20px; + align-items: center; + font-family: 'Arial', sans-serif; + font-size: 16px; +} + +.navbar-left { + display: flex; + align-items: center; + margin-left: 40px; +} + +.logo { + font-size: 1.25em; + text-decoration: none; + color: black; + margin-right: 20px; +} + +.navbar-left nav a { + margin-right: 20px; + text-decoration: none; + color: black; + font-weight: 500; + font-family: 'Arial', sans-serif; +} + +.navbar-right a { + margin-left: 20px; + text-decoration: none; + color: black; + font-weight: 500; + font-family: 'Arial', sans-serif; +} + +.quote-container { + display: flex; + justify-content: center; + align-items: center; + flex-grow: 1; +} + +.quote-card { + background-color: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + max-width: 700px; + position: relative; + text-align: left; +} + +.quote-card h2 { + font-weight: normal; +} + +.quote-card blockquote { + font-size: 2em; + font-family: 'Georgia', serif; + font-style: italic; + color: #4EC2F7; + margin: 0 0 20px 0; + line-height: 1.4; + text-align: left; +} + +.quote-card footer { + font-size: 0.55em; + color: black; + font-family: 'Arial', sans-serif; + font-style: normal; + text-align: left; + font-weight: bold; +} + +.vote-container { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 0em; +} + +.heart-button { + background-color: transparent; + border: none; + cursor: pointer; + padding: 5px; + font-size: 24px; +} + +.heart-button:hover { + background-color: #F0F0F0; +} + +.heart-button:focus { + outline: none; + box-shadow: none; +} + +footer { + background-color: white; + padding-top: 10px; + text-align: center; + border-top: 1px solid #eaeaea; +} + +footer a { + color: #4EC2F7; + text-decoration: none; +} + +.register-login-card { + width: 300px; + margin: 50px auto; + padding: 20px; + border-radius: 8px; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); + background-color: #ffffff; + text-align: center; +} + +h2 { + margin-bottom: 20px; + color: #333; +} + +.input-container { + margin-bottom: 15px; + text-align: left; + width: 100%; /* Ensure the container takes the full width */ +} + +label { + display: block; + margin-bottom: 5px; + font-size: 14px; + color: #555; +} + +input { + width: calc(100%); /* Add padding for both left and right */ + padding: 10px; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 14px; + box-sizing: border-box; /* Ensure padding doesn't affect the width */ +} + +input:focus { + outline: none; + border-color: #007bff; +} + +.register-login-button { + width: 100%; + padding: 10px; + background-color: #007bff; + border: none; + border-radius: 4px; + color: white; + font-size: 16px; + cursor: pointer; + margin-top: 10px; +} + +.register-login-button:hover { + background-color: #0056b3; +} + +.error-message { + color: red; +} + +.logout-btn { + margin-left: 20px; + background-color: transparent; + border: none; + cursor: pointer; + font-size: 16px; +} \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/App.jsx b/examples/quote-of-the-day/client/src/App.jsx new file mode 100644 index 0000000..36b34d6 --- /dev/null +++ b/examples/quote-of-the-day/client/src/App.jsx @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; +import { ContextProvider } from "./pages/AppContext"; +import Layout from "./Layout"; +import Home from "./pages/Home"; +import Privacy from "./pages/Privacy"; +import Register from "./pages/Register"; +import Login from "./pages/Login"; + + +function App() { + return ( + + + + + } /> + } /> + } /> + } /> + + + + + ); +} + +export default App; diff --git a/examples/quote-of-the-day/client/src/Layout.jsx b/examples/quote-of-the-day/client/src/Layout.jsx new file mode 100644 index 0000000..f1f594a --- /dev/null +++ b/examples/quote-of-the-day/client/src/Layout.jsx @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { useContext } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { AppContext } from "./pages/AppContext"; + +const Layout = ({ children }) => { + const { currentUser, logoutUser } = useContext(AppContext); + const navigate = useNavigate(); + + const handleLogout = () => { + logoutUser(); + navigate("/"); + }; + + return ( +
+
+
+ QuoteOfTheDay + +
+
+ {currentUser ? + ( + <> + Hello, {currentUser}! + + + ) : + ( + <> + Register + Login + + ) + } +
+
+ +
+ {children} +
+ +
+

© 2024 - QuoteOfTheDay - Privacy

+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/index.jsx b/examples/quote-of-the-day/client/src/index.jsx new file mode 100644 index 0000000..dc1ea2f --- /dev/null +++ b/examples/quote-of-the-day/client/src/index.jsx @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./App.css"; + +window.addEventListener("beforeunload", (event) => { + // clear the localStorage when the user leaves the page + localStorage.clear() +}); + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render( + + + +); \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/pages/AppContext.jsx b/examples/quote-of-the-day/client/src/pages/AppContext.jsx new file mode 100644 index 0000000..de6be88 --- /dev/null +++ b/examples/quote-of-the-day/client/src/pages/AppContext.jsx @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { createContext, useState } from "react"; + +export const AppContext = createContext(); + +export const ContextProvider = ({ children }) => { + const [currentUser, setCurrentUser] = useState(undefined); + + + const loginUser = (user) => { + setCurrentUser(user); + }; + + const logoutUser = () => { + setCurrentUser(undefined); + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/pages/Home.jsx b/examples/quote-of-the-day/client/src/pages/Home.jsx new file mode 100644 index 0000000..28b09ec --- /dev/null +++ b/examples/quote-of-the-day/client/src/pages/Home.jsx @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { useState, useEffect, useContext } from "react"; +import { FaHeart, FaRegHeart } from "react-icons/fa"; +import { AppContext } from "./AppContext"; + +function Home() { + const { featureManager, currentUser } = useContext(AppContext); + const [liked, setLiked] = useState(false); + const [message, setMessage] = useState(undefined); + + useEffect(() => { + const init = async () => { + const response = await fetch( + `/api/getGreetingMessage?userId=${currentUser ?? ""}`, + { + method: "GET", + } + ); + if (response.ok) { + const result = await response.json(); + setMessage(result.message ?? "Quote of the Day"); // default message is "Quote of the Day" + } else { + console.error("Failed to get greeting message."); + } + setLiked(false); + }; + + init(); + }, [featureManager, currentUser]); + + const handleClick = async () => { + if (!liked) { + try { + const response = await fetch("/api/like", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ userId: currentUser ?? "" }), + }); + + if (response.ok) { + console.log("Like the quote successfully."); + } else { + console.error("Failed to like the quote."); + } + } catch (error) { + console.error("Error:", error); + } + } + setLiked(!liked); + }; + + return ( +
+ { message != undefined ? + ( + <> +

+ <>{message} +

+
+

"You cannot change what you are, only what you do."

+
— Philip Pullman
+
+
+ +
+ + ) + :

Loading

+ } +
+ ); +} + +export default Home; \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/pages/Login.jsx b/examples/quote-of-the-day/client/src/pages/Login.jsx new file mode 100644 index 0000000..e099abb --- /dev/null +++ b/examples/quote-of-the-day/client/src/pages/Login.jsx @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { useState, useContext } from "react"; +import { useNavigate } from "react-router-dom"; + +import { AppContext } from "./AppContext"; + +const Login = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + + const { loginUser } = useContext(AppContext); + const navigate = useNavigate(); + + const handleLogin = (e) => { + + e.preventDefault(); + + // Retrieve user from localStorage + const users = JSON.parse(localStorage.getItem("users")) || []; + const user = users.find((user) => user.username === username && user.password === password); + + if (user) { + loginUser(username); + navigate("/"); + } + else { + setMessage("Invalid username or password!"); + } + }; + + return ( +
+

Login

+
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+

{message}

+
+
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/pages/Privacy.jsx b/examples/quote-of-the-day/client/src/pages/Privacy.jsx new file mode 100644 index 0000000..e1b573a --- /dev/null +++ b/examples/quote-of-the-day/client/src/pages/Privacy.jsx @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; + +const Privacy = () => { + return

Use this page to detail your site's privacy policy.

; + }; + +export default Privacy; \ No newline at end of file diff --git a/examples/quote-of-the-day/client/src/pages/Register.jsx b/examples/quote-of-the-day/client/src/pages/Register.jsx new file mode 100644 index 0000000..f585366 --- /dev/null +++ b/examples/quote-of-the-day/client/src/pages/Register.jsx @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React from "react"; +import { useState, useContext } from "react"; +import { useNavigate } from "react-router-dom"; + +import { AppContext } from "./AppContext"; + +const Register = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + + const { loginUser } = useContext(AppContext); + const navigate = useNavigate(); + + const handleRegister = (e) => { + + e.preventDefault(); + + const users = JSON.parse(localStorage.getItem("users")) || []; + const existingUser = users.some((user) => (user.username === username)); + + if (existingUser) { + setMessage("User already exists!"); + } + else { + users.push({ username, password }); + localStorage.setItem("users", JSON.stringify(users)); + loginUser(username); + navigate("/"); + } + }; + + return ( +
+

Register

+
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+

{message}

+
+
+ ); +}; + +export default Register; \ No newline at end of file diff --git a/examples/quote-of-the-day/client/vite.config.js b/examples/quote-of-the-day/client/vite.config.js new file mode 100644 index 0000000..6c4a25b --- /dev/null +++ b/examples/quote-of-the-day/client/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: "../public", + } +}) diff --git a/examples/quote-of-the-day/config.js b/examples/quote-of-the-day/config.js new file mode 100644 index 0000000..9cb0554 --- /dev/null +++ b/examples/quote-of-the-day/config.js @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +require("dotenv").config(); + +// Export configuration variables +module.exports = { + appConfigConnectionString: process.env.APPCONFIG_CONNECTION_STRING, + appInsightsConnectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING, + port: process.env.PORT || "8080" +}; \ No newline at end of file diff --git a/examples/quote-of-the-day/featureManagement.js b/examples/quote-of-the-day/featureManagement.js new file mode 100644 index 0000000..969cb03 --- /dev/null +++ b/examples/quote-of-the-day/featureManagement.js @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const { load } = require("@azure/app-configuration-provider"); +const { FeatureManager, ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider } = require("@microsoft/feature-management"); +const { createTelemetryPublisher } = require("@microsoft/feature-management-applicationinsights-node"); +const config = require("./config"); + +// Variables to hold the AppConfig and FeatureManager instances +let appConfig; +let featureManager; + +// Initialize AppConfig and FeatureManager +async function initializeFeatureManagement(appInsightsClient, targetingContextAccessor) { + console.log("Loading configuration..."); + appConfig = await load(config.appConfigConnectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "*" + } + ], + refresh: { + enabled: true, + refreshIntervalInMs: 10_000 + } + } + }); + const featureFlagProvider = new ConfigurationMapFeatureFlagProvider(appConfig); + + // You can also alternatively use local feature flag source. + // const fs = require('fs/promises'); + // const localFeatureFlags = JSON.parse(await fs.readFile("localFeatureFlags.json")); + // const featureFlagProvider = new ConfigurationObjectFeatureFlagProvider(localFeatureFlags); + + const publishTelemetry = createTelemetryPublisher(appInsightsClient); + featureManager = new FeatureManager(featureFlagProvider, { + onFeatureEvaluated: publishTelemetry, + targetingContextAccessor: targetingContextAccessor + }); + + return { featureManager, appConfig }; +} + +// Middleware to refresh configuration before each request +const featureFlagRefreshMiddleware = (req, res, next) => { + // The configuration refresh happens asynchronously to the processing of your app's incoming requests. + // It will not block or slow down the incoming request that triggered the refresh. + // The request that triggered the refresh may not get the updated configuration values, but later requests will get new configuration values. + appConfig?.refresh(); // intended to not await the refresh + next(); +}; + +module.exports = { + initializeFeatureManagement, + featureFlagRefreshMiddleware +}; \ No newline at end of file diff --git a/examples/quote-of-the-day/localFeatureFlags.json b/examples/quote-of-the-day/localFeatureFlags.json new file mode 100644 index 0000000..a89c000 --- /dev/null +++ b/examples/quote-of-the-day/localFeatureFlags.json @@ -0,0 +1,47 @@ +{ + "feature_management": { + "feature_flags": [ + { + "id": "Greeting", + "enabled": true, + "variants": [ + { + "name": "Default" + }, + { + "name": "Simple", + "configuration_value": "Hello!" + }, + { + "name": "Long", + "configuration_value": "I hope this makes your day!" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Default", + "from": 0, + "to": 50 + }, + { + "variant": "Simple", + "from": 50, + "to": 75 + }, + { + "variant": "Long", + "from": 75, + "to": 100 + } + ], + "default_when_enabled": "Default", + "default_when_disabled": "Default" + }, + "telemetry": { + "enabled": true + } + } + ] + } +} \ No newline at end of file diff --git a/examples/quote-of-the-day/package.json b/examples/quote-of-the-day/package.json new file mode 100644 index 0000000..da47d1c --- /dev/null +++ b/examples/quote-of-the-day/package.json @@ -0,0 +1,16 @@ +{ + "name": "quoteoftheday", + "scripts": { + "build-client": "cd client && npm install && npm run build", + "build": "npm install && npm run build-client", + "start": "node server.js" + }, + "dependencies": { + "@azure/app-configuration-provider": "latest", + "@microsoft/feature-management": "2.1.0-preview.1", + "@microsoft/feature-management-applicationinsights-node": "2.1.0-preview.1", + "applicationinsights": "^2.9.6", + "dotenv": "^16.5.0", + "express": "^4.19.2" + } +} diff --git a/examples/quote-of-the-day/routes.js b/examples/quote-of-the-day/routes.js new file mode 100644 index 0000000..a3a1186 --- /dev/null +++ b/examples/quote-of-the-day/routes.js @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const express = require("express"); +const router = express.Router(); + +// Initialize routes with dependencies +function initializeRoutes(featureManager, appInsightsClient) { + // API route to get greeting message with feature variants + router.get("/api/getGreetingMessage", async (req, res) => { + const variant = await featureManager.getVariant("Greeting"); + res.status(200).send({ + message: variant?.configuration + }); + }); + + // API route to track likes + router.post("/api/like", (req, res) => { + const { userId } = req.body; + if (userId === undefined) { + return res.status(400).send({ error: "UserId is required" }); + } + appInsightsClient.trackEvent({ name: "Like" }); + res.status(200).send({ message: "Like event logged successfully" }); + }); + + return router; +} + +module.exports = { initializeRoutes }; \ No newline at end of file diff --git a/examples/quote-of-the-day/server.js b/examples/quote-of-the-day/server.js new file mode 100644 index 0000000..e294854 --- /dev/null +++ b/examples/quote-of-the-day/server.js @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const config = require("./config"); + +const express = require("express"); +const { targetingContextAccessor, requestStorageMiddleware } = require("./targetingContextAccessor"); +const { initializeAppInsights } = require("./telemetry"); +const { initializeFeatureManagement, featureFlagRefreshMiddleware } = require("./featureManagement"); +const { initializeRoutes } = require("./routes"); + +// Initialize Express server +const server = express(); + +// Initialize Application Insights +const appInsights = initializeAppInsights(targetingContextAccessor); + +// Global variables to store feature manager and app config +let featureManager; + +// Initialize the configuration and start the server +async function startApp() { + try { + // Initialize AppConfig and FeatureManager + const result = await initializeFeatureManagement( + appInsights.defaultClient, + targetingContextAccessor + ); + featureManager = result.featureManager; + + console.log("Configuration loaded. Starting server..."); + + // Set up middleware + server.use(requestStorageMiddleware); + server.use(featureFlagRefreshMiddleware); + server.use(express.json()); + server.use(express.static("public")); + + // Set up routes + const routes = initializeRoutes(featureManager, appInsights.defaultClient); + server.use(routes); + + // Start the server + server.listen(config.port, () => { + console.log(`Server is running at http://localhost:${config.port}`); + }); + } catch (error) { + console.error("Failed to load configuration:", error); + process.exit(1); + } +} + +// Start the application +startApp(); diff --git a/examples/quote-of-the-day/targetingContextAccessor.js b/examples/quote-of-the-day/targetingContextAccessor.js new file mode 100644 index 0000000..a9b8868 --- /dev/null +++ b/examples/quote-of-the-day/targetingContextAccessor.js @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const { AsyncLocalStorage } = require("async_hooks"); + +// Create AsyncLocalStorage for request access across async operations +const requestAccessor = new AsyncLocalStorage(); + +// Create targeting context accessor to get user information for feature targeting +const targetingContextAccessor = { + getTargetingContext: () => { + const req = requestAccessor.getStore(); + if (req === undefined) { + return undefined; + } + // read user and groups from request + const userId = req.query.userId ?? req.body.userId; + const groups = req.query.groups ?? req.body.groups; + // return an ITargetingContext with the appropriate user info + return { userId: userId, groups: groups ? groups.split(",") : [] }; + } +}; + +// Create middleware to store request in AsyncLocalStorage +const requestStorageMiddleware = (req, res, next) => { + requestAccessor.run(req, next); +}; + +module.exports = { + targetingContextAccessor, + requestStorageMiddleware +}; \ No newline at end of file diff --git a/examples/quote-of-the-day/telemetry.js b/examples/quote-of-the-day/telemetry.js new file mode 100644 index 0000000..80bb2da --- /dev/null +++ b/examples/quote-of-the-day/telemetry.js @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const config = require('./config'); +const applicationInsights = require("applicationinsights"); +const { createTargetingTelemetryProcessor } = require("@microsoft/feature-management-applicationinsights-node"); + +// Initialize Application Insights +const initializeAppInsights = (targetingContextAccessor) => { + applicationInsights.setup(config.appInsightsConnectionString).start(); + + // Use the targeting telemetry processor to attach targeting id to the telemetry data sent to Application Insights. + applicationInsights.defaultClient.addTelemetryProcessor( + createTargetingTelemetryProcessor(targetingContextAccessor) + ); + + return applicationInsights; +}; + +module.exports = { + initializeAppInsights +}; \ No newline at end of file diff --git a/src/feature-management-applicationinsights-browser/package.json b/src/feature-management-applicationinsights-browser/package.json index 9bab599..01e6a00 100644 --- a/src/feature-management-applicationinsights-browser/package.json +++ b/src/feature-management-applicationinsights-browser/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/feature-management-applicationinsights-browser", - "version": "2.0.2", + "version": "2.1.0-preview.1", "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/esm/index.js", "module": "./dist/esm/index.js", @@ -26,7 +26,7 @@ }, "license": "MIT", "publishConfig": { - "tag": "latest" + "tag": "preview" }, "bugs": { "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" @@ -46,7 +46,7 @@ }, "dependencies": { "@microsoft/applicationinsights-web": "^3.3.2", - "@microsoft/feature-management": "2.0.2" + "@microsoft/feature-management": "2.1.0-preview.1" } } \ No newline at end of file diff --git a/src/feature-management-applicationinsights-browser/src/index.ts b/src/feature-management-applicationinsights-browser/src/index.ts index 6b53335..efbc293 100644 --- a/src/feature-management-applicationinsights-browser/src/index.ts +++ b/src/feature-management-applicationinsights-browser/src/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { createTelemetryPublisher, trackEvent } from "./telemetry.js"; +export { createTargetingTelemetryInitializer, createTelemetryPublisher, trackEvent } from "./telemetry.js"; export { VERSION } from "./version.js"; diff --git a/src/feature-management-applicationinsights-browser/src/telemetry.ts b/src/feature-management-applicationinsights-browser/src/telemetry.ts index 6877c0f..3b9cfad 100644 --- a/src/feature-management-applicationinsights-browser/src/telemetry.ts +++ b/src/feature-management-applicationinsights-browser/src/telemetry.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { EvaluationResult, createFeatureEvaluationEventProperties } from "@microsoft/feature-management"; -import { ApplicationInsights, IEventTelemetry } from "@microsoft/applicationinsights-web"; +import { EvaluationResult, createFeatureEvaluationEventProperties, ITargetingContextAccessor } from "@microsoft/feature-management"; +import { ApplicationInsights, IEventTelemetry, ITelemetryItem } from "@microsoft/applicationinsights-web"; const TARGETING_ID = "TargetingId"; const FEATURE_EVALUATION_EVENT_NAME = "FeatureEvaluation"; @@ -39,3 +39,20 @@ export function trackEvent(client: ApplicationInsights, targetingId: string, eve properties[TARGETING_ID] = targetingId ? targetingId.toString() : ""; client.trackEvent(event, properties); } + +/** + * Creates a telemetry initializer that adds targeting id to telemetry item's custom properties. + * @param targetingContextAccessor The accessor function to get the targeting context. + * @returns A telemetry initializer that attaches targeting id to telemetry items. + */ +export function createTargetingTelemetryInitializer(targetingContextAccessor: ITargetingContextAccessor): (item: ITelemetryItem) => void { + return (item: ITelemetryItem) => { + const targetingContext = targetingContextAccessor.getTargetingContext(); + if (targetingContext !== undefined) { + if (targetingContext?.userId === undefined) { + console.warn("Targeting id is undefined."); + } + item.data = {...item.data, [TARGETING_ID]: targetingContext?.userId || ""}; + } + }; +} diff --git a/src/feature-management-applicationinsights-browser/src/version.ts b/src/feature-management-applicationinsights-browser/src/version.ts index 92cdac8..4a665a7 100644 --- a/src/feature-management-applicationinsights-browser/src/version.ts +++ b/src/feature-management-applicationinsights-browser/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.0.2"; +export const VERSION = "2.1.0-preview.1"; diff --git a/src/feature-management-applicationinsights-node/package.json b/src/feature-management-applicationinsights-node/package.json index f6701d6..1e79509 100644 --- a/src/feature-management-applicationinsights-node/package.json +++ b/src/feature-management-applicationinsights-node/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/feature-management-applicationinsights-node", - "version": "2.0.2", + "version": "2.1.0-preview.1", "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", @@ -25,7 +25,7 @@ }, "license": "MIT", "publishConfig": { - "tag": "latest" + "tag": "preview" }, "bugs": { "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" @@ -45,7 +45,7 @@ }, "dependencies": { "applicationinsights": "^2.9.6", - "@microsoft/feature-management": "2.0.2" + "@microsoft/feature-management": "2.1.0-preview.1" } } \ No newline at end of file diff --git a/src/feature-management-applicationinsights-node/src/index.ts b/src/feature-management-applicationinsights-node/src/index.ts index 6b53335..e8509a8 100644 --- a/src/feature-management-applicationinsights-node/src/index.ts +++ b/src/feature-management-applicationinsights-node/src/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { createTelemetryPublisher, trackEvent } from "./telemetry.js"; +export { createTargetingTelemetryProcessor, createTelemetryPublisher, trackEvent } from "./telemetry.js"; export { VERSION } from "./version.js"; diff --git a/src/feature-management-applicationinsights-node/src/telemetry.ts b/src/feature-management-applicationinsights-node/src/telemetry.ts index 11030e6..825db5b 100644 --- a/src/feature-management-applicationinsights-node/src/telemetry.ts +++ b/src/feature-management-applicationinsights-node/src/telemetry.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { EvaluationResult, createFeatureEvaluationEventProperties } from "@microsoft/feature-management"; +import { EvaluationResult, createFeatureEvaluationEventProperties, ITargetingContextAccessor } from "@microsoft/feature-management"; import { TelemetryClient, Contracts } from "applicationinsights"; const TARGETING_ID = "TargetingId"; @@ -39,3 +39,19 @@ export function trackEvent(client: TelemetryClient, targetingId: string, event: }; client.trackEvent(event); } + +/** + * Creates a telemetry processor that adds targeting id to telemetry envelope's custom properties. + * @param targetingContextAccessor The accessor function to get the targeting context. + * @returns A telemetry processor that attaches targeting id to telemetry envelopes. + */ +export function createTargetingTelemetryProcessor(targetingContextAccessor: ITargetingContextAccessor): (envelope: Contracts.EnvelopeTelemetry) => boolean { + return (envelope: Contracts.EnvelopeTelemetry) => { + const targetingContext = targetingContextAccessor.getTargetingContext(); + if (targetingContext?.userId !== undefined) { + envelope.data.baseData = envelope.data.baseData || {}; + envelope.data.baseData.properties = {...envelope.data.baseData.properties, [TARGETING_ID]: targetingContext?.userId || ""}; + } + return true; + }; +} diff --git a/src/feature-management-applicationinsights-node/src/version.ts b/src/feature-management-applicationinsights-node/src/version.ts index 92cdac8..4a665a7 100644 --- a/src/feature-management-applicationinsights-node/src/version.ts +++ b/src/feature-management-applicationinsights-node/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.0.2"; +export const VERSION = "2.1.0-preview.1"; diff --git a/src/feature-management/package-lock.json b/src/feature-management/package-lock.json index a65a514..096097f 100644 --- a/src/feature-management/package-lock.json +++ b/src/feature-management/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/feature-management", - "version": "2.0.2", + "version": "2.1.0-preview.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/feature-management", - "version": "2.0.2", + "version": "2.1.0-preview.1", "license": "MIT", "devDependencies": { "@playwright/test": "^1.46.1", diff --git a/src/feature-management/package.json b/src/feature-management/package.json index 6bd0628..f68c288 100644 --- a/src/feature-management/package.json +++ b/src/feature-management/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/feature-management", - "version": "2.0.2", + "version": "2.1.0-preview.1", "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", @@ -27,7 +27,7 @@ }, "license": "MIT", "publishConfig": { - "tag": "latest" + "tag": "preview" }, "bugs": { "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" diff --git a/src/feature-management/src/IFeatureManager.ts b/src/feature-management/src/IFeatureManager.ts index d673dce..f982a6c 100644 --- a/src/feature-management/src/IFeatureManager.ts +++ b/src/feature-management/src/IFeatureManager.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ITargetingContext } from "./common/ITargetingContext"; +import { ITargetingContext } from "./common/targetingContext"; import { Variant } from "./variant/Variant"; export interface IFeatureManager { diff --git a/src/feature-management/src/common/ITargetingContext.ts b/src/feature-management/src/common/ITargetingContext.ts deleted file mode 100644 index 1d5a426..0000000 --- a/src/feature-management/src/common/ITargetingContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface ITargetingContext { - userId?: string; - groups?: string[]; -} - diff --git a/src/feature-management/src/common/targetingContext.ts b/src/feature-management/src/common/targetingContext.ts new file mode 100644 index 0000000..a133f15 --- /dev/null +++ b/src/feature-management/src/common/targetingContext.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Contextual information that is required to perform a targeting evaluation. + */ +export interface ITargetingContext { + /** + * The user id that should be considered when evaluating if the context is being targeted. + */ + userId?: string; + /** + * The groups that should be considered when evaluating if the context is being targeted. + */ + groups?: string[]; +} + +/** + * Provides access to the current targeting context. + */ +export interface ITargetingContextAccessor { + /** + * Retrieves the current targeting context. + */ + getTargetingContext: () => ITargetingContext | undefined; +} diff --git a/src/feature-management/src/featureManager.ts b/src/feature-management/src/featureManager.ts index a035b5d..69fc6cb 100644 --- a/src/feature-management/src/featureManager.ts +++ b/src/feature-management/src/featureManager.ts @@ -8,25 +8,25 @@ 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 { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js"; import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator.js"; export class FeatureManager implements IFeatureManager { - #provider: IFeatureFlagProvider; - #featureFilters: Map = new Map(); - #onFeatureEvaluated?: (event: EvaluationResult) => void; + readonly #provider: IFeatureFlagProvider; + readonly #featureFilters: Map = new Map(); + readonly #onFeatureEvaluated?: (event: EvaluationResult) => void; + readonly #targetingContextAccessor?: ITargetingContextAccessor; constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { this.#provider = provider; + this.#onFeatureEvaluated = options?.onFeatureEvaluated; + this.#targetingContextAccessor = options?.targetingContextAccessor; - const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; - + const builtinFilters = [new TimeWindowFilter(), new TargetingFilter(options?.targetingContextAccessor)]; // 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 { @@ -78,7 +78,7 @@ export class FeatureManager implements IFeatureManager { return { variant: undefined, reason: VariantAssignmentReason.None }; } - async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { + async #isEnabled(featureFlag: FeatureFlag, appContext?: unknown): Promise { if (featureFlag.enabled !== true) { // If the feature is not explicitly enabled, then it is disabled by default. return false; @@ -106,7 +106,7 @@ export class FeatureManager implements IFeatureManager { console.warn(`Feature filter ${clientFilter.name} is not found.`); return false; } - if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { + if (await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext) === shortCircuitEvaluationResult) { return shortCircuitEvaluationResult; } } @@ -115,7 +115,7 @@ export class FeatureManager implements IFeatureManager { return !shortCircuitEvaluationResult; } - async #evaluateFeature(featureName: string, context: unknown): Promise { + async #evaluateFeature(featureName: string, appContext: unknown): Promise { const featureFlag = await this.#provider.getFeatureFlag(featureName); const result = new EvaluationResult(featureFlag); @@ -128,9 +128,10 @@ export class FeatureManager implements IFeatureManager { validateFeatureFlagFormat(featureFlag); // Evaluate if the feature is enabled. - result.enabled = await this.#isEnabled(featureFlag, context); + result.enabled = await this.#isEnabled(featureFlag, appContext); - const targetingContext = context as ITargetingContext; + // Get targeting context from the app context or the targeting context accessor + const targetingContext = this.#getTargetingContext(appContext); result.targetingId = targetingContext?.userId; // Determine Variant @@ -151,7 +152,7 @@ export class FeatureManager implements IFeatureManager { } } else { // enabled, assign based on allocation - if (context !== undefined && featureFlag.allocation !== undefined) { + if (targetingContext !== undefined && featureFlag.allocation !== undefined) { const variantAndReason = await this.#assignVariant(featureFlag, targetingContext); variantDef = variantAndReason.variant; reason = variantAndReason.reason; @@ -189,6 +190,16 @@ export class FeatureManager implements IFeatureManager { return result; } + + #getTargetingContext(context: unknown): ITargetingContext | undefined { + let targetingContext: ITargetingContext | undefined = context as ITargetingContext; + if (targetingContext?.userId === undefined && + targetingContext?.groups === undefined && + this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor.getTargetingContext(); + } + return targetingContext; + } } export interface FeatureManagerOptions { @@ -202,6 +213,11 @@ export interface FeatureManagerOptions { * The callback function is called only when telemetry is enabled for the feature flag. */ onFeatureEvaluated?: (event: EvaluationResult) => void; + + /** + * The accessor function that provides the @see ITargetingContext for targeting evaluation. + */ + targetingContextAccessor?: ITargetingContextAccessor; } export class EvaluationResult { diff --git a/src/feature-management/src/filter/TargetingFilter.ts b/src/feature-management/src/filter/TargetingFilter.ts index 2d7220e..eb4b73d 100644 --- a/src/feature-management/src/filter/TargetingFilter.ts +++ b/src/feature-management/src/filter/TargetingFilter.ts @@ -3,7 +3,7 @@ import { IFeatureFilter } from "./FeatureFilter.js"; import { isTargetedPercentile } from "../common/targetingEvaluator.js"; -import { ITargetingContext } from "../common/ITargetingContext.js"; +import { ITargetingContext, ITargetingContextAccessor } from "../common/targetingContext.js"; type TargetingFilterParameters = { Audience: { @@ -26,28 +26,36 @@ type TargetingFilterEvaluationContext = { } export class TargetingFilter implements IFeatureFilter { - name: string = "Microsoft.Targeting"; + readonly name: string = "Microsoft.Targeting"; + readonly #targetingContextAccessor?: ITargetingContextAccessor; + + constructor(targetingContextAccessor?: ITargetingContextAccessor) { + this.#targetingContextAccessor = targetingContextAccessor; + } async evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): Promise { const { featureName, parameters } = context; TargetingFilter.#validateParameters(featureName, parameters); - if (appContext === undefined) { - throw new Error("The app context is required for targeting filter."); + let targetingContext: ITargetingContext | undefined; + if (appContext?.userId !== undefined || appContext?.groups !== undefined) { + targetingContext = appContext; + } else if (this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor.getTargetingContext(); } if (parameters.Audience.Exclusion !== undefined) { // check if the user is in the exclusion list - if (appContext?.userId !== undefined && + if (targetingContext?.userId !== undefined && parameters.Audience.Exclusion.Users !== undefined && - parameters.Audience.Exclusion.Users.includes(appContext.userId)) { + parameters.Audience.Exclusion.Users.includes(targetingContext.userId)) { return false; } // check if the user is in a group within exclusion list - if (appContext?.groups !== undefined && + if (targetingContext?.groups !== undefined && parameters.Audience.Exclusion.Groups !== undefined) { for (const excludedGroup of parameters.Audience.Exclusion.Groups) { - if (appContext.groups.includes(excludedGroup)) { + if (targetingContext.groups.includes(excludedGroup)) { return false; } } @@ -55,19 +63,19 @@ export class TargetingFilter implements IFeatureFilter { } // check if the user is being targeted directly - if (appContext?.userId !== undefined && + if (targetingContext?.userId !== undefined && parameters.Audience.Users !== undefined && - parameters.Audience.Users.includes(appContext.userId)) { + parameters.Audience.Users.includes(targetingContext.userId)) { return true; } // check if the user is in a group that is being targeted - if (appContext?.groups !== undefined && + if (targetingContext?.groups !== undefined && parameters.Audience.Groups !== undefined) { for (const group of parameters.Audience.Groups) { - if (appContext.groups.includes(group.Name)) { + if (targetingContext.groups.includes(group.Name)) { const hint = `${featureName}\n${group.Name}`; - if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { + if (await isTargetedPercentile(targetingContext.userId, hint, 0, group.RolloutPercentage)) { return true; } } @@ -76,7 +84,7 @@ export class TargetingFilter implements IFeatureFilter { // check if the user is being targeted by a default rollout percentage const hint = featureName; - return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); + return isTargetedPercentile(targetingContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); } static #validateParameters(featureName: string, parameters: TargetingFilterParameters): void { diff --git a/src/feature-management/src/filter/TimeWindowFilter.ts b/src/feature-management/src/filter/TimeWindowFilter.ts index 3cd0ead..beb0136 100644 --- a/src/feature-management/src/filter/TimeWindowFilter.ts +++ b/src/feature-management/src/filter/TimeWindowFilter.ts @@ -15,7 +15,7 @@ type TimeWindowFilterEvaluationContext = { } export class TimeWindowFilter implements IFeatureFilter { - name: string = "Microsoft.TimeWindow"; + readonly name: string = "Microsoft.TimeWindow"; evaluate(context: TimeWindowFilterEvaluationContext): boolean { const {featureName, parameters} = context; diff --git a/src/feature-management/src/index.ts b/src/feature-management/src/index.ts index 77d18c5..093d3fd 100644 --- a/src/feature-management/src/index.ts +++ b/src/feature-management/src/index.ts @@ -5,4 +5,5 @@ export { FeatureManager, FeatureManagerOptions, EvaluationResult, VariantAssignm export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js"; export { createFeatureEvaluationEventProperties } from "./telemetry/featureEvaluationEvent.js"; export { IFeatureFilter } from "./filter/FeatureFilter.js"; +export { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js"; export { VERSION } from "./version.js"; diff --git a/src/feature-management/src/telemetry/featureEvaluationEvent.ts b/src/feature-management/src/telemetry/featureEvaluationEvent.ts index b76f46b..2c195c6 100644 --- a/src/feature-management/src/telemetry/featureEvaluationEvent.ts +++ b/src/feature-management/src/telemetry/featureEvaluationEvent.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { EvaluationResult } from "../featureManager"; +import { EvaluationResult, VariantAssignmentReason } from "../featureManager"; import { EVALUATION_EVENT_VERSION } from "../version.js"; const VERSION = "Version"; @@ -10,6 +10,8 @@ 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) { @@ -26,6 +28,31 @@ export function createFeatureEvaluationEventProperties(result: EvaluationResult) [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) { diff --git a/src/feature-management/src/version.ts b/src/feature-management/src/version.ts index 6a06472..1174509 100644 --- a/src/feature-management/src/version.ts +++ b/src/feature-management/src/version.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.0.2"; +export const VERSION = "2.1.0-preview.1"; export const EVALUATION_EVENT_VERSION = "1.0.0"; diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index 91fe81b..6da25f6 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -131,15 +131,31 @@ describe("targeting filter", () => { ]); }); - it("should throw error if app context is not provided", () => { + it("should evaluate feature with targeting filter with targeting context accessor", async () => { const dataSource = new Map(); dataSource.set("feature_management", { feature_flags: [complexTargetingFeature] }); + let userId = ""; + let groups: string[] = []; + const testTargetingContextAccessor = { + getTargetingContext: () => { + return { userId: userId, groups: groups }; + } + }; 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."); + const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); + + userId = "Aiden"; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(false); + userId = "Blossom"; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(true); + expect(await featureManager.isEnabled("ComplexTargeting", {userId: "Aiden"})).to.eq(false); // targeting id will be overridden + userId = "Aiden"; + groups = ["Stage2"]; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(true); + userId = "Chris"; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(false); }); }); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index 118fc03..edff9a0 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -90,5 +90,32 @@ describe("feature variant", () => { }); }); +}); +describe("variant assignment with targeting context accessor", () => { + it("should assign variant based on targeting context accessor", async () => { + let userId = ""; + let groups: string[] = []; + const testTargetingContextAccessor = { + getTargetingContext: () => { + return { userId: userId, groups: groups }; + } + }; + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); + const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); + userId = "Marsha"; + let variant = await featureManager.getVariant(Features.VariantFeatureUser); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + userId = "Jeff"; + variant = await featureManager.getVariant(Features.VariantFeatureUser); + expect(variant).to.be.undefined; + variant = await featureManager.getVariant(Features.VariantFeatureUser, {userId: "Marsha"}); // targeting id will be overridden + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + groups = ["Group1"]; + variant = await featureManager.getVariant(Features.VariantFeatureGroup); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + }); });