Connecting Azure DevOps to Azure Monitor
A comprehensive guide to integrating Azure DevOps with Azure Monitor, covering pipeline telemetry, deployment annotations, release gate health checks, Application Insights integration, alert-driven work item creation, and observability dashboards that connect CI/CD to production monitoring.
Connecting Azure DevOps to Azure Monitor
Overview
The gap between deploying code and knowing whether it works in production is where most teams lose time. Azure DevOps handles the build and deploy pipeline; Azure Monitor handles the production telemetry. Connecting them closes that gap — deployment annotations mark when releases happened on your monitoring dashboards, release gates check production health before promoting builds, and alerts automatically create work items when things break. I have set up this integration for teams where the mean time to detect issues dropped from hours to minutes because the pipeline was finally aware of what was happening in production.
Prerequisites
- Azure DevOps organization with Pipelines and Boards enabled
- Azure subscription with Azure Monitor and Application Insights resources
- Azure service connection configured in Azure DevOps
- Application Insights instrumentation key or connection string
- Azure Monitor workspace (Log Analytics)
- Familiarity with Kusto Query Language (KQL) for Azure Monitor queries
Deployment Annotations
Deployment annotations mark the exact moment a release happened on your Application Insights timeline. When investigating a production issue, seeing a deployment marker at the point where error rates spiked immediately tells you the deployment caused it.
Adding Annotations from Azure Pipelines
# azure-pipelines.yml
steps:
- script: |
echo "Deploying application..."
# Your deployment steps here
- task: AzureCLI@2
displayName: "Create Deployment Annotation"
inputs:
azureSubscription: "your-azure-subscription"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
APPINSIGHTS_ID=$(az monitor app-insights component show \
--app your-app-insights \
--resource-group your-rg \
--query id --output tsv)
ANNOTATION_JSON=$(cat <<EOF
{
"Id": "$(Build.BuildId)",
"AnnotationName": "Release $(Build.BuildNumber)",
"EventTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"Category": "Deployment",
"Properties": "{\"BuildNumber\":\"$(Build.BuildNumber)\",\"Branch\":\"$(Build.SourceBranch)\",\"ReleaseName\":\"$(Release.ReleaseName)\",\"Environment\":\"production\",\"RequestedBy\":\"$(Build.RequestedFor)\"}"
}
EOF
)
az rest --method put \
--url "${APPINSIGHTS_ID}/Annotations?api-version=2015-05-01" \
--body "$ANNOTATION_JSON"
echo "Deployment annotation created for build $(Build.BuildNumber)"
Annotations with the REST API Directly
For more control, use the Application Insights REST API from a Node.js script:
// scripts/create-annotation.js
var https = require("https");
var APPINSIGHTS_APP_ID = process.env.APPINSIGHTS_APP_ID;
var APPINSIGHTS_API_KEY = process.env.APPINSIGHTS_API_KEY;
var BUILD_NUMBER = process.env.BUILD_BUILDNUMBER || "local";
var BUILD_BRANCH = (process.env.BUILD_SOURCEBRANCH || "").replace("refs/heads/", "");
var REQUESTED_BY = process.env.BUILD_REQUESTEDFOR || "manual";
var annotation = {
Id: Date.now().toString(),
AnnotationName: "Release " + BUILD_NUMBER,
EventTime: new Date().toISOString(),
Category: "Deployment",
Properties: JSON.stringify({
BuildNumber: BUILD_NUMBER,
Branch: BUILD_BRANCH,
RequestedBy: REQUESTED_BY,
Timestamp: new Date().toISOString()
})
};
var body = JSON.stringify([annotation]);
var options = {
hostname: "aigs1.eus.ext.azure.com",
path: "/api/v1/apps/" + APPINSIGHTS_APP_ID + "/annotations",
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-AIGS-ApiKey": APPINSIGHTS_API_KEY,
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log("Annotation created successfully");
} else {
console.error("Annotation failed (" + res.statusCode + "): " + data);
}
});
});
req.on("error", function (err) {
console.error("Annotation request failed: " + err.message);
});
req.write(body);
req.end();
Release Gates with Azure Monitor
Release gates query Azure Monitor before allowing a deployment to proceed to the next stage. If error rates are high or the application is unhealthy, the gate blocks the promotion.
Azure Monitor Alert Gate
Configure a release gate that checks Azure Monitor alerts:
- In your release pipeline, select a stage
- Click the gate icon (pre-deployment or post-deployment)
- Add Query Azure Monitor alerts
- Configure:
- Azure subscription: your subscription
- Resource group: your-rg
- Resource type: Application Insights
- Resource name: your-app-insights
- Alert filter: Severity Sev0, Sev1
- Time range: 5 minutes
If any Sev0 or Sev1 alerts are active, the gate fails and the deployment waits.
Custom KQL Query Gate
For more sophisticated health checks, use the Invoke REST API gate with a KQL query:
# In a YAML pipeline, use the InvokeRestAPI task
steps:
- task: InvokeRestAPI@1
displayName: "Check Application Health"
inputs:
connectionType: "connectedServiceName"
serviceConnection: "azure-monitor-connection"
method: POST
urlSuffix: "/v1/workspaces/$(WORKSPACE_ID)/query"
body: |
{
"query": "requests | where timestamp > ago(5m) | summarize errorRate = todouble(countif(success == false)) / count() * 100 | project healthy = errorRate < 5"
}
waitForCompletion: "true"
Post-Deployment Health Gate
After deploying, wait for the application to stabilize, then check health before marking the deployment as successful:
# azure-pipelines-with-gates.yml
stages:
- stage: Deploy
jobs:
- deployment: Production
environment: "production"
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying..."
displayName: "Deploy application"
postRouteTraffic:
steps:
- script: |
echo "Waiting 2 minutes for deployment to stabilize..."
sleep 120
displayName: "Stabilization period"
- task: AzureCLI@2
displayName: "Check post-deployment health"
inputs:
azureSubscription: "your-azure-subscription"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
# Query Application Insights for error rate
ERROR_RATE=$(az monitor app-insights query \
--app your-app-insights \
--resource-group your-rg \
--analytics-query "requests | where timestamp > ago(5m) | summarize errorRate = round(todouble(countif(success == false)) / count() * 100, 2)" \
--query "tables[0].rows[0][0]" --output tsv)
echo "Post-deployment error rate: ${ERROR_RATE}%"
if (( $(echo "$ERROR_RATE > 5.0" | bc -l) )); then
echo "##vso[task.logissue type=error]Error rate ${ERROR_RATE}% exceeds 5% threshold"
echo "##vso[task.complete result=Failed;]High error rate after deployment"
else
echo "Error rate ${ERROR_RATE}% is within acceptable range"
fi
Alert-Driven Work Item Creation
When Azure Monitor detects an issue in production, automatically create a work item in Azure DevOps so the team can track and resolve it.
Azure Monitor Action Group
Create an Action Group that calls an Azure Function or Logic App, which then creates a work item:
// alert-to-workitem/index.js
// Azure Function triggered by Azure Monitor alert webhook
var https = require("https");
var AZURE_ORG = process.env.AZURE_DEVOPS_ORG;
var AZURE_PROJECT = process.env.AZURE_DEVOPS_PROJECT;
var AZURE_PAT = process.env.AZURE_DEVOPS_PAT;
function createWorkItem(alertData, callback) {
var auth = Buffer.from(":" + AZURE_PAT).toString("base64");
var severity = alertData.data.essentials.severity;
var priority = severity === "Sev0" ? 1 : (severity === "Sev1" ? 2 : 3);
var title = "[Alert] " + alertData.data.essentials.alertRule;
var description = "<h3>Azure Monitor Alert</h3>" +
"<p><strong>Severity:</strong> " + severity + "</p>" +
"<p><strong>Fired at:</strong> " + alertData.data.essentials.firedDateTime + "</p>" +
"<p><strong>Description:</strong> " + (alertData.data.essentials.description || "No description") + "</p>" +
"<p><strong>Monitor condition:</strong> " + alertData.data.essentials.monitorCondition + "</p>" +
"<p><strong>Target resource:</strong> " + alertData.data.essentials.targetResourceName + "</p>";
if (alertData.data.alertContext && alertData.data.alertContext.condition) {
var conditions = alertData.data.alertContext.condition.allOf || [];
conditions.forEach(function (c) {
description += "<p><strong>Metric:</strong> " + c.metricName +
" " + c.operator + " " + c.threshold +
" (actual: " + c.metricValue + ")</p>";
});
}
var patchDoc = [
{ op: "add", path: "/fields/System.Title", value: title },
{ op: "add", path: "/fields/System.Description", value: description },
{ op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: priority },
{ op: "add", path: "/fields/System.Tags", value: "azure-monitor-alert;auto-created;" + severity }
];
var body = JSON.stringify(patchDoc);
var options = {
hostname: "dev.azure.com",
path: "/" + AZURE_ORG + "/" + AZURE_PROJECT + "/_apis/wit/workitems/$Bug?api-version=7.1",
method: "POST",
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json-patch+json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
if (res.statusCode >= 200 && res.statusCode < 300) {
var result = JSON.parse(data);
callback(null, result);
} else {
callback(new Error("Work item creation failed: " + res.statusCode + " " + data));
}
});
});
req.on("error", callback);
req.write(body);
req.end();
}
module.exports = function (context, req) {
context.log("Alert webhook received");
var alertData = req.body;
if (!alertData || !alertData.data || !alertData.data.essentials) {
context.res = { status: 400, body: "Invalid alert payload" };
context.done();
return;
}
var monitorCondition = alertData.data.essentials.monitorCondition;
if (monitorCondition === "Resolved") {
context.log("Alert resolved — skipping work item creation");
context.res = { status: 200, body: "Alert resolved, no action needed" };
context.done();
return;
}
createWorkItem(alertData, function (err, result) {
if (err) {
context.log.error("Failed to create work item: " + err.message);
context.res = { status: 500, body: err.message };
} else {
context.log("Created work item #" + result.id + ": " + result.fields["System.Title"]);
context.res = { status: 200, body: { workItemId: result.id } };
}
context.done();
});
};
Deduplication
Alert rules can fire repeatedly. Avoid creating duplicate work items by checking if one already exists:
function findExistingWorkItem(alertRule, callback) {
var wiql = {
query: "SELECT [System.Id] FROM WorkItems " +
"WHERE [System.Title] CONTAINS '[Alert] " + alertRule.replace(/'/g, "''") + "' " +
"AND [System.State] <> 'Closed' " +
"AND [System.Tags] CONTAINS 'azure-monitor-alert' " +
"AND [System.CreatedDate] >= @today - 7"
};
var auth = Buffer.from(":" + AZURE_PAT).toString("base64");
var body = JSON.stringify(wiql);
var options = {
hostname: "dev.azure.com",
path: "/" + AZURE_ORG + "/" + AZURE_PROJECT + "/_apis/wit/wiql?api-version=7.1",
method: "POST",
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
var result = JSON.parse(data);
var existing = result.workItems && result.workItems.length > 0;
callback(null, existing ? result.workItems[0].id : null);
});
});
req.on("error", callback);
req.write(body);
req.end();
}
Check before creating. If a work item already exists for this alert rule, add a comment instead of creating a duplicate.
Pipeline Telemetry to Application Insights
Send custom telemetry from your pipelines to Application Insights for build analytics:
// scripts/pipeline-telemetry.js
var https = require("https");
var INSTRUMENTATION_KEY = process.env.APPINSIGHTS_INSTRUMENTATION_KEY;
var INGESTION_ENDPOINT = "https://dc.services.visualstudio.com/v2/track";
function sendTelemetry(eventName, properties, measurements) {
var envelope = {
name: "Microsoft.ApplicationInsights." + INSTRUMENTATION_KEY.replace(/-/g, "") + ".Event",
time: new Date().toISOString(),
iKey: INSTRUMENTATION_KEY,
tags: {
"ai.cloud.role": "azure-pipelines",
"ai.cloud.roleInstance": process.env.BUILD_DEFINITIONNAME || "unknown"
},
data: {
baseType: "EventData",
baseData: {
ver: 2,
name: eventName,
properties: properties || {},
measurements: measurements || {}
}
}
};
var body = JSON.stringify([envelope]);
var url = new URL(INGESTION_ENDPOINT);
var options = {
hostname: url.hostname,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function (res) {
var data = "";
res.on("data", function (chunk) { data += chunk; });
res.on("end", function () {
if (res.statusCode === 200) {
console.log("Telemetry sent: " + eventName);
} else {
console.error("Telemetry failed (" + res.statusCode + "): " + data);
}
});
});
req.on("error", function (err) {
console.error("Telemetry error: " + err.message);
});
req.write(body);
req.end();
}
// Send build metrics
var buildStart = new Date(process.env.SYSTEM_PIPELINESTARTTIME || Date.now());
var durationMs = Date.now() - buildStart.getTime();
sendTelemetry("PipelineCompleted", {
buildNumber: process.env.BUILD_BUILDNUMBER || "local",
definitionName: process.env.BUILD_DEFINITIONNAME || "unknown",
branch: (process.env.BUILD_SOURCEBRANCH || "").replace("refs/heads/", ""),
result: process.env.AGENT_JOBSTATUS || "Unknown",
reason: process.env.BUILD_REASON || "manual",
repository: process.env.BUILD_REPOSITORY_NAME || "unknown"
}, {
durationMs: durationMs,
durationMinutes: Math.round(durationMs / 60000 * 100) / 100
});
Use this in your pipeline:
- script: node scripts/pipeline-telemetry.js
displayName: "Send pipeline telemetry"
condition: always()
env:
APPINSIGHTS_INSTRUMENTATION_KEY: $(APPINSIGHTS_KEY)
Now you can query pipeline metrics in Azure Monitor:
customEvents
| where name == "PipelineCompleted"
| extend buildNumber = tostring(customDimensions.buildNumber),
definition = tostring(customDimensions.definitionName),
result = tostring(customDimensions.result),
branch = tostring(customDimensions.branch),
durationMin = todouble(customMeasurements.durationMinutes)
| summarize
totalBuilds = count(),
avgDuration = round(avg(durationMin), 1),
successRate = round(countif(result == "Succeeded") * 100.0 / count(), 1),
failedCount = countif(result == "Failed")
by definition, bin(timestamp, 1d)
| order by timestamp desc
Complete Working Example: Observability-Driven Deployment Pipeline
This pipeline integrates Azure Monitor at every stage — pre-deployment health checks, deployment annotations, post-deployment verification, and rollback triggers:
# azure-pipelines-observable.yml
trigger:
branches:
include:
- main
pool:
vmImage: "ubuntu-latest"
variables:
- group: azure-monitor-config
- name: appInsightsName
value: "ai-myapp-prod"
- name: resourceGroup
value: "rg-myapp-prod"
stages:
- stage: Build
jobs:
- job: BuildAndTest
steps:
- script: npm ci && npm test
displayName: "Build and test"
- task: PublishPipelineArtifact@1
inputs:
targetPath: "$(System.DefaultWorkingDirectory)"
artifact: "app"
- stage: PreDeployCheck
displayName: "Pre-Deployment Health Check"
dependsOn: Build
jobs:
- job: CheckHealth
steps:
- task: AzureCLI@2
displayName: "Verify production is healthy before deploy"
inputs:
azureSubscription: "your-azure-subscription"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
ERROR_RATE=$(az monitor app-insights query \
--app $(appInsightsName) \
--resource-group $(resourceGroup) \
--analytics-query "requests | where timestamp > ago(10m) | summarize round(todouble(countif(success == false)) / count() * 100, 2)" \
--query "tables[0].rows[0][0]" --output tsv)
echo "Current error rate: ${ERROR_RATE}%"
if (( $(echo "$ERROR_RATE > 10.0" | bc -l) )); then
echo "##vso[task.logissue type=error]Production error rate is ${ERROR_RATE}% — too high to deploy"
exit 1
fi
ACTIVE_ALERTS=$(az monitor alert list \
--resource-group $(resourceGroup) \
--query "length([?properties.essentials.monitorCondition=='Fired' && (properties.essentials.severity=='Sev0' || properties.essentials.severity=='Sev1')])" \
--output tsv 2>/dev/null || echo "0")
echo "Active Sev0/Sev1 alerts: $ACTIVE_ALERTS"
if [ "$ACTIVE_ALERTS" -gt "0" ]; then
echo "##vso[task.logissue type=error]Active critical alerts — deployment blocked"
exit 1
fi
echo "Production is healthy. Proceeding with deployment."
- stage: Deploy
dependsOn: PreDeployCheck
jobs:
- deployment: Production
environment: "production"
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying build $(Build.BuildNumber)..."
displayName: "Deploy"
- task: AzureCLI@2
displayName: "Create deployment annotation"
inputs:
azureSubscription: "your-azure-subscription"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az monitor app-insights component update-tags \
--app $(appInsightsName) \
--resource-group $(resourceGroup) \
--tags "lastDeployment=$(Build.BuildNumber)"
echo "Deployment annotation created"
- stage: PostDeployVerify
displayName: "Post-Deployment Verification"
dependsOn: Deploy
jobs:
- job: Verify
steps:
- script: sleep 120
displayName: "Wait for stabilization (2 min)"
- task: AzureCLI@2
displayName: "Post-deployment health check"
inputs:
azureSubscription: "your-azure-subscription"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
echo "Checking post-deployment metrics..."
RESULTS=$(az monitor app-insights query \
--app $(appInsightsName) \
--resource-group $(resourceGroup) \
--analytics-query "
requests
| where timestamp > ago(5m)
| summarize
errorRate = round(todouble(countif(success == false)) / count() * 100, 2),
avgDuration = round(avg(duration), 0),
p95Duration = round(percentile(duration, 95), 0),
totalRequests = count()
" --output json)
echo "$RESULTS" | jq '.'
ERROR_RATE=$(echo "$RESULTS" | jq -r '.tables[0].rows[0][0]')
AVG_DURATION=$(echo "$RESULTS" | jq -r '.tables[0].rows[0][1]')
P95_DURATION=$(echo "$RESULTS" | jq -r '.tables[0].rows[0][2]')
echo "Error rate: ${ERROR_RATE}%"
echo "Avg response time: ${AVG_DURATION}ms"
echo "P95 response time: ${P95_DURATION}ms"
if (( $(echo "$ERROR_RATE > 5.0" | bc -l) )); then
echo "##vso[task.logissue type=error]Post-deployment error rate ${ERROR_RATE}% exceeds threshold"
exit 1
fi
echo "Post-deployment verification PASSED"
- script: node scripts/pipeline-telemetry.js
displayName: "Send deployment telemetry"
condition: always()
env:
APPINSIGHTS_INSTRUMENTATION_KEY: $(APPINSIGHTS_KEY)
Common Issues and Troubleshooting
Deployment annotation not appearing in Application Insights
The annotations API has changed across Application Insights versions. Classic Application Insights uses the /Annotations endpoint while workspace-based Application Insights uses a different mechanism. Verify your Application Insights resource type and use the corresponding API. Also check that the annotation timestamp falls within the time range displayed on the dashboard.
Release gate times out waiting for Azure Monitor query
Gate evaluation timed out
Azure Monitor queries can take 10-30 seconds, and gates have a short evaluation window. Increase the gate timeout and evaluation interval in the gate configuration. Also simplify your KQL query — complex queries with multiple joins or large time ranges take longer to execute.
Application Insights query returns "Forbidden" from pipeline
ERROR: (AuthorizationFailed) The client does not have authorization to perform action 'microsoft.insights/components/query/read'
The service connection's Service Principal needs the Monitoring Reader role on the Application Insights resource. Assign the role at the resource group level to cover all monitoring resources. The default Contributor role does not include monitoring query permissions.
Alert webhook payload format changed unexpectedly
Azure Monitor has two alert payload schemas: common alert schema and the legacy schema. If you enabled the common alert schema on an action group but your webhook handler expects the legacy format, parsing fails. Always check the action group's "Use common alert schema" setting and handle both formats in your webhook receiver.
Best Practices
Create deployment annotations on every production deployment. Annotations are the fastest way to correlate production issues with deployments. When someone reports a bug, the first question is "did anything deploy recently?" — annotations answer that instantly.
Use pre-deployment gates to block deploys during incidents. If production is already on fire, deploying new code makes it worse. A simple gate that checks for active Sev0/Sev1 alerts prevents piling new changes on top of existing problems.
Send custom pipeline telemetry to Application Insights. Track build duration, success rates, and failure patterns over time. This turns Application Insights into a single dashboard for both application and CI/CD health.
Deduplicate alert-driven work items. Alert rules fire repeatedly while a condition persists. Without deduplication, a single production incident can create dozens of identical bugs. Check for existing open work items with the same alert rule name before creating new ones.
Set post-deployment verification windows based on your traffic patterns. A 2-minute stabilization period works for high-traffic applications that generate enough telemetry quickly. Low-traffic services may need 10-15 minutes for meaningful error rate calculations.
Use KQL queries for sophisticated health checks. Simple HTTP health endpoints tell you the app is running. KQL queries against Application Insights tell you the app is running well — error rates, latency percentiles, dependency failures, and exception trends.