├── .nvmrc ├── CODEOWNERS ├── assets ├── readme-images │ ├── log-query-alert-sev1.png │ ├── log-query-alert-sev2.png │ ├── service-health-alert.png │ ├── express-route-alert-one.png │ ├── express-route-alert-two.png │ ├── log-query-alert-sev3-sev4.png │ ├── express-route-resolved-one.png │ ├── express-route-resolved-two.png │ ├── service-health-alert-expanded.png │ ├── express-route-burst-notification.png │ └── express-route-burst-resolved-notification.png └── sample-data │ ├── express-route-burst-warning.json │ ├── express-route-primary-resolved.json │ ├── express-route-secondary-resolved.json │ ├── express-route-primary-alert.json │ ├── express-route-secondary-alert.json │ ├── adaptiveCardExampleFormat.json │ ├── adaptiveCardTeamsWebhookPostExample.json │ └── service-health-alert.json ├── .editorConfig ├── .funcignore ├── .github ├── workflows │ ├── dependabot-auto-merge.yml │ ├── dev-build-and-deploy.yml │ └── build-and-deploy.yml └── dependabot.yml ├── host.json ├── .gitignore ├── lib ├── cards │ ├── simple.js │ ├── express-route-metric-burst-alert.js │ ├── express-route-log-query-burst-alert.js │ ├── express-route-alert.js │ ├── app-insights-log-query-alert.js │ └── service-health-alert.js ├── express-route.js ├── helpers.js └── storage.js ├── example.local.settings.json ├── LICENSE ├── biome.json ├── package.json ├── CODE_OF_CONDUCT.md ├── src └── alert-endpoint.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | @mikesprague 2 | @CU-CommunityApps/cloud-team 3 | -------------------------------------------------------------------------------- /assets/readme-images/log-query-alert-sev1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/log-query-alert-sev1.png -------------------------------------------------------------------------------- /assets/readme-images/log-query-alert-sev2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/log-query-alert-sev2.png -------------------------------------------------------------------------------- /assets/readme-images/service-health-alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/service-health-alert.png -------------------------------------------------------------------------------- /assets/readme-images/express-route-alert-one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/express-route-alert-one.png -------------------------------------------------------------------------------- /assets/readme-images/express-route-alert-two.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/express-route-alert-two.png -------------------------------------------------------------------------------- /assets/readme-images/log-query-alert-sev3-sev4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/log-query-alert-sev3-sev4.png -------------------------------------------------------------------------------- /assets/readme-images/express-route-resolved-one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/express-route-resolved-one.png -------------------------------------------------------------------------------- /assets/readme-images/express-route-resolved-two.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/express-route-resolved-two.png -------------------------------------------------------------------------------- /assets/readme-images/service-health-alert-expanded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/service-health-alert-expanded.png -------------------------------------------------------------------------------- /assets/readme-images/express-route-burst-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/express-route-burst-notification.png -------------------------------------------------------------------------------- /assets/readme-images/express-route-burst-resolved-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cu-cit-cloud-team/az-common-alert-endpoint/HEAD/assets/readme-images/express-route-burst-resolved-notification.png -------------------------------------------------------------------------------- /.editorConfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.funcignore: -------------------------------------------------------------------------------- 1 | node_modules/azure-functions-core-tools 2 | .git/ 3 | .history/ 4 | .vscode/ 5 | *.js.map 6 | *.ts 7 | .git* 8 | local.settings.json 9 | tsconfig.json 10 | .editorConfig 11 | .nvmrc 12 | README.md 13 | *.code-workspace 14 | scratch.js 15 | .devcontainer 16 | assets/ 17 | example.local.settings.json 18 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: 🤖 Dependabot auto-merge 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | call-auto-merge-workflow: 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | uses: mikesprague/reusable-workflows/.github/workflows/dependabot-auto-merge.yml@main 13 | secrets: 14 | REPO_TOKEN: ${{ secrets.GITHUB_TOKEN}} 15 | 16 | -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | }, 10 | "console": { 11 | "isEnabled": true 12 | } 13 | }, 14 | "extensionBundle": { 15 | "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", 16 | "version": "[4.*, 5.0.0)" 17 | }, 18 | "functionTimeout": "00:10:00", 19 | "healthMonitor": { 20 | "enabled": true, 21 | "healthCheckInterval": "00:00:10", 22 | "healthCheckWindow": "00:02:00", 23 | "healthCheckThreshold": 6, 24 | "counterThreshold": 0.8 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | csx 4 | .vs 5 | edge 6 | Publish 7 | 8 | *.user 9 | *.suo 10 | *.cscfg 11 | *.Cache 12 | project.lock.json 13 | 14 | /packages 15 | /TestResults 16 | 17 | /tools/NuGet.exe 18 | /App_Data 19 | /secrets 20 | /data 21 | .secrets 22 | appsettings.json 23 | local.settings.json 24 | 25 | node_modules 26 | dist 27 | 28 | # Local python packages 29 | .python_packages/ 30 | 31 | # Python Environments 32 | .env 33 | .venv 34 | env/ 35 | venv/ 36 | ENV/ 37 | env.bak/ 38 | venv.bak/ 39 | 40 | # Byte-compiled / optimized / DLL files 41 | __pycache__/ 42 | *.py[cod] 43 | *$py.class 44 | 45 | # VS Code stuff 46 | .history 47 | .vscode 48 | 49 | # misc 50 | assets/sample-data/app-insights-log-query*.json 51 | scratch.js 52 | -------------------------------------------------------------------------------- /lib/cards/simple.js: -------------------------------------------------------------------------------- 1 | import { assembleAdaptiveCard } from '../helpers.js'; 2 | 3 | export const messageCard = ({ title, text, color = 'emphasis' }) => { 4 | const titleContent = { 5 | type: 'Container', 6 | items: [ 7 | { 8 | type: 'TextBlock', 9 | text: title, 10 | wrap: true, 11 | size: 'Large', 12 | weight: 'Bolder', 13 | }, 14 | ], 15 | style: color, 16 | bleed: true, 17 | }; 18 | const bodyContent = { 19 | type: 'TextBlock', 20 | text, 21 | fontType: 'Monospace', 22 | wrap: true, 23 | height: 'stretch', 24 | }; 25 | 26 | const cardTemplate = assembleAdaptiveCard([titleContent, bodyContent]); 27 | 28 | return cardTemplate; 29 | }; 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # maintain GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | time: "07:00" 9 | timezone: "America/New_York" 10 | target-branch: "dev" 11 | commit-message: 12 | prefix: "chore" 13 | prefix-development: "chore" 14 | include: "scope" 15 | # maintain npm dependencies 16 | - package-ecosystem: "npm" 17 | directory: "/" 18 | schedule: 19 | interval: "daily" 20 | time: "07:00" 21 | timezone: "America/New_York" 22 | target-branch: "dev" 23 | commit-message: 24 | prefix: "chore" 25 | prefix-development: "chore" 26 | include: "scope" 27 | 28 | -------------------------------------------------------------------------------- /example.local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "APPINSIGHTS_INSTRUMENTATIONKEY": "", 5 | "APPLICATIONINSIGHTS_CONNECTION_STRING": "", 6 | "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING": "", 7 | "WEBSITE_CONTENTSHARE": "", 8 | "AzureWebJobsStorage": "", 9 | "AzureWebJobsSecretStorageType": "Blob", 10 | "FUNCTIONS_EXTENSION_VERSION": "~4", 11 | "FUNCTIONS_WORKER_RUNTIME": "node", 12 | "WEBSITE_NODE_DEFAULT_VERSION": "~20", 13 | "WEBSITE_RUN_FROM_PACKAGE": "1", 14 | "WEBSITE_TIME_ZONE": "Eastern Standard Time", 15 | "MS_TEAMS_ALERT_WEBHOOK_URL": "", 16 | "MS_TEAMS_DEV_WEBHOOK_URL": "", 17 | "MS_TEAMS_NOTIFICATION_WEBHOOK_URL": "", 18 | "BLOB_CONTAINER_NAME": "functions-data-dev", 19 | "NODE_ENV": "development", 20 | "languageWorkers:node:arguments": "--inspect=5858" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Michael Sprague 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", 3 | "files": { 4 | "includes": [ 5 | "**", 6 | "!**/.git/**/*", 7 | "!**/.github/**/*", 8 | "!**/.history/**/*", 9 | "!**/.vscode/**/*", 10 | "!**/node_modules/**/*", 11 | "!**/assets/**/*" 12 | ] 13 | }, 14 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 15 | "linter": { 16 | "enabled": true, 17 | "rules": { 18 | "recommended": true, 19 | "style": { 20 | "useBlockStatements": "error", 21 | "noParameterAssign": "error", 22 | "useAsConstAssertion": "error", 23 | "useDefaultParameterLast": "error", 24 | "useEnumInitializers": "error", 25 | "useSelfClosingElements": "error", 26 | "useSingleVarDeclarator": "error", 27 | "noUnusedTemplateLiteral": "error", 28 | "useNumberNamespace": "error", 29 | "noInferrableTypes": "error", 30 | "noUselessElse": "error" 31 | } 32 | } 33 | }, 34 | "formatter": { 35 | "enabled": true, 36 | "formatWithErrors": true, 37 | "indentStyle": "space", 38 | "indentWidth": 2, 39 | "lineWidth": 80 40 | }, 41 | "javascript": { 42 | "formatter": { 43 | "quoteProperties": "preserve", 44 | "arrowParentheses": "always", 45 | "jsxQuoteStyle": "double", 46 | "quoteStyle": "single", 47 | "semicolons": "always", 48 | "trailingCommas": "es5" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ct-az-common-alert-endpoint", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/alert-endpoint.js", 7 | "description": "Azure Function for an HTTP endpoint to receive Azure Monitor alerts that use the Common Alert Schema", 8 | "scripts": { 9 | "func": "cross-env NODE_ENV=development LOCAL_DEV=true func start", 10 | "func:verbose": "cross-env NODE_ENV=development LOCAL_DEV=true func start --verbose", 11 | "scratch": "cross-env NODE_ENV=development LOCAL_DEV=true node --trace-warnings ./scratch.js", 12 | "test": "echo \"No tests yet...\"" 13 | }, 14 | "author": { 15 | "name": "Michael Sprague", 16 | "email": "ms388@cornell.edu" 17 | }, 18 | "license": "MIT", 19 | "repository": { 20 | "url": "https://github.com/cu-cit-cloud-team/az-common-alert-endpoint/" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/cu-cit-cloud-team/az-common-alert-endpoint/issues" 24 | }, 25 | "homepage": "https://github.com/cu-cit-cloud-team/az-common-alert-endpoint#readme", 26 | "engines": { 27 | "node": ">= 22.x", 28 | "npm": ">= 10.x" 29 | }, 30 | "dependencies": { 31 | "@azure/functions": "4.8.0", 32 | "@azure/storage-blob": "12.28.0", 33 | "axios": "1.12.2", 34 | "dayjs": "1.11.18", 35 | "dotenv": "17.2.2", 36 | "turndown": "7.2.1" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "2.2.4", 40 | "azure-functions-core-tools": "4.2.2", 41 | "cross-env": "10.0.0", 42 | "cz-git": "1.12.0" 43 | }, 44 | "config": { 45 | "commitizen": { 46 | "path": "./node_modules/cz-git" 47 | } 48 | }, 49 | "volta": { 50 | "node": "22.19.0", 51 | "npm": "11.6.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /assets/sample-data/express-route-burst-warning.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaId": "azureMonitorCommonAlertSchema", 3 | "data": { 4 | "essentials": { 5 | "alertId": "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/providers/Microsoft.AlertsManagement/alerts/30907719-a06d-4790-8b7e-e399d4c66d6f", 6 | "alertRule": "bits out greater than 50mbps", 7 | "severity": "Sev3", 8 | "signalType": "Metric", 9 | "monitorCondition": "Fired", 10 | "monitoringService": "Platform", 11 | "alertTargetIDs": [ 12 | "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/resourcegroups/expressroute-1-rg/providers/microsoft.network/expressroutecircuits/expressroute-1" 13 | ], 14 | "configurationItems": ["expressroute-1"], 15 | "originAlertId": "30d34e13-de0b-4a7c-9764-ddafc94199b1_expressroute-1-rg_microsoft.insights_metricAlerts_bits out greater than 50mbps_-1438826534", 16 | "firedDateTime": "2022-01-06T16:46:40.9510418Z", 17 | "description": "Whenever the maximum bitsoutpersecond is greater than 50000000 bits/second", 18 | "essentialsVersion": "1.0", 19 | "alertContextVersion": "1.0" 20 | }, 21 | "alertContext": { 22 | "properties": null, 23 | "conditionType": "SingleResourceMultipleMetricCriteria", 24 | "condition": { 25 | "windowSize": "PT5M", 26 | "allOf": [ 27 | { 28 | "metricName": "BitsOutPerSecond", 29 | "metricNamespace": "Microsoft.Network/expressRouteCircuits", 30 | "operator": "GreaterThan", 31 | "threshold": "50000000", 32 | "timeAggregation": "Maximum", 33 | "dimensions": [], 34 | "metricValue": 60462482, 35 | "webTestName": null 36 | } 37 | ], 38 | "windowStartTime": "2022-01-06T16:38:30.864Z", 39 | "windowEndTime": "2022-01-06T16:43:30.864Z" 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /assets/sample-data/express-route-primary-resolved.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaId": "azureMonitorCommonAlertSchema", 3 | "data": { 4 | "essentials": { 5 | "alertId": "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/providers/Microsoft.AlertsManagement/alerts/91e53143-9914-472c-932c-a21db54972f9", 6 | "alertRule": "ER BGP availability less than 100", 7 | "severity": "Sev3", 8 | "signalType": "Metric", 9 | "monitorCondition": "Resolved", 10 | "monitoringService": "Platform", 11 | "alertTargetIDs": [ 12 | "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/resourcegroups/expressroute-1-rg/providers/microsoft.network/expressroutecircuits/expressroute-1" 13 | ], 14 | "configurationItems": ["expressroute-1"], 15 | "originAlertId": "30d34e13-de0b-4a7c-9764-ddafc94199b1_expressroute-1-rg_microsoft.insights_metricAlerts_ER BGP availability less than 100_-138627915", 16 | "firedDateTime": "2021-11-03T06:40:16.9703852Z", 17 | "resolvedDateTime": "2021-11-03T13:42:19.8182846Z", 18 | "description": "ExpressRoute BGP availability less than 100%", 19 | "essentialsVersion": "1.0", 20 | "alertContextVersion": "1.0" 21 | }, 22 | "alertContext": { 23 | "properties": null, 24 | "conditionType": "SingleResourceMultipleMetricCriteria", 25 | "condition": { 26 | "windowSize": "PT5M", 27 | "allOf": [ 28 | { 29 | "metricName": "BgpAvailability", 30 | "metricNamespace": "Microsoft.Network/expressRouteCircuits", 31 | "operator": "LessThan", 32 | "threshold": "100", 33 | "timeAggregation": "Average", 34 | "dimensions": [ 35 | { "name": "PeeringType", "value": "Private" }, 36 | { "name": "Peer", "value": "Primary-IPv4" } 37 | ], 38 | "metricValue": 100, 39 | "webTestName": null 40 | } 41 | ], 42 | "windowStartTime": "2021-11-03T13:34:10.829Z", 43 | "windowEndTime": "2021-11-03T13:39:10.829Z" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/sample-data/express-route-secondary-resolved.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaId": "azureMonitorCommonAlertSchema", 3 | "data": { 4 | "essentials": { 5 | "alertId": "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/providers/Microsoft.AlertsManagement/alerts/91e53143-9914-472c-932c-a21db54972f9", 6 | "alertRule": "ER BGP availability less than 100", 7 | "severity": "Sev3", 8 | "signalType": "Metric", 9 | "monitorCondition": "Resolved", 10 | "monitoringService": "Platform", 11 | "alertTargetIDs": [ 12 | "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/resourcegroups/expressroute-1-rg/providers/microsoft.network/expressroutecircuits/expressroute-1" 13 | ], 14 | "configurationItems": ["expressroute-1"], 15 | "originAlertId": "30d34e13-de0b-4a7c-9764-ddafc94199b1_expressroute-1-rg_microsoft.insights_metricAlerts_ER BGP availability less than 100_-711320189", 16 | "firedDateTime": "2021-11-03T08:15:18.6892387Z", 17 | "resolvedDateTime": "2021-11-03T13:36:19.6853003Z", 18 | "description": "ExpressRoute BGP availability less than 100%", 19 | "essentialsVersion": "1.0", 20 | "alertContextVersion": "1.0" 21 | }, 22 | "alertContext": { 23 | "properties": null, 24 | "conditionType": "SingleResourceMultipleMetricCriteria", 25 | "condition": { 26 | "windowSize": "PT5M", 27 | "allOf": [ 28 | { 29 | "metricName": "BgpAvailability", 30 | "metricNamespace": "Microsoft.Network/expressRouteCircuits", 31 | "operator": "LessThan", 32 | "threshold": "100", 33 | "timeAggregation": "Average", 34 | "dimensions": [ 35 | { "name": "PeeringType", "value": "Private" }, 36 | { "name": "Peer", "value": "Secondary-IPv4" } 37 | ], 38 | "metricValue": 100, 39 | "webTestName": null 40 | } 41 | ], 42 | "windowStartTime": "2021-11-03T13:28:10.771Z", 43 | "windowEndTime": "2021-11-03T13:33:10.771Z" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/sample-data/express-route-primary-alert.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaId": "azureMonitorCommonAlertSchema", 3 | "data": { 4 | "essentials": { 5 | "alertId": "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/providers/Microsoft.AlertsManagement/alerts/91e53143-9914-472c-932c-a21db54972f9", 6 | "alertRule": "ER BGP availability less than 100", 7 | "severity": "Sev3", 8 | "signalType": "Metric", 9 | "monitorCondition": "Fired", 10 | "monitoringService": "Platform", 11 | "alertTargetIDs": [ 12 | "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/resourcegroups/expressroute-1-rg/providers/microsoft.network/expressroutecircuits/expressroute-1" 13 | ], 14 | "configurationItems": ["expressroute-1"], 15 | "originAlertId": "30d34e13-de0b-4a7c-9764-ddafc94199b1_expressroute-1-rg_microsoft.insights_metricAlerts_ER BGP availability less than 100_-138627915", 16 | "firedDateTime": "2021-11-03T06:40:16.9703852Z", 17 | "description": "ExpressRoute BGP availability less than 100%", 18 | "essentialsVersion": "1.0", 19 | "alertContextVersion": "1.0" 20 | }, 21 | "alertContext": { 22 | "properties": null, 23 | "conditionType": "SingleResourceMultipleMetricCriteria", 24 | "condition": { 25 | "windowSize": "PT5M", 26 | "allOf": [ 27 | { 28 | "metricName": "BgpAvailability", 29 | "metricNamespace": "Microsoft.Network/expressRouteCircuits", 30 | "operator": "LessThan", 31 | "threshold": "100", 32 | "timeAggregation": "Average", 33 | "dimensions": [ 34 | { 35 | "name": "PeeringType", 36 | "value": "Private" 37 | }, 38 | { 39 | "name": "Peer", 40 | "value": "Primary-IPv4" 41 | } 42 | ], 43 | "metricValue": 80, 44 | "webTestName": null 45 | } 46 | ], 47 | "windowStartTime": "2021-11-03T06:32:07.199Z", 48 | "windowEndTime": "2021-11-03T06:37:07.199Z" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /assets/sample-data/express-route-secondary-alert.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaId": "azureMonitorCommonAlertSchema", 3 | "data": { 4 | "essentials": { 5 | "alertId": "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/providers/Microsoft.AlertsManagement/alerts/91e53143-9914-472c-932c-a21db54972f9", 6 | "alertRule": "ER BGP availability less than 100", 7 | "severity": "Sev3", 8 | "signalType": "Metric", 9 | "monitorCondition": "Fired", 10 | "monitoringService": "Platform", 11 | "alertTargetIDs": [ 12 | "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/resourcegroups/expressroute-1-rg/providers/microsoft.network/expressroutecircuits/expressroute-1" 13 | ], 14 | "configurationItems": ["expressroute-1"], 15 | "originAlertId": "30d34e13-de0b-4a7c-9764-ddafc94199b1_expressroute-1-rg_microsoft.insights_metricAlerts_ER BGP availability less than 100_-711320189", 16 | "firedDateTime": "2021-11-03T08:15:18.6892387Z", 17 | "description": "ExpressRoute BGP availability less than 100%", 18 | "essentialsVersion": "1.0", 19 | "alertContextVersion": "1.0" 20 | }, 21 | "alertContext": { 22 | "properties": null, 23 | "conditionType": "SingleResourceMultipleMetricCriteria", 24 | "condition": { 25 | "windowSize": "PT5M", 26 | "allOf": [ 27 | { 28 | "metricName": "BgpAvailability", 29 | "metricNamespace": "Microsoft.Network/expressRouteCircuits", 30 | "operator": "LessThan", 31 | "threshold": "100", 32 | "timeAggregation": "Average", 33 | "dimensions": [ 34 | { 35 | "name": "PeeringType", 36 | "value": "Private" 37 | }, 38 | { 39 | "name": "Peer", 40 | "value": "Secondary-IPv4" 41 | } 42 | ], 43 | "metricValue": 80, 44 | "webTestName": null 45 | } 46 | ], 47 | "windowStartTime": "2021-11-03T08:07:11.808Z", 48 | "windowEndTime": "2021-11-03T08:12:11.808Z" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/cards/express-route-metric-burst-alert.js: -------------------------------------------------------------------------------- 1 | import { 2 | assembleAdaptiveCard, 3 | getEmoji, 4 | localizeDateTime, 5 | } from '../helpers.js'; 6 | 7 | export const messageCard = async (alertData) => { 8 | const { firedDateTime, alertId, monitorCondition, alertTargetIDs } = 9 | alertData.essentials; 10 | const { metricName, operator, threshold, metricValue } = 11 | alertData.alertContext.condition.allOf[0]; 12 | const title = 'Azure ExpressRoute Burst Alert'; 13 | const timestamp = localizeDateTime(firedDateTime); 14 | const subscriptionId = alertId.split('/')[2]; 15 | // AdaptiveCard color strings: good = green, accent = blue, warning = yellow, attention = red, emphasis = gray 16 | let color = 'warning'; 17 | const description = `${metricName} ${operator} ${ 18 | Number(threshold) / 1000000 19 | }mbps`; 20 | if (monitorCondition.toLowerCase() === 'resolved') { 21 | color = 'good'; 22 | } 23 | 24 | const headerContent = { 25 | type: 'Container', 26 | style: color, 27 | bleed: true, 28 | spacing: 'None', 29 | items: [ 30 | { 31 | type: 'TextBlock', 32 | size: 'large', 33 | weight: 'bolder', 34 | spacing: 'none', 35 | text: process.env.NODE_ENV === 'development' ? `DEV ${title}` : title, 36 | }, 37 | ], 38 | }; 39 | 40 | const accentEmoji = getEmoji(color); 41 | const descriptionContent = { 42 | type: 'TextBlock', 43 | text: `${accentEmoji}${description}`, 44 | size: 'Large', 45 | wrap: true, 46 | }; 47 | 48 | const factSetContent = { 49 | type: 'FactSet', 50 | facts: [ 51 | { 52 | title: `${metricName}:`, 53 | value: `${Math.round( 54 | Number(metricValue) / 1000000 55 | )}mbps (${metricValue})`, 56 | }, 57 | { 58 | title: 'Alert State:', 59 | value: monitorCondition, 60 | }, 61 | { 62 | title: 'Alert Timestamp:', 63 | value: timestamp, 64 | }, 65 | { 66 | title: 'Subscription ID:', 67 | value: subscriptionId, 68 | }, 69 | ], 70 | height: 'stretch', 71 | }; 72 | 73 | const actionSetContent = { 74 | type: 'ActionSet', 75 | actions: [ 76 | { 77 | type: 'Action.OpenUrl', 78 | title: 'View Alerts in Azure Portal', 79 | url: `https://portal.azure.com/#resource${alertTargetIDs[0]}/alerts`, 80 | }, 81 | ], 82 | }; 83 | 84 | const cardTemplate = assembleAdaptiveCard([ 85 | headerContent, 86 | descriptionContent, 87 | factSetContent, 88 | actionSetContent, 89 | ]); 90 | 91 | return cardTemplate; 92 | }; 93 | -------------------------------------------------------------------------------- /lib/cards/express-route-log-query-burst-alert.js: -------------------------------------------------------------------------------- 1 | import { 2 | assembleAdaptiveCard, 3 | getEmoji, 4 | localizeDateTime, 5 | } from '../helpers.js'; 6 | 7 | export const messageCard = async (alertData) => { 8 | const { 9 | firedDateTime, 10 | resolvedDateTime, 11 | alertId, 12 | monitorCondition, 13 | alertTargetIDs, 14 | } = alertData.data.essentials; 15 | const { 16 | linkToSearchResultsUI, 17 | metricMeasureColumn, 18 | metricValue, 19 | operator, 20 | threshold, 21 | timeAggregation, 22 | } = alertData.data.alertContext.condition.allOf[0]; 23 | 24 | const title = 'Azure ExpressRoute Burst Notification'; 25 | const timestamp = 26 | monitorCondition.toLowerCase() === 'resolved' 27 | ? localizeDateTime({ date: resolvedDateTime }) 28 | : localizeDateTime({ date: firedDateTime }); 29 | 30 | const subscriptionId = alertId.split('/')[2]; 31 | 32 | const description = `${timeAggregation} ${metricMeasureColumn} ${operator} ${ 33 | Number(threshold) / 1000000 34 | }mbps`; 35 | 36 | // AdaptiveCard color strings: good = green, accent = blue, warning = yellow, attention = red, emphasis = gray 37 | let color = 'warning'; 38 | if (monitorCondition.toLowerCase() === 'resolved') { 39 | color = 'good'; 40 | } 41 | 42 | const accentEmoji = getEmoji(color); 43 | 44 | const headerContent = { 45 | type: 'Container', 46 | style: color, 47 | bleed: true, 48 | spacing: 'None', 49 | items: [ 50 | { 51 | type: 'TextBlock', 52 | size: 'large', 53 | weight: 'bolder', 54 | spacing: 'none', 55 | text: 56 | process.env.NODE_ENV === 'development' 57 | ? `DEV ${accentEmoji}${title}` 58 | : `${accentEmoji}${title}`, 59 | }, 60 | ], 61 | }; 62 | 63 | const descriptionContent = { 64 | type: 'TextBlock', 65 | text: description, 66 | size: 'Large', 67 | wrap: true, 68 | }; 69 | 70 | const metricFact = 71 | monitorCondition.toLowerCase() === 'resolved' 72 | ? { title: '', value: '' } 73 | : { 74 | title: `${metricMeasureColumn}:`, 75 | value: `${Math.round( 76 | Number(metricValue) / 1000000 77 | )}mbps (${metricValue})`, 78 | }; 79 | 80 | const factSetContent = { 81 | type: 'FactSet', 82 | facts: [ 83 | { 84 | title: 'Alert State:', 85 | value: monitorCondition, 86 | }, 87 | metricFact, 88 | { 89 | title: 'Alert Timestamp:', 90 | value: timestamp, 91 | }, 92 | { 93 | title: 'Subscription ID:', 94 | value: subscriptionId, 95 | }, 96 | ], 97 | height: 'stretch', 98 | }; 99 | 100 | const actionSetContent = { 101 | type: 'ActionSet', 102 | actions: [ 103 | { 104 | type: 'Action.OpenUrl', 105 | title: 'View Alerts in Azure Portal', 106 | url: `https://portal.azure.com/#resource${alertTargetIDs[0]}/alerts`, 107 | }, 108 | { 109 | type: 'Action.OpenUrl', 110 | title: 'View Log Query Search Results', 111 | url: linkToSearchResultsUI, 112 | }, 113 | ], 114 | }; 115 | 116 | const cardTemplate = assembleAdaptiveCard([ 117 | headerContent, 118 | descriptionContent, 119 | factSetContent, 120 | actionSetContent, 121 | ]); 122 | 123 | return cardTemplate; 124 | }; 125 | -------------------------------------------------------------------------------- /lib/cards/express-route-alert.js: -------------------------------------------------------------------------------- 1 | import { 2 | assembleAdaptiveCard, 3 | getEmoji, 4 | localizeDateTime, 5 | } from '../helpers.js'; 6 | 7 | import { initPeersStatus, setPeerStatus, whichPeer } from '../express-route.js'; 8 | 9 | export const messageCard = async (alertData) => { 10 | await initPeersStatus(); 11 | const { condition } = alertData.alertContext; 12 | const { 13 | firedDateTime, 14 | alertId, 15 | monitorCondition, 16 | alertTargetIDs, 17 | alertRule, 18 | } = alertData.essentials; 19 | 20 | const title = 'Azure ExpressRoute Alert'; 21 | const timestamp = localizeDateTime(firedDateTime); 22 | const subscriptionId = alertId.split('/')[2]; 23 | 24 | const newStatus = monitorCondition.toLowerCase() === 'resolved'; 25 | const peer = whichPeer(condition.allOf[0].dimensions); 26 | const updatedStatus = JSON.parse( 27 | await setPeerStatus({ peer, status: newStatus }) 28 | ); 29 | const peersKeys = Object.keys(updatedStatus); 30 | const totalPeers = peersKeys.length; 31 | const peersDown = peersKeys.filter( 32 | (peerKey) => updatedStatus[peerKey] !== true 33 | ); 34 | 35 | // AdaptiveCard color strings: good = green, accent = blue, warning = yellow, attention = red, emphasis = gray 36 | let color = 'accent'; 37 | let description = ''; 38 | if (monitorCondition.toLowerCase() === 'resolved' && !peersDown.length) { 39 | color = 'good'; 40 | description = `${peer} UP (all peers operating normally)`; 41 | } 42 | if (peersDown.length) { 43 | color = 'warning'; 44 | description = `${peer} ${ 45 | monitorCondition.toLowerCase() === 'resolved' ? 'UP' : 'DOWN' 46 | } (${peersDown.length} of ${totalPeers} peers down)`; 47 | if (peersDown.length === totalPeers) { 48 | color = 'attention'; 49 | } 50 | } 51 | 52 | const accentEmoji = getEmoji(color); 53 | 54 | const headerContent = { 55 | type: 'Container', 56 | style: color, 57 | bleed: true, 58 | spacing: 'None', 59 | items: [ 60 | { 61 | type: 'TextBlock', 62 | size: 'large', 63 | weight: 'bolder', 64 | spacing: 'none', 65 | text: 66 | process.env.NODE_ENV === 'development' 67 | ? `DEV ${accentEmoji}${title}` 68 | : `${accentEmoji}${title}`, 69 | }, 70 | ], 71 | }; 72 | 73 | const descriptionContent = { 74 | type: 'TextBlock', 75 | text: description, 76 | size: 'Large', 77 | wrap: true, 78 | }; 79 | 80 | const factSetContent = { 81 | type: 'FactSet', 82 | facts: [ 83 | { 84 | title: 'Affected Peer:', 85 | value: peer, 86 | }, 87 | { 88 | title: 'Alert Rule:', 89 | value: alertRule, 90 | }, 91 | { 92 | title: 'Alert State:', 93 | value: monitorCondition, 94 | }, 95 | { 96 | title: 'Alert Timestamp:', 97 | value: timestamp, 98 | }, 99 | { 100 | title: 'Subscription ID:', 101 | value: subscriptionId, 102 | }, 103 | ], 104 | height: 'stretch', 105 | }; 106 | 107 | const actionSetContent = { 108 | type: 'ActionSet', 109 | actions: [ 110 | { 111 | type: 'Action.OpenUrl', 112 | title: 'View Alerts in Azure Portal', 113 | url: `https://portal.azure.com/#resource${alertTargetIDs[0]}/alerts`, 114 | }, 115 | ], 116 | }; 117 | 118 | const cardTemplate = assembleAdaptiveCard([ 119 | headerContent, 120 | descriptionContent, 121 | factSetContent, 122 | actionSetContent, 123 | ]); 124 | 125 | return cardTemplate; 126 | }; 127 | -------------------------------------------------------------------------------- /lib/express-route.js: -------------------------------------------------------------------------------- 1 | import { 2 | getBlobContainerClient, 3 | getBlobContent, 4 | getBlobServiceClient, 5 | storageDefaults, 6 | uploadFile, 7 | } from '../lib/storage.js'; 8 | 9 | const { AzureWebJobsStorage, BLOB_CONTAINER_NAME } = process.env; 10 | 11 | /** 12 | * isExpressRouteAlert 13 | * @summary returns true if the alertId indicates it's for the expressroute 14 | * @param {Array} targetIds 15 | * @returns {boolean} 16 | */ 17 | export const isExpressRouteAlert = (targetIds) => { 18 | const result = targetIds.filter((targetId) => 19 | targetId.toLowerCase().includes('expressroutecircuits') 20 | ); 21 | return Boolean(result.length); 22 | }; 23 | 24 | /** 25 | * whichPeer 26 | * @summary returns the peer that fired the alert 27 | * @param {Array} dimensions 28 | * @returns {string} (e.g. "Primary-IPv4" or "Secondary-IPv4") 29 | */ 30 | export const whichPeer = (dimensions) => { 31 | const [result] = dimensions.filter( 32 | (dimension) => dimension.name.toLowerCase() === 'peer' 33 | ); 34 | return result.value; 35 | }; 36 | 37 | /** 38 | * getPeersStatus 39 | * @summary returns data from peers status file 40 | * @returns {string} 41 | */ 42 | export const getPeersStatus = async () => { 43 | const blobServiceClient = await getBlobServiceClient(AzureWebJobsStorage); 44 | const containerClient = await getBlobContainerClient( 45 | blobServiceClient, 46 | BLOB_CONTAINER_NAME || storageDefaults.blobContainerName 47 | ); 48 | const blobContent = await getBlobContent( 49 | containerClient, 50 | storageDefaults.peersFileName 51 | ); 52 | return blobContent; 53 | }; 54 | 55 | /** 56 | * getPeerStatus 57 | * @summary returns status of an individual peer from peers status file 58 | * @param {Object} [peersData=null] 59 | * @param {string} [peer='Primary-IPv4'] 60 | * @returns {Boolean} true/false === up/down 61 | */ 62 | export const getPeerStatus = async ({ 63 | peersData = null, 64 | peer = 'Primary-IPv4', 65 | }) => { 66 | if (!peersData) { 67 | peersData = await getPeersStatus(); 68 | } 69 | return peersData[peer]; 70 | }; 71 | 72 | /** 73 | * setPeersStatus 74 | * @summary sets the full data object in peers status file 75 | * @param {Object} peersData 76 | * @returns {void} 77 | */ 78 | export const setPeersStatus = async (peersData) => { 79 | await uploadFile({ 80 | storageConnectionString: AzureWebJobsStorage, 81 | blobContainerName: BLOB_CONTAINER_NAME || storageDefaults.blobContainerName, 82 | fileContent: peersData, 83 | fileName: storageDefaults.peersFileName, 84 | }); 85 | }; 86 | 87 | /** 88 | * setPeerStatus 89 | * @summary updates status of an individual peer in peer status file 90 | * @param {string} peer 91 | * @param {boolean} status 92 | * @param {Object} [peersData=null] 93 | * @returns {Object} peers status data object 94 | */ 95 | export const setPeerStatus = async ({ peer, status, peersData = null }) => { 96 | if (!peersData || peersData === undefined) { 97 | peersData = JSON.parse(await getPeersStatus()); 98 | } 99 | peersData[peer] = Boolean(status); 100 | await setPeersStatus(peersData); 101 | const updatedStatus = await getPeersStatus(); 102 | return updatedStatus; 103 | }; 104 | 105 | /** 106 | * initPeersStatus 107 | * @summary looks for peer status file and creates one if not there 108 | * @returns {void} 109 | */ 110 | export const initPeersStatus = async () => { 111 | try { 112 | const currentStatus = await getPeersStatus(); 113 | return currentStatus; 114 | } catch (error) { 115 | await setPeersStatus(storageDefaults.peersDefaultContent); 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import timezone from 'dayjs/plugin/timezone.js'; 3 | import utc from 'dayjs/plugin/utc.js'; 4 | import dotenv from 'dotenv'; 5 | 6 | dotenv.config(); 7 | 8 | let { NOTIFICATION_TIMEZONE } = process.env; 9 | if (!NOTIFICATION_TIMEZONE) { 10 | NOTIFICATION_TIMEZONE = 'America/New_York'; 11 | } 12 | 13 | dayjs.extend(utc); 14 | dayjs.extend(timezone); 15 | dayjs.tz.setDefault(NOTIFICATION_TIMEZONE); 16 | 17 | /** 18 | * getAdaptiveCardTemplate 19 | * @summary returns object with basic structure for an AdaptiveCard 20 | * @returns {Object} 21 | */ 22 | export const getAdaptiveCardTemplate = () => ({ 23 | type: 'message', 24 | attachments: [ 25 | { 26 | contentType: 'application/vnd.microsoft.card.adaptive', 27 | contentUrl: null, 28 | content: { 29 | msteams: { 30 | width: 'Full', 31 | }, 32 | $schema: 'https://adaptivecards.io/schemas/adaptive-card.json', 33 | type: 'AdaptiveCard', 34 | version: '1.5', 35 | body: [], 36 | }, 37 | }, 38 | ], 39 | }); 40 | 41 | /** 42 | * assembleAdaptiveCard 43 | * @summary assembles and returns a full AdaptiveCard object 44 | * @param {Array} cardSectionsArray 45 | * @returns {Object} 46 | */ 47 | export const assembleAdaptiveCard = (cardSectionsArray) => { 48 | const cardTemplate = getAdaptiveCardTemplate(); 49 | cardTemplate.attachments[0].content.body.push(...cardSectionsArray); 50 | return cardTemplate; 51 | }; 52 | 53 | /** 54 | * localizeDateTime 55 | * @summary returns a date/time string formatted for specified timezone 56 | * @param {(Date|null)} [date=null] returns current date/time if null 57 | * @param {string} [tz=America/New_York] 58 | * @param {string} [dateFormat=ddd, D MMM YYYY hh:mm:ss Z] (formatting options: https://day.js.org/docs/en/display/format) 59 | * @returns {string} 60 | */ 61 | export const localizeDateTime = ({ 62 | date = null, 63 | tz = 'America/New_York', 64 | dateFormat = 'ddd, D MMM YYYY hh:mm:ss Z', 65 | }) => 66 | date instanceof Date || (typeof date === 'string' && date !== null) 67 | ? dayjs(date).tz(tz).format(dateFormat) 68 | : dayjs().tz(tz).format(dateFormat); 69 | 70 | /** 71 | * getEmoji 72 | * @summary returns an emoji based on adaptive card color 73 | * @param {string} [adaptiveCardColor=emphasis] 74 | * @returns {string} 75 | */ 76 | export const getEmoji = (adaptiveCardColor = 'emphasis') => { 77 | const emojiList = { 78 | good: '✅ ', 79 | accent: 'ℹ️ ', 80 | warning: '⚠️ ', 81 | attention: '🚨 ', 82 | emphasis: '', 83 | }; 84 | return emojiList[adaptiveCardColor] || ''; 85 | }; 86 | 87 | /** 88 | * getColorBasedOnSev 89 | * @summary returns an adaptive card color based on alert severity 90 | * @param {string} sevString azure severity string (e.g. Sev1, Sev2, Sev3, Sev4) 91 | * @returns {string} 92 | */ 93 | export const getColorBasedOnSev = (sevString) => { 94 | const colorList = { 95 | Sev0: 'attention', 96 | Sev1: 'attention', 97 | Sev2: 'warning', 98 | Sev3: 'accent', 99 | Sev4: 'accent', 100 | }; 101 | return colorList[sevString] || ''; 102 | }; 103 | 104 | /** 105 | * getSevDescription 106 | * @summary returns the Azure description associated with a sev string 107 | * @param {string} sevString azure severity string (e.g. Sev1, Sev2, Sev3, Sev4) 108 | * @returns {string} 109 | */ 110 | export const getSevDescription = (sevString) => { 111 | const colorList = { 112 | Sev0: 'Critical', 113 | Sev1: 'Error', 114 | Sev2: 'Warning', 115 | Sev3: 'Informational', 116 | Sev4: 'Verbose', 117 | }; 118 | return colorList[sevString] || ''; 119 | }; 120 | -------------------------------------------------------------------------------- /.github/workflows/dev-build-and-deploy.yml: -------------------------------------------------------------------------------- 1 | name: DEV Build & Deploy 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: 9 | - dev 10 | paths-ignore: 11 | - "**.md" 12 | - "**.png" 13 | - ".vscode/**" 14 | - ".devcontainer/**" 15 | - "assets/**" 16 | - ".github/dependabot.yml" 17 | - "example.local.settings.json" 18 | 19 | permissions: 20 | contents: read 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.ref }} 24 | cancel-in-progress: true 25 | 26 | env: 27 | NODE_VERSION: 20.x # set this to the node version to use (supports 18.x, 20.x) 28 | DISPLAY_NAME: DEV Azure Alert Endpoint 29 | FUNCTION_APP_NAME: ct-common-alert-endpoint 30 | FUNCTION_APP_SLOT_NAME: dev 31 | FUNCTION_APP_PATH: ./ 32 | 33 | jobs: 34 | build-and-deploy-dev: 35 | runs-on: windows-latest 36 | environment: development 37 | steps: 38 | - name: 📤 Notify Teams 39 | uses: mikesprague/teams-incoming-webhook-action@v1 40 | with: 41 | github-token: ${{ github.token }} 42 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 43 | deploy-card: true 44 | title: ${{ env.DISPLAY_NAME }} Deployment Started 45 | color: info 46 | 47 | - name: 👷 Checkout repo 48 | uses: actions/checkout@v5 49 | 50 | - name: 🏗️ Setup Node.js ${{ env.NODE_VERSION }} environment 51 | uses: actions/setup-node@v5 52 | with: 53 | node-version: ${{ env.NODE_VERSION }} 54 | 55 | - name: ⚡ Cache node_modules 56 | uses: actions/cache@v4 57 | id: cache 58 | with: 59 | path: ${{ env.FUNCTION_APP_PATH }}/node_modules 60 | key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} 61 | restore-keys: | 62 | ${{ runner.os }}-node-${{ env.NODE_VERSION }}- 63 | 64 | - name: ⬆️ Update npm and install dependencies 65 | if: steps.cache.outputs.cache-hit != 'true' 66 | shell: bash 67 | run: npm i -g npm && npm ci --omit=dev 68 | 69 | - name: 🛫 Deploy function app to Azure 70 | uses: Azure/functions-action@v1 71 | with: 72 | app-name: ${{ env.FUNCTION_APP_NAME }} 73 | package: ${{ env.FUNCTION_APP_PATH }} 74 | publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE_DEV }} 75 | # respect-funcignore: true 76 | 77 | - name: ⚠️ Cancelled Notification 78 | if: ${{ cancelled() }} 79 | uses: mikesprague/teams-incoming-webhook-action@v1 80 | with: 81 | github-token: ${{ github.token }} 82 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 83 | deploy-card: true 84 | title: ${{ env.DISPLAY_NAME }} Deployment Cancelled 85 | color: warning 86 | 87 | - name: ⛔ Failure Notification 88 | if: ${{ failure() }} 89 | uses: mikesprague/teams-incoming-webhook-action@v1 90 | with: 91 | github-token: ${{ github.token }} 92 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 93 | deploy-card: true 94 | title: ${{ env.DISPLAY_NAME }} Deployment Failed 95 | color: failure 96 | 97 | - name: 🎉 Success Notification 98 | if: ${{ success() }} 99 | uses: mikesprague/teams-incoming-webhook-action@v1 100 | with: 101 | github-token: ${{ github.token }} 102 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 103 | deploy-card: true 104 | title: ${{ env.DISPLAY_NAME }} Deployment Successful 105 | color: success 106 | -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy.yml: -------------------------------------------------------------------------------- 1 | name: PROD Build & Deploy 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | 7 | pull_request: 8 | types: [closed] 9 | branches: 10 | - main 11 | paths-ignore: 12 | - "**.md" 13 | - "**.png" 14 | - ".vscode/**" 15 | - ".devcontainer/**" 16 | - "assets/**" 17 | - ".github/dependabot.yml" 18 | - "example.local.settings.json" 19 | 20 | permissions: 21 | contents: read 22 | 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.ref }} 25 | cancel-in-progress: true 26 | 27 | env: 28 | NODE_VERSION: 20.x # set this to the node version to use (supports 18.x, 20.x) 29 | DISPLAY_NAME: Azure Alert Endpoint 30 | FUNCTION_APP_NAME: ct-common-alert-endpoint 31 | FUNCTION_APP_SLOT_NAME: production 32 | FUNCTION_APP_PATH: ./ 33 | 34 | jobs: 35 | build-and-deploy-prod: 36 | runs-on: windows-latest 37 | environment: production 38 | steps: 39 | - name: 📤 Notify Teams 40 | uses: mikesprague/teams-incoming-webhook-action@v1 41 | with: 42 | github-token: ${{ github.token }} 43 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 44 | deploy-card: true 45 | title: ${{ env.DISPLAY_NAME }} Deployment Started 46 | color: info 47 | 48 | - name: 👷 Checkout repo 49 | uses: actions/checkout@v5 50 | 51 | - name: 🏗️ Setup Node.js ${{ env.NODE_VERSION }} environment 52 | uses: actions/setup-node@v5 53 | with: 54 | node-version: ${{ env.NODE_VERSION }} 55 | 56 | - name: ⚡ Cache node_modules 57 | uses: actions/cache@v4 58 | id: cache 59 | with: 60 | path: ${{ env.FUNCTION_APP_PATH }}/node_modules 61 | key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} 62 | restore-keys: | 63 | ${{ runner.os }}-node-${{ env.NODE_VERSION }}- 64 | 65 | - name: ⬆️ Update npm and install dependencies 66 | if: steps.cache.outputs.cache-hit != 'true' 67 | shell: bash 68 | run: npm i -g npm && npm ci --omit=dev 69 | 70 | - name: 🛫 Deploy function app to Azure 71 | uses: Azure/functions-action@v1 72 | with: 73 | app-name: ${{ env.FUNCTION_APP_NAME }} 74 | slot-name: ${{ env.FUNCTION_APP_SLOT_NAME }} 75 | package: ${{ env.FUNCTION_APP_PATH }} 76 | publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }} 77 | # respect-funcignore: true 78 | 79 | - name: ⚠️ Cancelled Notification 80 | if: ${{ cancelled() }} 81 | uses: mikesprague/teams-incoming-webhook-action@v1 82 | with: 83 | github-token: ${{ github.token }} 84 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 85 | deploy-card: true 86 | title: ${{ env.DISPLAY_NAME }} Deployment Cancelled 87 | color: warning 88 | 89 | - name: ⛔ Failure Notification 90 | if: ${{ failure() }} 91 | uses: mikesprague/teams-incoming-webhook-action@v1 92 | with: 93 | github-token: ${{ github.token }} 94 | webhook-url: ${{ secrets.MS_TEAMS_ALERT_WEBHOOK_URL }} 95 | deploy-card: true 96 | title: ${{ env.DISPLAY_NAME }} Deployment Failed 97 | color: failure 98 | 99 | - name: 🎉 Success Notification 100 | if: ${{ success() }} 101 | uses: mikesprague/teams-incoming-webhook-action@v1 102 | with: 103 | github-token: ${{ github.token }} 104 | webhook-url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_DEV }} 105 | deploy-card: true 106 | title: ${{ env.DISPLAY_NAME }} Deployment Successful 107 | color: success 108 | -------------------------------------------------------------------------------- /lib/cards/app-insights-log-query-alert.js: -------------------------------------------------------------------------------- 1 | import { 2 | assembleAdaptiveCard, 3 | getColorBasedOnSev, 4 | getEmoji, 5 | getSevDescription, 6 | localizeDateTime, 7 | } from '../helpers.js'; 8 | 9 | export const messageCard = async (alertData) => { 10 | // console.log(alertData); 11 | const { 12 | firedDateTime, 13 | resolvedDateTime, 14 | alertId, 15 | monitorCondition, 16 | alertTargetIDs, 17 | severity, 18 | alertRule, 19 | description, 20 | } = alertData.data.essentials; 21 | 22 | let queryToDisplay = null; 23 | let searchResultsLink = null; 24 | 25 | if (alertData.data.alertContext.SearchQuery) { 26 | queryToDisplay = alertData.data.alertContext.SearchQuery; 27 | searchResultsLink = alertData.data.alertContext.LinkToSearchResults; 28 | } 29 | if (!queryToDisplay && alertData.data.alertContext.condition.allOf) { 30 | queryToDisplay = alertData.data.alertContext.condition.allOf[0].searchQuery; 31 | searchResultsLink = 32 | alertData.data.alertContext.condition.allOf[0].linkToSearchResultsUI; 33 | } 34 | 35 | const title = 'Azure Log Query Alert'; 36 | const titlePrefix = process.env.NODE_ENV === 'development' ? 'DEV ' : ''; 37 | const descriptionText = description.trim().length 38 | ? `${alertRule}: ${description}` 39 | : alertRule; 40 | const severityText = `${getSevDescription(severity)} (${severity})`; 41 | const timestamp = 42 | monitorCondition.toLowerCase() === 'resolved' 43 | ? localizeDateTime({ date: resolvedDateTime }) 44 | : localizeDateTime({ date: firedDateTime }); 45 | const subscriptionId = alertId.split('/')[2]; 46 | // AdaptiveCard color strings: good = green, accent = blue, warning = yellow, attention = red, emphasis = gray 47 | let color = getColorBasedOnSev(severity); 48 | if (!color.trim().length) { 49 | color = 'warning'; 50 | } 51 | if (monitorCondition.toLowerCase() === 'resolved') { 52 | color = 'good'; 53 | } 54 | 55 | const accentEmoji = getEmoji(color); 56 | 57 | const headerContent = { 58 | type: 'Container', 59 | style: color, 60 | bleed: true, 61 | spacing: 'None', 62 | items: [ 63 | { 64 | type: 'TextBlock', 65 | size: 'large', 66 | weight: 'bolder', 67 | spacing: 'none', 68 | text: `${accentEmoji}${titlePrefix}${title}`, 69 | }, 70 | ], 71 | }; 72 | 73 | const descriptionContent = { 74 | type: 'TextBlock', 75 | text: descriptionText, 76 | size: 'Large', 77 | wrap: true, 78 | }; 79 | 80 | const logQueryContent = { 81 | type: 'TextBlock', 82 | text: queryToDisplay, 83 | wrap: true, 84 | fontType: 'Monospace', 85 | }; 86 | 87 | const factSetContent = { 88 | type: 'FactSet', 89 | facts: [ 90 | { 91 | title: 'State:', 92 | value: monitorCondition, 93 | }, 94 | { 95 | title: 'Severity:', 96 | value: severityText, 97 | }, 98 | { 99 | title: 'Timestamp:', 100 | value: timestamp, 101 | }, 102 | { 103 | title: 'Subscription:', 104 | value: subscriptionId, 105 | }, 106 | ], 107 | height: 'stretch', 108 | }; 109 | 110 | const actionSetContent = { 111 | type: 'ActionSet', 112 | actions: [ 113 | { 114 | type: 'Action.OpenUrl', 115 | title: 'View Log Query Search Results', 116 | url: searchResultsLink, 117 | }, 118 | { 119 | type: 'Action.OpenUrl', 120 | title: 'View Alerts in Azure Portal', 121 | url: `https://portal.azure.com/#resource${alertTargetIDs[0]}/alerts`, 122 | }, 123 | ], 124 | }; 125 | 126 | const cardTemplate = assembleAdaptiveCard([ 127 | headerContent, 128 | descriptionContent, 129 | logQueryContent, 130 | factSetContent, 131 | actionSetContent, 132 | ]); 133 | 134 | return cardTemplate; 135 | }; 136 | -------------------------------------------------------------------------------- /lib/storage.js: -------------------------------------------------------------------------------- 1 | import { BlobServiceClient } from '@azure/storage-blob'; 2 | 3 | /** 4 | * storageDefaults 5 | * @summary static object with some defaults 6 | */ 7 | export const storageDefaults = { 8 | blobContainerName: 'functions-data', 9 | peersFileName: 'peers-status.json', 10 | peersDefaultContent: { 11 | 'Primary-IPv4': true, 12 | 'Secondary-IPv4': true, 13 | }, 14 | }; 15 | 16 | /** 17 | * getBlobServiceClient 18 | * @summary async helper that instantiates/returns blob service client 19 | * @param {string} connectionString 20 | * @returns {BlobServiceClient} Azure blob service client 21 | */ 22 | export const getBlobServiceClient = async (connectionString) => { 23 | const blobServiceClient = await BlobServiceClient.fromConnectionString( 24 | connectionString 25 | ); 26 | return blobServiceClient; 27 | }; 28 | 29 | /** 30 | * getBlobContainerClient 31 | * @summary async helper that instantiates/returns blob storage container client 32 | * @param {BlobServiceClient} blobServiceClient 33 | * @param {string} blobContainerName 34 | * @returns {ContainerClient} Azure blob container client 35 | */ 36 | export const getBlobContainerClient = async ( 37 | blobServiceClient, 38 | blobContainerName 39 | ) => { 40 | let containerClient; 41 | 42 | // try connecting to container and create one if it doesn't already exist 43 | try { 44 | containerClient = await blobServiceClient.getContainerClient( 45 | blobContainerName 46 | ); 47 | } catch (error) { 48 | await blobServiceClient.createContainer(blobContainerName); 49 | containerClient = await blobServiceClient.getContainerClient( 50 | blobContainerName 51 | ); 52 | } 53 | 54 | return containerClient; 55 | }; 56 | 57 | /** 58 | * getBlockClient 59 | * @summary async helper for instantiating/returning a block blob client 60 | * @param {ContainerClient} containerClient 61 | * @param {string} blobName 62 | * @returns {BlockBlobClient} Azure block blob client 63 | */ 64 | export const getBlockClient = async (containerClient, blobName) => { 65 | const blockBlobClient = await containerClient.getBlockBlobClient(blobName); 66 | return blockBlobClient; 67 | }; 68 | 69 | /** 70 | * uploadFile 71 | * @summary async function that uploads SFInfo export files to Azure blob storage 72 | * @example await uploadFile(storageConnectionString, containerName, JSON.stringify(fileContent)) 73 | * @param {string} storageConnectionString 74 | * @param {string} blobContainerName 75 | * @param {any} fileContent 76 | * @param {string} fileName 77 | * @param {string} [storageTier=Hot] Hot, Cold, or Archive 78 | * @returns {Object} response from blob upload 79 | */ 80 | export const uploadFile = async ({ 81 | storageConnectionString, 82 | blobContainerName, 83 | fileContent, 84 | fileName, 85 | storageTier = 'Hot', 86 | }) => { 87 | const uploadContent = 88 | typeof fileContent === 'string' ? fileContent : JSON.stringify(fileContent); 89 | const blobServiceClient = await getBlobServiceClient(storageConnectionString); 90 | const containerClient = await getBlobContainerClient( 91 | blobServiceClient, 92 | blobContainerName 93 | ); 94 | const blockBlobClient = await containerClient.getBlockBlobClient(fileName); 95 | const uploadBlobResponse = await blockBlobClient.upload( 96 | uploadContent, 97 | Buffer.byteLength(uploadContent), 98 | { 99 | tier: storageTier, 100 | } 101 | ); 102 | return { 103 | fileName, 104 | ...uploadBlobResponse, 105 | }; 106 | }; 107 | 108 | /** 109 | * getBlobContent 110 | * @summary async function for returning the text content of a specific blob 111 | * @example await getBlobContent(containerClient, '20210101-sfinfo-export.json') 112 | * @param {ContainerClient} containerClient 113 | * @param {string} blobContainerName 114 | * @returns {string} content of specified blab 115 | */ 116 | export const getBlobContent = async (containerClient, blobName) => { 117 | const blockBlobClient = await getBlockClient(containerClient, blobName); 118 | const downloadBlockBlobResponse = await blockBlobClient.download(0); 119 | const exportStream = await downloadBlockBlobResponse.readableStreamBody; 120 | 121 | const chunks = []; 122 | for await (const chunk of exportStream) { 123 | chunks.push(chunk); 124 | } 125 | const fullContent = Buffer.concat(chunks).toString(); 126 | 127 | return fullContent; 128 | }; 129 | -------------------------------------------------------------------------------- /assets/sample-data/adaptiveCardExampleFormat.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", 3 | "type": "AdaptiveCard", 4 | "version": "1.5", 5 | "body": [ 6 | { 7 | "type": "Container", 8 | "style": "accent", 9 | "bleed": true, 10 | "spacing": "None", 11 | "items": [ 12 | { 13 | "type": "TextBlock", 14 | "size": "medium", 15 | "weight": "bolder", 16 | "spacing": "small", 17 | "text": "Azure Service Health Alert" 18 | } 19 | ] 20 | }, 21 | { 22 | "type": "TextBlock", 23 | "text": "RCA - Azure Storage - Unable to see Firewall / Private Endpoints blades", 24 | "size": "Large", 25 | "wrap": true 26 | }, 27 | { 28 | "type": "FactSet", 29 | "facts": [ 30 | { 31 | "title": "Subscription ID", 32 | "value": "d7e08082-8d2d-412d-b07f-b9989684e312" 33 | }, 34 | { 35 | "title": "Alert Timestamp", 36 | "value": "Tue, 12 Oct 2021 12:32:38 -04:00" 37 | }, 38 | { 39 | "title": "Affected Service", 40 | "value": "Storage" 41 | }, 42 | { 43 | "title": "Affected Region", 44 | "value": "East US" 45 | }, 46 | { 47 | "title": "Alert State", 48 | "value": "RCA" 49 | } 50 | ], 51 | "height": "stretch" 52 | }, 53 | { 54 | "type": "ActionSet", 55 | "actions": [ 56 | { 57 | "type": "Action.ToggleVisibility", 58 | "title": "Show/Hide Details", 59 | "style": "default", 60 | "targetElements": [ 61 | { 62 | "elementId": "detailsFactSet" 63 | } 64 | ] 65 | }, 66 | { 67 | "type": "Action.OpenUrl", 68 | "title": "Service Issues Page (Azure Portal)", 69 | "url": "https://portal.azure.com/#blade/Microsoft_Azure_Health/AzureHealthBrowseBlade/serviceIssues", 70 | "style": "default" 71 | } 72 | ] 73 | }, 74 | { 75 | "type": "FactSet", 76 | "isVisible": false, 77 | "id": "detailsFactSet", 78 | "facts": [ 79 | { 80 | "title": "Details", 81 | "value": "Summary of Impact: Between 2021-09-27 12:36 UTC and 2021-09-27 17:58 UTC, customers using Azure Portal in multiple regions may have experienced failures while creating storage accounts or viewing and configuring Storage Account properties. The incident only impacted operations performed through Azure Portal, and other methods such as PowerShell, CLI, REST, etc, continued to operate normally.\n\nRoot Cause: This incident was caused by a regional configuration change deployed with a misconfigured entity. We have built-in validation that should detect such configuration problems, but it missed handling the case with the impacted entity. The incorrect entity caused the Azure Portal to fail to display some of the UI components related to Storage Account operation workflows. The configuration deployment process followed our Safe Deployment Practices (SDP), however, the failure went undetected until multiple regions had been updated due to an incorrect severity assigned to the associated failures.\n\nMitigation: Upon detection we stopped the configuration deployment and rolled back the changes in the affected regions thus restoring the portal experience to its previous state\n\nNext Steps: We apologize for the inconvenience this issue has caused. We are continuously working to improve our system to prevent such issues from recurring. The actions we are taking include:\n\nPortal change to handle the misconfigured entity and prevent impact to overall Storage Account operation workflows is now fixed.Address gap in the built-in configuration validation to handle detection of the misconfigured entity.Fix severity for this category of failures in the deployment process to ensure deployments will be halted immediately when encountered.\n\nProvide Feedback: Please help us improve the Azure customer communications experience by taking our survey: https://aka.ms/AzurePIRSurvey" 82 | }, 83 | { 84 | "title": "incidentType", 85 | "value": "Incident" 86 | }, 87 | { 88 | "title": "trackingId", 89 | "value": "AB1C-2DE" 90 | }, 91 | { 92 | "title": "impactStartTime", 93 | "value": "09/27/2021 12:08:18" 94 | }, 95 | { 96 | "title": "impactMitigationTime", 97 | "value": "09/27/2021 17:58:16" 98 | }, 99 | { 100 | "title": "communicationId", 101 | "value": "99999999999999" 102 | }, 103 | { 104 | "title": "isHIR", 105 | "value": "false" 106 | }, 107 | { 108 | "title": "IsSynthetic", 109 | "value": "False" 110 | }, 111 | { 112 | "title": "impactType", 113 | "value": "ServiceRegionScaleUnit" 114 | }, 115 | { 116 | "title": "version", 117 | "value": "0.1.1" 118 | } 119 | ] 120 | } 121 | ] 122 | } 123 | -------------------------------------------------------------------------------- /lib/cards/service-health-alert.js: -------------------------------------------------------------------------------- 1 | import TurndownService from 'turndown'; 2 | 3 | import { 4 | assembleAdaptiveCard, 5 | getEmoji, 6 | localizeDateTime, 7 | } from '../helpers.js'; 8 | 9 | const { SUB_DISPLAY_NAME_IN_DESCRIPTION, SUB_DISPLAY_NAME_SEPARATOR } = 10 | process.env; 11 | 12 | const turndownService = new TurndownService(); 13 | 14 | export const messageCard = (alertData) => { 15 | const { incidentType, stage } = alertData.alertContext.properties; 16 | const { firedDateTime, alertId } = alertData.essentials; 17 | 18 | const timestamp = localizeDateTime(firedDateTime); 19 | const subscriptionId = alertId.split('/')[2]; 20 | 21 | const incidentTypes = { 22 | Informational: 'accent', 23 | ActionRequired: 'warning', 24 | Incident: 'attention', 25 | Maintenance: 'warning', 26 | Security: 'attention', 27 | }; 28 | // incidentTypes with stages: Incident, Security 29 | const incidentTypesWithStages = ['Incident', 'Security']; 30 | // Active, Planned, InProgress, Canceled, Rescheduled, Resolved, Complete and RCA 31 | const incidentStages = { 32 | Active: 'attention', 33 | Resolved: 'good', 34 | RCA: 'accent', 35 | }; 36 | const color = incidentTypesWithStages.includes(incidentType) 37 | ? incidentStages[stage] 38 | : incidentTypes[incidentType]; 39 | 40 | const accentEmoji = getEmoji(color); 41 | const title = `${accentEmoji}${alertData.alertContext.properties.title}`; 42 | 43 | const headerContent = { 44 | type: 'Container', 45 | style: color, 46 | bleed: true, 47 | spacing: 'None', 48 | items: [ 49 | { 50 | type: 'TextBlock', 51 | size: 'large', 52 | wrap: true, 53 | weight: 'bolder', 54 | spacing: 'none', 55 | text: process.env.NODE_ENV === 'development' ? `DEV ${title}` : title, 56 | }, 57 | ], 58 | }; 59 | 60 | // custom logic checks for existence of env vars and tries 61 | // to extract sub name from alert description if they exist 62 | let subscriptionDisplayName = subscriptionId; 63 | try { 64 | const { description } = alertData.essentials; 65 | if ( 66 | SUB_DISPLAY_NAME_IN_DESCRIPTION && 67 | SUB_DISPLAY_NAME_SEPARATOR && 68 | SUB_DISPLAY_NAME_SEPARATOR.length && 69 | description.includes(SUB_DISPLAY_NAME_SEPARATOR) 70 | ) { 71 | const textSeparator = 72 | SUB_DISPLAY_NAME_SEPARATOR?.length && 73 | description.includes(SUB_DISPLAY_NAME_SEPARATOR) 74 | ? SUB_DISPLAY_NAME_SEPARATOR 75 | : ' for:\n'; 76 | subscriptionDisplayName = description.split(textSeparator)[1]?.length 77 | ? `${description.split(textSeparator)[1]} (${subscriptionId})` 78 | : subscriptionId; 79 | } 80 | } catch (error) { 81 | // if any of the custom logic fails, continue processing 82 | // and just use sub id 83 | subscriptionDisplayName = subscriptionId; 84 | } 85 | 86 | const factSetOneContent = { 87 | type: 'FactSet', 88 | facts: [ 89 | { 90 | title: 'Subscription', 91 | value: subscriptionDisplayName, 92 | }, 93 | { 94 | title: 'Type', 95 | value: `${incidentType} (${stage})`, 96 | }, 97 | { 98 | title: 'Fired At', 99 | value: timestamp, 100 | }, 101 | // { 102 | // title: 'Service', 103 | // value: alertData.alertContext.properties.service, 104 | // }, 105 | // { 106 | // title: 'Region', 107 | // value: alertData.alertContext.properties.region, 108 | // }, 109 | ], 110 | }; 111 | 112 | const actionSetContent = { 113 | type: 'ActionSet', 114 | actions: [ 115 | { 116 | type: 'Action.ToggleVisibility', 117 | title: 'Toggle Additional Details', 118 | style: 'default', 119 | targetElements: [ 120 | { 121 | elementId: 'detailsFactSet', 122 | }, 123 | ], 124 | }, 125 | { 126 | type: 'Action.OpenUrl', 127 | title: 'View Service Issues Page', 128 | url: `https://portal.azure.com/#blade/Microsoft_Azure_Health/AzureHealthBrowseBlade/serviceIssues/${ 129 | alertData.alertContext.properties.trackingId 130 | }/${subscriptionId.slice(0, 3)}${subscriptionId.slice(-3)}`, 131 | style: 'default', 132 | }, 133 | ], 134 | }; 135 | 136 | const factSetTwoContent = { 137 | type: 'FactSet', 138 | isVisible: false, 139 | id: 'detailsFactSet', 140 | facts: [], 141 | }; 142 | const propertyKeys = Object.keys(alertData.alertContext.properties); 143 | const ignoreKeys = [ 144 | 'defaultLanguageContent', // repeat data 145 | 'defaultLanguageTitle', // repeat data 146 | // 'impactedServices', // repeat data 147 | 'impactedServicesTableRows', // repeat data 148 | 'incidentType', // used elsewhere 149 | 'region', // used elsewhere 150 | 'service', // used elsewhere 151 | 'stage', // used elsewhere 152 | 'title', // used elsewhere 153 | ]; 154 | for (const key of propertyKeys) { 155 | if (!ignoreKeys.includes(key)) { 156 | const factContent = alertData.alertContext.properties[key].includes('<') 157 | ? turndownService.turndown(alertData.alertContext.properties[key]) 158 | : alertData.alertContext.properties[key]; 159 | factSetTwoContent.facts.push({ 160 | title: key, 161 | value: factContent, 162 | }); 163 | } 164 | } 165 | 166 | const cardTemplate = assembleAdaptiveCard([ 167 | headerContent, 168 | factSetOneContent, 169 | actionSetContent, 170 | factSetTwoContent, 171 | ]); 172 | 173 | return cardTemplate; 174 | }; 175 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /src/alert-endpoint.js: -------------------------------------------------------------------------------- 1 | import { app } from '@azure/functions'; 2 | import axios from 'axios'; 3 | 4 | import { isExpressRouteAlert } from '../lib/express-route.js'; 5 | 6 | const { 7 | MS_TEAMS_NOTIFICATION_WEBHOOK_URL, 8 | // MS_TEAMS_ALERT_WEBHOOK_URL, 9 | MS_TEAMS_DEV_WEBHOOK_URL, 10 | NODE_ENV, 11 | LOCAL_DEV, 12 | } = process.env; 13 | 14 | app.http('alert-endpoint', { 15 | methods: ['POST'], 16 | handler: async (request, context) => { 17 | try { 18 | if (request.body) { 19 | const requestBody = await request.json(); 20 | let webHookUrl = MS_TEAMS_NOTIFICATION_WEBHOOK_URL; 21 | const { schemaId } = requestBody; 22 | let adaptiveCard; 23 | // supported schema: azureMonitorCommonAlertSchema 24 | if ( 25 | schemaId === 'azureMonitorCommonAlertSchema' && 26 | requestBody.data && 27 | requestBody.data.essentials 28 | ) { 29 | const { monitoringService, alertTargetIDs } = 30 | requestBody.data.essentials; 31 | if (monitoringService) { 32 | const { alertContext } = requestBody.data; 33 | 34 | // service health alerts 35 | if (monitoringService === 'ServiceHealth') { 36 | // context.info('SERVICE HEALTH ALERT'); 37 | webHookUrl = MS_TEAMS_NOTIFICATION_WEBHOOK_URL; 38 | const { messageCard } = await import( 39 | '../lib/cards/service-health-alert.js' 40 | ); 41 | adaptiveCard = messageCard(requestBody.data); 42 | } 43 | 44 | // log query alerts 45 | const logAlertServices = [ 46 | 'Log Alerts V2', 47 | 'Log Alerts', 48 | 'Application Insights', 49 | ]; 50 | if (logAlertServices.includes(monitoringService)) { 51 | // context.info('LOG QUERY ALERT'); 52 | try { 53 | // check for expressroute data and use that card if it is 54 | const burstAlertMetrics = [ 55 | 'BitsOutPerSecond', 56 | 'BitsInPerSecond', 57 | ]; 58 | if ( 59 | burstAlertMetrics.includes( 60 | alertContext?.condition?.allOf[0]?.metricMeasureColumn 61 | ) 62 | ) { 63 | const { messageCard } = await import( 64 | '../lib/cards/express-route-log-query-burst-alert.js' 65 | ); 66 | adaptiveCard = await messageCard(requestBody); 67 | } 68 | // no custom adaptiveCard in use, default to the log query handler 69 | if (!adaptiveCard) { 70 | const { messageCard } = await import( 71 | '../lib/cards/app-insights-log-query-alert.js' 72 | ); 73 | adaptiveCard = await messageCard(requestBody); 74 | webHookUrl = MS_TEAMS_DEV_WEBHOOK_URL; 75 | } 76 | } catch (error) { 77 | adaptiveCard = null; 78 | context.info('⚠️ UNRECOGNIZED LOG QUERY ALERT DATA:\n', error); 79 | } 80 | } 81 | 82 | // platform/monitor alerts 83 | const platformAlertServices = ['Platform']; 84 | if (platformAlertServices.includes(monitoringService)) { 85 | // context.info('PLATFORM MONITOR ALERT'); 86 | if (isExpressRouteAlert(alertTargetIDs)) { 87 | try { 88 | const upDownAlertMetrics = ['BgpAvailability']; 89 | if ( 90 | upDownAlertMetrics.includes( 91 | alertContext.condition.allOf[0].metricName 92 | ) 93 | ) { 94 | const { messageCard } = await import( 95 | '../lib/cards/express-route-alert.js' 96 | ); 97 | adaptiveCard = await messageCard(requestBody.data); 98 | } 99 | } catch (error) { 100 | adaptiveCard = null; 101 | context.info('⚠️ UNRECOGNIZED PLATFORM ALERT DATA:\n', error); 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | // we have unrecognized data or there's been an error 109 | if (!adaptiveCard) { 110 | // use dev webhook if available, fall back to notification webhook 111 | webHookUrl = 112 | MS_TEAMS_DEV_WEBHOOK_URL || MS_TEAMS_NOTIFICATION_WEBHOOK_URL; 113 | const { messageCard } = await import('../lib/cards/simple.js'); 114 | const color = 'warning'; 115 | const title = '⚠️ Azure Monitoring Alert (unsupported payload)'; 116 | const text = JSON.stringify(requestBody); 117 | adaptiveCard = messageCard({ title, color, text }); 118 | } 119 | 120 | // always use dev webhook if provided and in a development environment 121 | if ( 122 | (NODE_ENV === 'development' || LOCAL_DEV === 'true') && 123 | MS_TEAMS_DEV_WEBHOOK_URL !== undefined 124 | ) { 125 | webHookUrl = MS_TEAMS_DEV_WEBHOOK_URL; 126 | } 127 | 128 | await axios 129 | .post(webHookUrl, adaptiveCard) 130 | .then((response) => { 131 | return { 132 | status: 200, 133 | body: response.data, 134 | }; 135 | }) 136 | .catch((error) => { 137 | // log error for dev and/or debugging purposes 138 | context.error('⚠️ AXIOS ERROR:\n', error); 139 | // bubble error up so it throws 500 and outputs content 140 | throw error; 141 | }); 142 | } else { 143 | const errorMessage = 'ERROR: No POST data received'; 144 | context.error(errorMessage); 145 | return { 146 | status: 400, 147 | body: JSON.stringify({ 148 | status: 400, 149 | error: errorMessage, 150 | }), 151 | }; 152 | } 153 | } catch (error) { 154 | context.error(error); 155 | return { 156 | status: 500, 157 | body: JSON.stringify({ 158 | status: 500, 159 | error, 160 | }), 161 | }; 162 | } 163 | }, 164 | }); 165 | -------------------------------------------------------------------------------- /assets/sample-data/adaptiveCardTeamsWebhookPostExample.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "message", 3 | "attachments": [ 4 | { 5 | "contentType": "application/vnd.microsoft.card.adaptive", 6 | "contentUrl": null, 7 | "content": { 8 | "msteams": { 9 | "width": "Full" 10 | }, 11 | "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", 12 | "type": "AdaptiveCard", 13 | "version": "1.5", 14 | "body": [ 15 | { 16 | "type": "Container", 17 | "style": "accent", 18 | "bleed": true, 19 | "spacing": "None", 20 | "items": [ 21 | { 22 | "type": "TextBlock", 23 | "size": "medium", 24 | "weight": "bolder", 25 | "spacing": "small", 26 | "text": "Azure Service Health Alert" 27 | } 28 | ] 29 | }, 30 | { 31 | "type": "TextBlock", 32 | "text": "RCA - Azure Storage - Unable to see Firewall / Private Endpoints blades", 33 | "size": "Large", 34 | "wrap": true 35 | }, 36 | { 37 | "type": "FactSet", 38 | "facts": [ 39 | { 40 | "title": "Subscription ID", 41 | "value": "d7e08082-8d2d-412d-b07f-b9989684e312" 42 | }, 43 | { 44 | "title": "Alert Timestamp", 45 | "value": "Tue, 12 Oct 2021 12:32:38 -04:00" 46 | }, 47 | { 48 | "title": "Affected Service", 49 | "value": "Storage" 50 | }, 51 | { 52 | "title": "Affected Region", 53 | "value": "East US" 54 | }, 55 | { 56 | "title": "Alert State", 57 | "value": "RCA" 58 | } 59 | ], 60 | "height": "stretch" 61 | }, 62 | { 63 | "type": "ActionSet", 64 | "actions": [ 65 | { 66 | "type": "Action.ToggleVisibility", 67 | "title": "Show/Hide Details", 68 | "style": "default", 69 | "targetElements": [ 70 | { 71 | "elementId": "detailsFactSet" 72 | } 73 | ] 74 | }, 75 | { 76 | "type": "Action.OpenUrl", 77 | "title": "Service Issues Page (Azure Portal)", 78 | "url": "https://portal.azure.com/#blade/Microsoft_Azure_Health/AzureHealthBrowseBlade/serviceIssues", 79 | "style": "default" 80 | } 81 | ] 82 | }, 83 | { 84 | "type": "FactSet", 85 | "isVisible": false, 86 | "id": "detailsFactSet", 87 | "facts": [ 88 | { 89 | "title": "Details", 90 | "value": "

Summary of Impact: Between 2021-09-27 12:36 UTC and 2021-09-27 17:58 UTC, customers using Azure Portal in multiple regions may have experienced failures while creating storage accounts or viewing and configuring Storage Account properties. The incident only impacted operations performed through Azure Portal, and other methods such as PowerShell, CLI, REST, etc, continued to operate normally.

\n

Root Cause: This incident was caused by a regional configuration change deployed with a misconfigured entity. We have built-in validation that should detect such configuration problems, but it missed handling the case with the impacted entity. The incorrect entity caused the Azure Portal to fail to display some of the UI components related to Storage Account operation workflows. The configuration deployment process followed our Safe Deployment Practices (SDP), however, the failure went undetected until multiple regions had been updated due to an incorrect severity assigned to the associated failures.

\n

Mitigation: Upon detection we stopped the configuration deployment and rolled back the changes in the affected regions thus restoring the portal experience to its previous state

\n

Next Steps: We apologize for the inconvenience this issue has caused. We are continuously working to improve our system to prevent such issues from recurring. The actions we are taking include:

\n\n

Provide Feedback: Please help us improve the Azure customer communications experience by taking our survey: https://aka.ms/AzurePIRSurvey

" 91 | }, 92 | { 93 | "title": "incidentType", 94 | "value": "Incident" 95 | }, 96 | { 97 | "title": "trackingId", 98 | "value": "AB1C-2DE" 99 | }, 100 | { 101 | "title": "impactStartTime", 102 | "value": "09/27/2021 12:08:18" 103 | }, 104 | { 105 | "title": "impactMitigationTime", 106 | "value": "09/27/2021 17:58:16" 107 | }, 108 | { 109 | "title": "communicationId", 110 | "value": "99999999999999" 111 | }, 112 | { 113 | "title": "isHIR", 114 | "value": "false" 115 | }, 116 | { 117 | "title": "IsSynthetic", 118 | "value": "False" 119 | }, 120 | { 121 | "title": "impactType", 122 | "value": "ServiceRegionScaleUnit" 123 | }, 124 | { 125 | "title": "version", 126 | "value": "0.1.1" 127 | } 128 | ] 129 | } 130 | ] 131 | } 132 | } 133 | ] 134 | } 135 | -------------------------------------------------------------------------------- /assets/sample-data/service-health-alert.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaId": "azureMonitorCommonAlertSchema", 3 | "data": { 4 | "essentials": { 5 | "alertId": "/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1/providers/Microsoft.AlertsManagement/alerts/02a7e47c47d849a4a56fd3eaf15593fd13d2075db29c7ecf47d897eb492b4da6", 6 | "alertRule": "Monitor all the things", 7 | "severity": "Sev4", 8 | "signalType": "Activity Log", 9 | "monitorCondition": "Fired", 10 | "monitoringService": "ServiceHealth", 11 | "alertTargetIDs": ["/subscriptions/30d34e13-de0b-4a7c-9764-ddafc94199b1"], 12 | "originAlertId": "653c1972-8d98-4995-98c5-fdb90b0be81c", 13 | "firedDateTime": "2021-10-12T00:32:38.1239171", 14 | "description": "Test monitor for everything", 15 | "essentialsVersion": "1.0", 16 | "alertContextVersion": "1.0" 17 | }, 18 | "alertContext": { 19 | "authorization": null, 20 | "channels": 1, 21 | "claims": null, 22 | "caller": null, 23 | "correlationId": "42a0dc0d-bcbe-48c4-b438-700cf6876417", 24 | "eventSource": 2, 25 | "eventTimestamp": "2021-10-12T00:32:18.6121962+00:00", 26 | "httpRequest": null, 27 | "eventDataId": "653c1972-8d98-4995-98c5-fdb90b0be81c", 28 | "level": 3, 29 | "operationName": "Microsoft.ServiceHealth/incident/action", 30 | "operationId": "42a0dc0d-bcbe-48c4-b438-700cf6876417", 31 | "properties": { 32 | "title": "RCA - Azure Storage - Unable to see Firewall / Private Endpoints blades", 33 | "service": "Storage", 34 | "region": "East US", 35 | "communication": "

Summary of Impact: Between 2021-09-27 12:36 UTC and 2021-09-27 17:58 UTC, customers using Azure Portal in multiple regions may have experienced failures while creating storage accounts or viewing and configuring Storage Account properties. The incident only impacted operations performed through Azure Portal, and other methods such as PowerShell, CLI, REST, etc, continued to operate normally.

\n

Root Cause: This incident was caused by a regional configuration change deployed with a misconfigured entity. We have built-in validation that should detect such configuration problems, but it missed handling the case with the impacted entity. The incorrect entity caused the Azure Portal to fail to display some of the UI components related to Storage Account operation workflows. The configuration deployment process followed our Safe Deployment Practices (SDP), however, the failure went undetected until multiple regions had been updated due to an incorrect severity assigned to the associated failures.

\n

Mitigation: Upon detection we stopped the configuration deployment and rolled back the changes in the affected regions thus restoring the portal experience to its previous state

\n

Next Steps: We apologize for the inconvenience this issue has caused. We are continuously working to improve our system to prevent such issues from recurring. The actions we are taking include:

\n\n

Provide Feedback: Please help us improve the Azure customer communications experience by taking our survey: https://aka.ms/AzurePIRSurvey

", 36 | "incidentType": "Incident", 37 | "trackingId": "AB1C-2DE", 38 | "impactStartTime": "2021-09-27T12:08:18.983Z", 39 | "impactMitigationTime": "2021-09-27T17:58:16Z", 40 | "impactedServices": "[{\"ImpactedRegions\":[{\"RegionName\":\"East US\"}],\"ServiceName\":\"Storage\"}]", 41 | "impactedServicesTableRows": "\r\nStorage\r\nEast US
\r\n\r\n", 42 | "defaultLanguageTitle": "RCA - Azure Storage - Unable to see Firewall / Private Endpoints blades", 43 | "defaultLanguageContent": "

Summary of Impact: Between 2021-09-27 12:36 UTC and 2021-09-27 17:58 UTC, customers using Azure Portal in multiple regions may have experienced failures while creating storage accounts or viewing and configuring Storage Account properties. The incident only impacted operations performed through Azure Portal, and other methods such as PowerShell, CLI, REST, etc, continued to operate normally.

\n

Root Cause: This incident was caused by a regional configuration change deployed with a misconfigured entity. We have built-in validation that should detect such configuration problems, but it missed handling the case with the impacted entity. The incorrect entity caused the Azure Portal to fail to display some of the UI components related to Storage Account operation workflows. The configuration deployment process followed our Safe Deployment Practices (SDP), however, the failure went undetected until multiple regions had been updated due to an incorrect severity assigned to the associated failures.

\n

Mitigation: Upon detection we stopped the configuration deployment and rolled back the changes in the affected regions thus restoring the portal experience to its previous state

\n

Next Steps: We apologize for the inconvenience this issue has caused. We are continuously working to improve our system to prevent such issues from recurring. The actions we are taking include:

\n\n

Provide Feedback: Please help us improve the Azure customer communications experience by taking our survey: https://aka.ms/AzurePIRSurvey

", 44 | "stage": "RCA", 45 | "communicationId": "99999999999999", 46 | "isHIR": "false", 47 | "IsSynthetic": "False", 48 | "impactType": "ServiceRegionScaleUnit", 49 | "version": "0.1.1" 50 | }, 51 | "status": "Resolved", 52 | "subStatus": null, 53 | "submissionTimestamp": "2021-10-12T00:32:38.1239171+00:00", 54 | "ResourceType": null 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # az-common-alert-endpoint 2 | 3 | Azure Function for an HTTP endpoint to receive Azure Monitor alerts that use the Common Alert Schema 4 | 5 | | Branch | Status | CI/CD Build Trigger | 6 | | --- | --- | --- | 7 | | `dev` | [![DEV Build & Deploy](https://github.com/cu-cit-cloud-team/az-common-alert-endpoint/actions/workflows/dev-build-and-deploy.yml/badge.svg?branch=dev)](https://github.com/cu-cit-cloud-team/az-common-alert-endpoint/actions/workflows/dev-build-and-deploy.yml) | Pushes to `dev` branch | 8 | | `main` | [![Build & Deploy](https://github.com/cu-cit-cloud-team/az-common-alert-endpoint/actions/workflows/build-and-deploy.yml/badge.svg)](https://github.com/cu-cit-cloud-team/az-common-alert-endpoint/actions/workflows/build-and-deploy.yml) | PR to `main` branch | 9 | 10 | ## Azure Function 11 | 12 | ### `alert-endpoint` 13 | 14 | Accepts alert data from Azure Monitors using the Common Alert Schema - formats alert data 15 | as an [AdaptiveCard](https://adaptivecards.microsoft.com/) and then sends it to an 16 | [MS Teams Incoming Webhook](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook) 17 | 18 | - Type: HTTP Trigger 19 | - Auth: Anonymous 20 | - Accepts: 21 | - Method: `POST` 22 | - Content-Type: `application/json` 23 | - Schema: `azureMonitorCommonAlertSchema` 24 | - [Definitions](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema-definitions) 25 | - [About](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema) 26 | - Currently Supported Alerts 27 | - Azure Service Health Alert 28 | - [Schema](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema-definitions#monitoringservice--servicehealth) 29 | - Details: 30 | - Gives most important info at a glance 31 | - Color bar changes based on incident type/stage 32 | - Buttons to toggle additional details or go to service issues page in Azure Portal 33 | - HTML in communication converted to Markdown so it displays properly 34 | - Examples: 35 | - Collapsed 36 | ![service-health-alert.png](./assets/readme-images/service-health-alert.png) 37 | - Full 38 | ![service-health-alert-expanded.png](./assets/readme-images/service-health-alert-expanded.png) 39 | - ExpressRoute Platform Alert 40 | - [Schema](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema-definitions#monitoringservice--platform) 41 | - Details: 42 | - Gives most important info at a glance 43 | - Color bar changes based on alert type and number of peers affected 44 | - Button to go view the alert in the Azure Portal 45 | - Additional Notes/Requirements 46 | - Manages state using a JSON file (kept in Blob Storage inside the Function App's existing storage account) 47 | - You can specify the blob container (useful for dev vs prod) by setting an environment variable: `BLOB_CONTAINER_NAME` 48 | - Uses default value of `functions-data` if `BLOB_CONTAINER_NAME` is not provided 49 | - Examples 50 | - Primary Down 51 | ![express-route-alert-one.png](./assets/readme-images/express-route-alert-one.png) 52 | - Secondary Down 53 | ![express-route-alert-two.png](./assets/readme-images/express-route-alert-two.png) 54 | - Primary Up 55 | ![express-route-resolved-one.png](./assets/readme-images/express-route-resolved-one.png) 56 | - Secondary Up 57 | ![express-route-resolved-two.png](./assets/readme-images/express-route-resolved-two.png) 58 | - ExpressRoute Bursts via log query search 59 | - [Schema](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema-definitions#monitoringservice--log-alerts-v2) 60 | - Currently supports ExpressRoute BitsInPerSecond/BitsOutPerSecond Log Searches 61 | - Example query: `AzureMetrics | where MetricName == 'BitsOutPerSecond' and Maximum >= 50000000 | order by TimeGenerated desc | limit 10 | where TimeGenerated > ago(10m) | summarize BitsOutPerSecond = sum(Maximum) by TimeGenerated` 62 | - Details: 63 | - Gives most important info at a glance 64 | - Color bar and icon changes based on alert status 65 | - Button to go view the alert in the Azure Portal 66 | - Button to view the log query results in the Azure Portal 67 | - Currently set to fire if it's in violation for at least 2 of the last 3 evaluation periods (eval period is currently 5 minutes) 68 | - Examples: 69 | - Burst Notification 70 | ![express-route-burst-notification.png](./assets/readme-images/express-route-burst-notification.png) 71 | - Resolved Notification 72 | ![express-route-burst-resolved-notification.png](./assets/readme-images/express-route-burst-resolved-notification.png) 73 | 74 | - Log Queries (Application Insights Log Alert / Log Alerts V2) 75 | - Schemas 76 | - [Application Insights](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema-definitions#monitoringservice--application-insights) 77 | - [Log Alerts V2](https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-common-schema-definitions#monitoringservice--log-alerts-v2) 78 | - Details 79 | - Generic heading identifies it as an Azure Log Query Alert 80 | - Color bar and icons change based on alert severity (alert severity is chosen during setup, it's a user controlled value) 81 | - Includes alert rule name, description (if provided), and log analytics search query to make it easier to grok what's alerting 82 | - Button to view the log query results in the Azure Portal 83 | - Button to view alert(s) in the Azure Portal 84 | - Examples 85 | - Sev1 (Alert) 86 | ![log-query-alert-sev1.png](./assets/readme-images/log-query-alert-sev1.png) 87 | - Sev2 (Warning) 88 | ![log-query-alert-sev2.png](./assets/readme-images/log-query-alert-sev2.png) 89 | - Sev3 (Informational) / Sev4 (Verbose) 90 | ![log-query-alert-sev3-sev4.png](./assets/readme-images/log-query-alert-sev3-sev4.png) 91 | 92 | ## GitHub Repo Settings 93 | 94 | - **Actions secrets:** 95 | - **REQUIRED** 96 | - `AZURE_FUNCTIONAPP_PUBLISH_PROFILE` 97 | - Publish profile for production function app 98 | - `AZURE_FUNCTIONAPP_PUBLISH_PROFILE_DEV` 99 | - Publish profile for dev function app 100 | - `MS_TEAMS_WEBHOOK_URL` 101 | - URL of MS Teams Incoming Webhook to be used for deploy notifications 102 | - `MS_TEAMS_WEBHOOK_URL_DEV` 103 | - URL of MS Teams Incoming Webhook to be used for dev function app deploy notifications (can be same as `MS_TEAMS_WEBHOOK_URL`) 104 | - `MS_TEAMS_ALERT_WEBHOOK_URL` 105 | - URL of MS Teams Incoming Webhook to be used for deploy failure notifications (can be same as `MS_TEAMS_WEBHOOK_URL`) 106 | - `ACTIONS_STEP_DEBUG` 107 | - `false` (set to `true` for additional debug output in GitHub Actions logs) 108 | - `ACTIONS_RUNNER_DEBUG` 109 | - `false` (set to `true` for additional debug output in GitHub Actions logs) 110 | 111 | ## Local Development 112 | 113 | ### Requirements 114 | 115 | - Node.js >= v22.x 116 | - npm >= v10.x 117 | 118 | ### Getting Started 119 | 120 | 1. Clone repo `git clone https://github.com/cu-cit-cloud-team/az-common-alert-endpoint.git your-folder-name` 121 | 1. Enter directory `cd your-folder-name` 122 | 1. Install dependencies `npm install` 123 | 1. Set up environment variables in `.env` and `local.settings.json`: 124 | - **REQUIRED** 125 | - `MS_TEAMS_NOTIFICATION_WEBHOOK_URL` 126 | - URL of MS Teams Incoming Webhook to be used for informational notifications 127 | - `MS_TEAMS_ALERT_WEBHOOK_URL` 128 | - URL of MS Teams Incoming Webhook to be used for actionable alerts (can be same as `MS_TEAMS_NOTIFICATION_WEBHOOK_URL`) 129 | - **OPTIONAL** 130 | - `MS_TEAMS_DEV_WEBHOOK_URL` 131 | - URL of MS Teams Incoming Webhook to be used for unsupported payloads and development - if not provided, function will fall back to `MS_TEAMS_NOTIFICATION_WEBHOOK_URL` 132 | - `NOTIFICATION_TIMEZONE` 133 | - Timezone db name to use for formatting timestamps in notifications - defaults to `America/New_York` ([full list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)) 134 | - `BLOB_CONTAINER_NAME` 135 | - Name of the Azure Blob container to use for storing state files - defaults to `functions-data` 136 | - `SUB_DISPLAY_NAME_IN_DESCRIPTION` 137 | - Used to indicate whether the SHA rule(s) have the subscription name in their description (value should be `true` or `false`) 138 | - `SUB_DISPLAY_NAME_SEPARATOR` 139 | - Some identifying text that can be used to extract the subscription name from the description if above value is true (e.g. ` for: ` if the description is "Some Alert Rule for: Subscription Name" and you wanted to extract "Subscription Name" for the SHA notification) 140 | - `LOCAL_DEV` 141 | - Set to `true` to override alert and notification webhooks during development 142 | - Make sure to also set up `MS_TEAMS_DEV_WEBHOOK_URL` with a value or it will fall back to `MS_TEAMS_NOTIFICATION_WEBHOOK_URL` 143 | - `WEBSITE_RUN_FROM_PACKAGE` 144 | - Change to `0` to _trick_ the runtime into reloading when changes are made (otherwise you have to manually stop and run again) 145 | 146 | 1. Run locally `npm run functions` (for verbose logging use `npm run functions:verbose`) 147 | 148 | #### Posting Sample Data Using `curl` 149 | 150 | Assumes functions are running locally using instructions above and you are in the root of the repo directory in your terminal 151 | 152 | ```bash 153 | curl -X POST -H "Content-Type: application/json" --data "@assets/sample-data/service-health-alert.json" http://localhost:7071/api/alert-endpoint 154 | ``` 155 | --------------------------------------------------------------------------------