From 1e8f7be690ec44293a08d16bdf994f45db109220 Mon Sep 17 00:00:00 2001 From: aprilk-ms <55356546+aprilk-ms@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:16:31 +0000 Subject: [PATCH 1/4] infra updates --- azure.yaml | 13 +++++ infra/main.bicep | 54 ++++++++++++++++++- infra/main.parameters.json | 3 ++ infra/scripts/pre-down.ps1 | 19 +++++++ infra/shared/appConfiguration.bicep | 10 ++++ infra/shared/dataExport.bicep | 28 ++++++++++ infra/shared/la-summary-rules.yaml | 33 ++++++++++++ infra/shared/monitoring.bicep | 13 +++++ .../shared/onlineExperimentation-access.bicep | 22 ++++++++ infra/shared/onlineExperimentation.bicep | 30 +++++++++++ infra/shared/summaryRule.bicep | 25 +++++++++ 11 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 infra/scripts/pre-down.ps1 create mode 100644 infra/shared/dataExport.bicep create mode 100644 infra/shared/la-summary-rules.yaml create mode 100644 infra/shared/onlineExperimentation-access.bicep create mode 100644 infra/shared/onlineExperimentation.bicep create mode 100644 infra/shared/summaryRule.bicep diff --git a/azure.yaml b/azure.yaml index aeb782a..f9f2059 100644 --- a/azure.yaml +++ b/azure.yaml @@ -7,6 +7,19 @@ hooks: prepackage: shell: pwsh run: cd src && npm run build-client + predown: + windows: + shell: pwsh + run: | + Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass + ./infra/scripts/pre-down.ps1 + continueOnError: false + interactive: true + posix: + shell: sh + run: pwsh ./infra/scripts/pre-down.ps1 + continueOnError: false + interactive: true services: QuoteOfTheDay: project: src diff --git a/infra/main.bicep b/infra/main.bicep index 78c6a10..1340237 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -7,9 +7,9 @@ param environmentName string @secure() param quoteOfTheDayDefinition object +param location string param LAWname string -param location string param LAWsku string param AppInsightsName string param ApplicationType string @@ -20,6 +20,11 @@ param AACsoftDeleteRetentionInDays int param AACenablePurgeProtection bool param AACdisableLocalAuth bool +param principalId string +param principalType string = 'User' + +param enableOnlineExperimentation bool + // Tags that should be applied to all resources. // // Note that 'azd-service-name' tags should be applied separately to service host resources. @@ -48,6 +53,8 @@ module monitoring './shared/monitoring.bicep' = { ApplicationType: ApplicationType LAWsku: LAWsku tags: tags + principalId: principalId + principalType: principalType } scope: rg } @@ -63,6 +70,7 @@ module appConfiguration './shared/appConfiguration.bicep' = { name: '${AppConfigName}${resourceToken}' applicationInsightsId: monitoring.outputs.applicationInsightsId tags: tags + enableOnlineExperimentation: enableOnlineExperimentation } scope: rg } @@ -91,5 +99,49 @@ module quoteOfTheDay './app/QuoteOfTheDay.bicep' = { scope: rg } +// Setup for online experimentation if enabled +// Including adding summary rules and data export rule to Log Analytics +module onlineExperimentationWorkspace 'shared/onlineExperimentation.bicep' = if (enableOnlineExperimentation) { + name: 'online-experimentation-${resourceToken}' + scope: subscription() + params: { + resourceId: appConfiguration.outputs.onlineExperimentationResourceId + resourceGroupname: appConfiguration.outputs.managedResourceGroupName + principalId: principalId + principalType: principalType + } +} + + +var ruleDefinitions = loadYamlContent('shared/la-summary-rules.yaml') +module summaryRules 'shared/summaryRule.bicep' = [for (rule, i) in ruleDefinitions.summaryRules: if (enableOnlineExperimentation) { + name: 'loganalytics-summaryrule-${i}' + scope: rg + params: { + location: location + logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName + summaryRuleName: rule.name + description: rule.description + query: rule.query + binSize: rule.binSize + destinationTable: rule.destinationTable + } +}] + +module dataExportRule 'shared/dataExport.bicep' = if (enableOnlineExperimentation) { + name: 'loganalytics-dataexportrule' + scope: rg + params: { + name: 'OEW-${resourceToken}-DataExportRule' + logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName + storageAccountResourceId: appConfiguration.outputs.storageAccountResourceId + tables: [ + 'AppEvents' + ] + } +} + +output AZURE_RESOURCE_GROUP string = rg.name +output APPCONFIG_RESOURCE_NAME string = appConfiguration.outputs.appConfigurationName output APPCONFIG_ENDPOINT string = appConfiguration.outputs.appConfigurationEndpoint output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString diff --git a/infra/main.parameters.json b/infra/main.parameters.json index f457f79..4455780 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -46,6 +46,9 @@ }, "AACdisableLocalAuth": { "value": false + }, + "enableOnlineExperimentation": { + "value": "${ENABLE_ONLINE_EXPERIMENTATION=false}" } } } \ No newline at end of file diff --git a/infra/scripts/pre-down.ps1 b/infra/scripts/pre-down.ps1 new file mode 100644 index 0000000..bc59333 --- /dev/null +++ b/infra/scripts/pre-down.ps1 @@ -0,0 +1,19 @@ +# Get the directory of the current script +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +# Load environment variables from azd env +$subscriptionId = azd env get-value AZURE_SUBSCRIPTION_ID +$resourceName = azd env get-value APPCONFIG_RESOURCE_NAME +$resourceGroup = azd env get-value AZURE_RESOURCE_GROUP +$experimentationResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.AppConfiguration/configurationStores/$resourceName/experimentation/default" + +# Check resource existence by examining the exit code +az resource show --ids $experimentationResourceId --api-version 2025-02-01-preview --output none 2>$null +$resourceExists = $? + +if ($resourceExists) { + Write-Host "Disabling online experimentation for App Configuration resource: $experimentationResourceId" + az resource delete --ids $experimentationResourceId --api-version 2025-02-01-preview +} else { + Write-Host "Online experimentation not enabled, skipping online experimentation disable step" +} \ No newline at end of file diff --git a/infra/shared/appConfiguration.bicep b/infra/shared/appConfiguration.bicep index 6a4d8b8..aeef5b4 100644 --- a/infra/shared/appConfiguration.bicep +++ b/infra/shared/appConfiguration.bicep @@ -6,6 +6,7 @@ param AACenablePurgeProtection bool param AACdisableLocalAuth bool param applicationInsightsId string param tags object = {} +param enableOnlineExperimentation bool resource appConfigurationStore 'Microsoft.AppConfiguration/configurationStores@2023-09-01-preview' = { name: name @@ -76,5 +77,14 @@ resource variantFeatureFlagGreeting 'Microsoft.AppConfiguration/configurationSto } } +resource experimentation 'Microsoft.AppConfiguration/configurationStores/experimentation@2025-02-01-preview' = if (enableOnlineExperimentation) { + parent: appConfigurationStore + name: 'default' +} + output appConfigurationEndpoint string = appConfigurationStore.properties.endpoint output appConfigurationName string = appConfigurationStore.name + +output managedResourceGroupName string = enableOnlineExperimentation ? experimentation.properties.managedResourceGroupName : '' +output onlineExperimentationResourceId string = enableOnlineExperimentation ? experimentation.properties.onlineExperimentationWorkspaceResourceId : '' +output storageAccountResourceId string = enableOnlineExperimentation ? experimentation.properties.storageAccountResourceId : '' diff --git a/infra/shared/dataExport.bicep b/infra/shared/dataExport.bicep new file mode 100644 index 0000000..3c66587 --- /dev/null +++ b/infra/shared/dataExport.bicep @@ -0,0 +1,28 @@ +metadata description = 'Creates a data export rule.' +param name string +param logAnalyticsWorkspaceName string +param storageAccountResourceId string +param tables string[] + +var segments = split(storageAccountResourceId, '/') +var storageAccountName = segments[length(segments) - 1] + +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: storageAccountName +} + +resource dataExportRule 'Microsoft.OperationalInsights/workspaces/dataExports@2020-08-01' = { + name: name + parent: logAnalyticsWorkspace + properties: { + destination: { + resourceId: storageAccount.id + } + enable: true + tableNames: tables + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' existing = { + name: logAnalyticsWorkspaceName +} diff --git a/infra/shared/la-summary-rules.yaml b/infra/shared/la-summary-rules.yaml new file mode 100644 index 0000000..8f61a74 --- /dev/null +++ b/infra/shared/la-summary-rules.yaml @@ -0,0 +1,33 @@ +summaryRules: + - name: "Online-Experimentation-Assignment-Summary" + binSize: 20 + destinationTable: OEWExperimentAssignmentSummary_CL + description: "Summary rule definition for online experiment assignment summary." + query: | + AppEvents + | where Name == "FeatureEvaluation" + | where Properties has "Percentile" + | where tostring(Properties.VariantAssignmentReason) == "Percentile" + | where tostring(Properties.TargetingId) != "" + | where tostring(Properties.Enabled) == "True" + | where tostring(Properties.AllocationId) != "" + | where toint(Properties.VariantAssignmentPercentage) < 100 + | extend FeatureFlagReference = tostring(Properties.FeatureFlagReference) + | extend Label = tostring(parse_url(FeatureFlagReference).["Query Parameters"].label) + | project + TimeGenerated, + ItemCount, + FeatureFlagReference = FeatureFlagReference, + FeatureName = tostring(Properties.FeatureName), + Label = Label, + AllocationId = tostring(Properties.AllocationId), + Variant = tostring(Properties.Variant), + VariantAssignmentPercentage = toreal(Properties.VariantAssignmentPercentage), + IsControlVariant = tostring(Properties.DefaultWhenEnabled) == tostring(Properties.Variant) + | summarize + VariantAssignmentPercentage = take_any(VariantAssignmentPercentage), + IsControlVariant = take_any(IsControlVariant), + AssignmentEventCount = tolong(sum(ItemCount)), + FirstAssignmentTimestamp = min(TimeGenerated), + LastAssignmentTimestamp = max(TimeGenerated) + by FeatureFlagReference, FeatureName, Label, AllocationId, Variant \ No newline at end of file diff --git a/infra/shared/monitoring.bicep b/infra/shared/monitoring.bicep index 4479e99..e1d31fe 100644 --- a/infra/shared/monitoring.bicep +++ b/infra/shared/monitoring.bicep @@ -5,6 +5,8 @@ param ApplicationType string param AIrequestSource string param LAWsku string param tags object = {} +param principalId string +param principalType string resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { name: logAnalyticsName @@ -17,6 +19,17 @@ resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-previ } } +// Assign the Log Analytics Contributor role to the specified principal +var logAnalyticsContributorRoleId = '92aaf0da-9dab-42b6-94a3-d43ce8d16293' +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, logAnalyticsContributorRoleId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', logAnalyticsContributorRoleId) + } +} + resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { name: applicationInsightsName location: location diff --git a/infra/shared/onlineExperimentation-access.bicep b/infra/shared/onlineExperimentation-access.bicep new file mode 100644 index 0000000..8624c76 --- /dev/null +++ b/infra/shared/onlineExperimentation-access.bicep @@ -0,0 +1,22 @@ +metadata description = 'Configure online experimentation workspace access' +param resourceName string +param principalId string +param principalType string + +resource workspace 'Microsoft.OnlineExperimentation/workspaces@2025-05-31-preview' existing = { + name: resourceName +} + +resource onlineExperimentDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' existing = { + name: '53747cdd-e97c-477a-948c-b587d0e514b2' // Online Experimentation Data Owner +} + +resource dataOwnerUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, onlineExperimentDataOwnerRole.id) + scope: workspace + properties: { + principalId: principalId + roleDefinitionId: onlineExperimentDataOwnerRole.id + principalType: principalType + } +} diff --git a/infra/shared/onlineExperimentation.bicep b/infra/shared/onlineExperimentation.bicep new file mode 100644 index 0000000..e7608c4 --- /dev/null +++ b/infra/shared/onlineExperimentation.bicep @@ -0,0 +1,30 @@ +metadata description = 'Configure online experimentation workspace' +targetScope = 'subscription' +param resourceId string +param resourceGroupname string +param principalId string +param principalType string + +resource managedResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = { + name: resourceGroupname +} + +var segments = split(resourceId, '/') +var resourceName = segments[length(segments) - 1] + +resource workspace 'Microsoft.OnlineExperimentation/workspaces@2025-05-31-preview' existing = { + name: resourceName + scope: managedResourceGroup +} + +module workspaceAccess './onlineExperimentation-access.bicep' = { + name: 'online-experimentation-role-assignment' + scope: managedResourceGroup + params: { + resourceName: resourceName + principalId: principalId + principalType: principalType + } +} + +output endpoint string = workspace.properties.endpoint diff --git a/infra/shared/summaryRule.bicep b/infra/shared/summaryRule.bicep new file mode 100644 index 0000000..6282dfd --- /dev/null +++ b/infra/shared/summaryRule.bicep @@ -0,0 +1,25 @@ +metadata description = 'Creates a Log Analytics Summary Rule.' +param logAnalyticsWorkspaceName string +param summaryRuleName string +param description string +param ruleType string = 'User' +param query string +param binSize int +param destinationTable string +param location string + +resource workspaceName_summaryRule 'Microsoft.OperationalInsights/workspaces/summaryLogs@2023-01-01-preview' = { + name: '${logAnalyticsWorkspaceName}/${summaryRuleName}' + location: location + properties: { + ruleType: ruleType + description: description + ruleDefinition: { + query: query + binSize: binSize + destinationTable: destinationTable + } + } +} + +output id string = workspaceName_summaryRule.id From 983575c048cbcb61a8965a9ed75fea5957faccc6 Mon Sep 17 00:00:00 2001 From: aprilk-ms <55356546+aprilk-ms@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:17:28 +0000 Subject: [PATCH 2/4] code update --- infra/shared/la-summary-rules.yaml | 2 +- src/config.js | 11 +++ src/featureManagement.js | 53 ++++++++++++++ src/package.json | 5 +- src/routes.js | 30 ++++++++ src/server.js | 112 ++++++++++------------------- src/targetingContextAccessor.js | 32 +++++++++ src/telemetry.js | 22 ++++++ 8 files changed, 191 insertions(+), 76 deletions(-) create mode 100644 src/config.js create mode 100644 src/featureManagement.js create mode 100644 src/routes.js create mode 100644 src/targetingContextAccessor.js create mode 100644 src/telemetry.js diff --git a/infra/shared/la-summary-rules.yaml b/infra/shared/la-summary-rules.yaml index 8f61a74..b82e3f0 100644 --- a/infra/shared/la-summary-rules.yaml +++ b/infra/shared/la-summary-rules.yaml @@ -30,4 +30,4 @@ summaryRules: AssignmentEventCount = tolong(sum(ItemCount)), FirstAssignmentTimestamp = min(TimeGenerated), LastAssignmentTimestamp = max(TimeGenerated) - by FeatureFlagReference, FeatureName, Label, AllocationId, Variant \ No newline at end of file + by FeatureFlagReference, FeatureName, Label, AllocationId, Variant diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..9cb0554 --- /dev/null +++ b/src/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/src/featureManagement.js b/src/featureManagement.js new file mode 100644 index 0000000..c8e6b7f --- /dev/null +++ b/src/featureManagement.js @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const { load } = require("@azure/app-configuration-provider"); +const { FeatureManager, ConfigurationMapFeatureFlagProvider } = 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); + + 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 +}; diff --git a/src/package.json b/src/package.json index 15a6984..cb398e1 100644 --- a/src/package.json +++ b/src/package.json @@ -7,9 +7,10 @@ }, "dependencies": { "@azure/app-configuration-provider": "latest", - "@microsoft/feature-management": "^2.0.0", - "@microsoft/feature-management-applicationinsights-node": "^2.0.0", + "@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/src/routes.js b/src/routes.js new file mode 100644 index 0000000..a3a1186 --- /dev/null +++ b/src/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/src/server.js b/src/server.js index 87c0df9..25309a9 100644 --- a/src/server.js +++ b/src/server.js @@ -1,88 +1,54 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const appConfigEndpoint = process.env.APPCONFIG_ENDPOINT; -const appInsightsConnectionString = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING; - -const applicationInsights = require("applicationinsights"); -applicationInsights.setup(appInsightsConnectionString).start(); +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(); -const { DefaultAzureCredential } = require("@azure/identity"); -const { load } = require("@azure/app-configuration-provider"); -const { FeatureManager, ConfigurationMapFeatureFlagProvider } = require("@microsoft/feature-management"); -const { createTelemetryPublisher, trackEvent } = require("@microsoft/feature-management-applicationinsights-node"); -let appConfig; -let featureManager; -async function initializeConfig() { - console.log("Loading configuration..."); - appConfig = await load(appConfigEndpoint, new DefaultAzureCredential(), { - featureFlagOptions: { - enabled: true, - selectors: [ - { - keyFilter: "*" - } - ], - refresh: { - enabled: true, - refreshIntervalInMs: 10_000 - } - } - }); +// Initialize Application Insights +const appInsights = initializeAppInsights(targetingContextAccessor); - const featureFlagProvider = new ConfigurationMapFeatureFlagProvider(appConfig); - const publishTelemetry = createTelemetryPublisher(applicationInsights.defaultClient); - featureManager = new FeatureManager(featureFlagProvider, { - onFeatureEvaluated: publishTelemetry - }); -} +// Global variables to store feature manager and app config +let featureManager; // Initialize the configuration and start the server -initializeConfig() - .then(() => { - console.log("Configuration loaded. Starting server..."); - startServer(); - }) - .catch((error) => { - console.error("Failed to load configuration:", error); - process.exit(1); - }); +async function startApp() { + try { + // Initialize AppConfig and FeatureManager + const result = await initializeFeatureManagement( + appInsights.defaultClient, + targetingContextAccessor + ); + featureManager = result.featureManager; -function startServer() { - // Use a middleware to refresh the configuration before each request - // The configuration refresh is triggered by the incoming requests to your web app. No refresh will occur if your app is idle. - server.use((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(); - }); - server.use(express.json()); - server.use(express.static("public")); + console.log("Configuration loaded. Starting server..."); - server.get("/api/getGreetingMessage", async (req, res) => { - const { userId, groups } = req.query; - const variant = await featureManager.getVariant("Greeting", { userId: userId, groups: groups ? groups.split(",") : [] }); - res.status(200).send({ - message: variant?.configuration - }); - }); + // Set up middleware + server.use(requestStorageMiddleware); + server.use(featureFlagRefreshMiddleware); + server.use(express.json()); + server.use(express.static("public")); - server.post("/api/like", (req, res) => { - const { UserId } = req.body; - if (UserId === undefined) { - return res.status(400).send({ error: "UserId is required" }); - } - trackEvent(applicationInsights.defaultClient, UserId, { name: "Like" }); - res.status(200).send({ message: "Like event logged successfully" }); - }); + // Set up routes + const routes = initializeRoutes(featureManager, appInsights.defaultClient); + server.use(routes); - const port = process.env.PORT || "8080"; - server.listen(port, () => { - console.log(`Server is running at http://localhost:${port}`); - }); + // 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(); \ No newline at end of file diff --git a/src/targetingContextAccessor.js b/src/targetingContextAccessor.js new file mode 100644 index 0000000..bc55bea --- /dev/null +++ b/src/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 +}; diff --git a/src/telemetry.js b/src/telemetry.js new file mode 100644 index 0000000..8c60f11 --- /dev/null +++ b/src/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 +}; From a37e0a7b1212ec9971d7a7fa38ad50001f50c5fb Mon Sep 17 00:00:00 2001 From: aprilk-ms <55356546+aprilk-ms@users.noreply.github.com> Date: Mon, 16 Jun 2025 00:47:00 +0000 Subject: [PATCH 3/4] revert app changes --- src/config.js | 11 ---- src/featureManagement.js | 53 --------------- src/routes.js | 30 --------- src/server.js | 112 +++++++++++++++++++++----------- src/targetingContextAccessor.js | 32 --------- src/telemetry.js | 22 ------- 6 files changed, 73 insertions(+), 187 deletions(-) delete mode 100644 src/config.js delete mode 100644 src/featureManagement.js delete mode 100644 src/routes.js delete mode 100644 src/targetingContextAccessor.js delete mode 100644 src/telemetry.js diff --git a/src/config.js b/src/config.js deleted file mode 100644 index 9cb0554..0000000 --- a/src/config.js +++ /dev/null @@ -1,11 +0,0 @@ -// 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/src/featureManagement.js b/src/featureManagement.js deleted file mode 100644 index c8e6b7f..0000000 --- a/src/featureManagement.js +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -const { load } = require("@azure/app-configuration-provider"); -const { FeatureManager, ConfigurationMapFeatureFlagProvider } = 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); - - 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 -}; diff --git a/src/routes.js b/src/routes.js deleted file mode 100644 index a3a1186..0000000 --- a/src/routes.js +++ /dev/null @@ -1,30 +0,0 @@ -// 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/src/server.js b/src/server.js index 25309a9..87c0df9 100644 --- a/src/server.js +++ b/src/server.js @@ -1,54 +1,88 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const config = require("./config"); +const appConfigEndpoint = process.env.APPCONFIG_ENDPOINT; +const appInsightsConnectionString = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING; -const express = require("express"); -const { targetingContextAccessor, requestStorageMiddleware } = require("./targetingContextAccessor"); -const { initializeAppInsights } = require("./telemetry"); -const { initializeFeatureManagement, featureFlagRefreshMiddleware } = require("./featureManagement"); -const { initializeRoutes } = require("./routes"); +const applicationInsights = require("applicationinsights"); +applicationInsights.setup(appInsightsConnectionString).start(); -// Initialize Express server +const express = require("express"); const server = express(); -// Initialize Application Insights -const appInsights = initializeAppInsights(targetingContextAccessor); - -// Global variables to store feature manager and app config +const { DefaultAzureCredential } = require("@azure/identity"); +const { load } = require("@azure/app-configuration-provider"); +const { FeatureManager, ConfigurationMapFeatureFlagProvider } = require("@microsoft/feature-management"); +const { createTelemetryPublisher, trackEvent } = require("@microsoft/feature-management-applicationinsights-node"); +let appConfig; let featureManager; +async function initializeConfig() { + console.log("Loading configuration..."); + appConfig = await load(appConfigEndpoint, new DefaultAzureCredential(), { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "*" + } + ], + refresh: { + enabled: true, + refreshIntervalInMs: 10_000 + } + } + }); -// 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; + const featureFlagProvider = new ConfigurationMapFeatureFlagProvider(appConfig); + const publishTelemetry = createTelemetryPublisher(applicationInsights.defaultClient); + featureManager = new FeatureManager(featureFlagProvider, { + onFeatureEvaluated: publishTelemetry + }); +} +// Initialize the configuration and start the server +initializeConfig() + .then(() => { console.log("Configuration loaded. Starting server..."); + startServer(); + }) + .catch((error) => { + console.error("Failed to load configuration:", error); + process.exit(1); + }); - // 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); +function startServer() { + // Use a middleware to refresh the configuration before each request + // The configuration refresh is triggered by the incoming requests to your web app. No refresh will occur if your app is idle. + server.use((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(); + }); + server.use(express.json()); + server.use(express.static("public")); - // Start the server - server.listen(config.port, () => { - console.log(`Server is running at http://localhost:${config.port}`); + server.get("/api/getGreetingMessage", async (req, res) => { + const { userId, groups } = req.query; + const variant = await featureManager.getVariant("Greeting", { userId: userId, groups: groups ? groups.split(",") : [] }); + res.status(200).send({ + message: variant?.configuration }); - } catch (error) { - console.error("Failed to load configuration:", error); - process.exit(1); - } -} + }); + + server.post("/api/like", (req, res) => { + const { UserId } = req.body; + if (UserId === undefined) { + return res.status(400).send({ error: "UserId is required" }); + } + trackEvent(applicationInsights.defaultClient, UserId, { name: "Like" }); + res.status(200).send({ message: "Like event logged successfully" }); + }); -// Start the application -startApp(); \ No newline at end of file + const port = process.env.PORT || "8080"; + server.listen(port, () => { + console.log(`Server is running at http://localhost:${port}`); + }); +} diff --git a/src/targetingContextAccessor.js b/src/targetingContextAccessor.js deleted file mode 100644 index bc55bea..0000000 --- a/src/targetingContextAccessor.js +++ /dev/null @@ -1,32 +0,0 @@ -// 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 -}; diff --git a/src/telemetry.js b/src/telemetry.js deleted file mode 100644 index 8c60f11..0000000 --- a/src/telemetry.js +++ /dev/null @@ -1,22 +0,0 @@ -// 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 -}; From 0544600830a4499b4844e02c3d83a509089f81e7 Mon Sep 17 00:00:00 2001 From: aprilk-ms <55356546+aprilk-ms@users.noreply.github.com> Date: Mon, 16 Jun 2025 00:47:18 +0000 Subject: [PATCH 4/4] update pre-down script --- infra/scripts/pre-down.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/infra/scripts/pre-down.ps1 b/infra/scripts/pre-down.ps1 index bc59333..f578103 100644 --- a/infra/scripts/pre-down.ps1 +++ b/infra/scripts/pre-down.ps1 @@ -1,19 +1,25 @@ # Get the directory of the current script $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +# Check if user is logged in to Azure CLI +az account show --output none 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "Not logged in to Azure CLI. Please run 'az login' first." + exit 1 +} + # Load environment variables from azd env $subscriptionId = azd env get-value AZURE_SUBSCRIPTION_ID $resourceName = azd env get-value APPCONFIG_RESOURCE_NAME $resourceGroup = azd env get-value AZURE_RESOURCE_GROUP $experimentationResourceId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.AppConfiguration/configurationStores/$resourceName/experimentation/default" -# Check resource existence by examining the exit code +# Check experimentation resource existence az resource show --ids $experimentationResourceId --api-version 2025-02-01-preview --output none 2>$null -$resourceExists = $? - -if ($resourceExists) { +if ($LASTEXITCODE -eq 0) { Write-Host "Disabling online experimentation for App Configuration resource: $experimentationResourceId" az resource delete --ids $experimentationResourceId --api-version 2025-02-01-preview } else { Write-Host "Online experimentation not enabled, skipping online experimentation disable step" + exit 0 } \ No newline at end of file