diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..02db30a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# If there are abnormal line endings in any file, run "git add --renormalize ", +# review the changes, and commit them to fix the line endings. +* text=auto \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f800717..b6da1df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,70 +1,70 @@ -name: FeatureManagement-JavaScript CI - -on: - push: - branches: - - main - - preview - - release/* - pull_request: - branches: - - main - - preview - - release/* - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x] - - defaults: - run: - working-directory: src/feature-management - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - cache-dependency-path: src/feature-management/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: src/feature-management - - - name: Run lint check for feature-management - run: npm run lint - working-directory: src/feature-management - - - name: Build feature-management - run: npm run build - working-directory: src/feature-management - - - name: Run tests - run: npm run test - working-directory: src/feature-management - - - name: Run browser tests - run: npm run test-browser - working-directory: src/feature-management - - - name: Build feature-management-applicationinsights-browser - run: npm run build - working-directory: src/feature-management-applicationinsights-browser - - - name: Run lint check for feature-management-applicationinsights-browser - run: npm run lint - working-directory: src/feature-management-applicationinsights-browser - - - name: Build feature-management-applicationinsights-node - run: npm run build - working-directory: src/feature-management-applicationinsights-node - - - name: Run lint check for feature-management-applicationinsights-node - run: npm run lint - working-directory: src/feature-management-applicationinsights-node +name: FeatureManagement-JavaScript CI + +on: + push: + branches: + - main + - preview + - release/* + pull_request: + branches: + - main + - preview + - release/* + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + defaults: + run: + working-directory: src/feature-management + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: src/feature-management/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: src/feature-management + + - name: Run lint check for feature-management + run: npm run lint + working-directory: src/feature-management + + - name: Build feature-management + run: npm run build + working-directory: src/feature-management + + - name: Run tests + run: npm run test + working-directory: src/feature-management + + - name: Run browser tests + run: npm run test-browser + working-directory: src/feature-management + + - name: Build feature-management-applicationinsights-browser + run: npm run build + working-directory: src/feature-management-applicationinsights-browser + + - name: Run lint check for feature-management-applicationinsights-browser + run: npm run lint + working-directory: src/feature-management-applicationinsights-browser + + - name: Build feature-management-applicationinsights-node + run: npm run build + working-directory: src/feature-management-applicationinsights-node + + - name: Run lint check for feature-management-applicationinsights-node + run: npm run lint + working-directory: src/feature-management-applicationinsights-node diff --git a/SUPPORT.md b/SUPPORT.md index f176f1a..90187a0 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,11 +1,11 @@ -# Support - -## How to file issues and get help - -This project uses GitHub Issues to track bugs and feature requests. Please search the existing -issues before filing new issues to avoid duplicates. For new issues, file your bug or -feature request as a new Issue. - -## Microsoft Support Policy - -Support for this project is limited to the resources listed above. +# Support + +## How to file issues and get help + +This project uses GitHub Issues to track bugs and feature requests. Please search the existing +issues before filing new issues to avoid duplicates. For new issues, file your bug or +feature request as a new Issue. + +## Microsoft Support Policy + +Support for this project is limited to the resources listed above. diff --git a/examples/express-app/README.md b/examples/express-app/README.md index 5cd07b0..a8ba572 100644 --- a/examples/express-app/README.md +++ b/examples/express-app/README.md @@ -8,14 +8,7 @@ The examples are compatible with [LTS versions of Node.js](https://github.com/no ## Setup & Run -1. Go to `src/feature-management` under the root folder and run: - - ```bash - npm run install - npm run build - ``` - -1. Go back to `examples/express-app` and install the dependencies using `npm`: +1. Install the dependencies using `npm`: ```bash npm install @@ -41,6 +34,9 @@ The targeting mechanism uses the `exampleTargetingContextAccessor` to extract th 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 diff --git a/examples/express-app/package.json b/examples/express-app/package.json index 6833583..c9094e9 100644 --- a/examples/express-app/package.json +++ b/examples/express-app/package.json @@ -3,7 +3,7 @@ "start": "node server.mjs" }, "dependencies": { - "@microsoft/feature-management": "../../src/feature-management", + "@microsoft/feature-management": "2.1.0", "express": "^4.21.2" } } \ No newline at end of file diff --git a/examples/express-app/server.mjs b/examples/express-app/server.mjs index 2b8ea53..0404b33 100644 --- a/examples/express-app/server.mjs +++ b/examples/express-app/server.mjs @@ -15,6 +15,9 @@ 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 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..8a46357 --- /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", + "@microsoft/feature-management-applicationinsights-node": "2.1.0", + "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 01e6a00..00f792e 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.1.0-preview.1", + "version": "2.1.0", "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": "preview" + "tag": "latest" }, "bugs": { "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" @@ -46,7 +46,7 @@ }, "dependencies": { "@microsoft/applicationinsights-web": "^3.3.2", - "@microsoft/feature-management": "2.1.0-preview.1" + "@microsoft/feature-management": "2.1.0" } } \ No newline at end of file diff --git a/src/feature-management-applicationinsights-browser/src/version.ts b/src/feature-management-applicationinsights-browser/src/version.ts index 4a665a7..0200538 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.1.0-preview.1"; +export const VERSION = "2.1.0"; diff --git a/src/feature-management-applicationinsights-node/package.json b/src/feature-management-applicationinsights-node/package.json index 1e79509..af65e8b 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.1.0-preview.1", + "version": "2.1.0", "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": "preview" + "tag": "latest" }, "bugs": { "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" @@ -45,7 +45,7 @@ }, "dependencies": { "applicationinsights": "^2.9.6", - "@microsoft/feature-management": "2.1.0-preview.1" + "@microsoft/feature-management": "2.1.0" } } \ No newline at end of file diff --git a/src/feature-management-applicationinsights-node/src/version.ts b/src/feature-management-applicationinsights-node/src/version.ts index 4a665a7..0200538 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.1.0-preview.1"; +export const VERSION = "2.1.0"; diff --git a/src/feature-management/package-lock.json b/src/feature-management/package-lock.json index 923d15c..cb413c2 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.1.0-preview.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/feature-management", - "version": "2.1.0-preview.1", + "version": "2.1.0", "license": "MIT", "devDependencies": { "@playwright/test": "^1.46.1", @@ -1295,9 +1295,9 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -2228,12 +2228,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { diff --git a/src/feature-management/package.json b/src/feature-management/package.json index f68c288..bc9a10b 100644 --- a/src/feature-management/package.json +++ b/src/feature-management/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/feature-management", - "version": "2.1.0-preview.1", + "version": "2.1.0", "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": "preview" + "tag": "latest" }, "bugs": { "url": "https://github.com/microsoft/FeatureManagement-JavaScript/issues" diff --git a/src/feature-management/src/version.ts b/src/feature-management/src/version.ts index 1174509..816cce6 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.1.0-preview.1"; +export const VERSION = "2.1.0"; export const EVALUATION_EVENT_VERSION = "1.0.0";