"The NSG rule is gone." "The VM was resized overnight." "Who deleted the storage account?" Every control-plane write, delete and action in Azure is recorded in the Activity Log with the caller, time, IP address and result. This cheat sheet has the commands and queries to find the entry quickly and turn it into an answer.
Which source to use
| Source | What it gives you | How far back |
|---|---|---|
Activity Log (portal, az monitor activity-log) | Caller, operation, status, IP, correlation ID | 90 days |
Resource Graph resourcechanges | Property-level before/after values, plus changedBy and client type | 14 days |
AzureActivity table in Log Analytics | Same as the Activity Log, queryable with KQL across subscriptions | Your workspace retention |
Tip
The Activity Log covers the control plane only: ARM operations such as create, update, delete, start and listKeys. It doesn't show who read a blob or a secret, or who ran SQL inside a database. Those actions are in the resource's own diagnostic logs (for example
StorageBlobLogsorAzureDiagnostics), if you enabled them.
Azure CLI
# Everything in a resource group in the last 24 hours (default window is 6h)
az monitor activity-log list -g rg-app-prod --offset 24h \
--query "[].{time:eventTimestamp, caller:caller, op:operationName.value, status:status.value, resource:resourceId}" \
-o table
# One resource
RID=$(az network nsg show -g rg-net -n nsg-snet-app --query id -o tsv)
az monitor activity-log list --resource-id $RID --offset 7d \
--query "[].{time:eventTimestamp, caller:caller, op:operationName.value, status:status.value}" -o table
# Explicit time window around the incident
az monitor activity-log list -g rg-app-prod \
--start-time 2026-10-25T22:00:00Z --end-time 2026-10-26T02:00:00Z -o table
# Only deletes that succeeded
az monitor activity-log list -g rg-app-prod --offset 7d \
--query "[?contains(operationName.value, '/delete') && status.value=='Succeeded'].{time:eventTimestamp, caller:caller, resource:resourceId}" \
-o table
# What a given person or service principal did
az monitor activity-log list --caller ops.admin@contoso.com --offset 3d -o table
# All events for one operation (Started, Accepted, Succeeded/Failed share an ID)
az monitor activity-log list --correlation-id <correlation-id> -o json
For a resource group that no longer exists, search at subscription level and filter on the operation:
az monitor activity-log list --offset 30d --max-events 5000 \
--query "[?operationName.value=='Microsoft.Resources/subscriptions/resourceGroups/delete'].{time:eventTimestamp, caller:caller, status:status.value, rg:resourceGroupName}" \
-o table
Azure PowerShell
Get-AzActivityLog -ResourceGroupName rg-app-prod -StartTime (Get-Date).AddDays(-1) |
Select-Object EventTimestamp, Caller, @{n='Operation';e={$_.OperationName.Value}},
@{n='Status';e={$_.Status.Value}}, ResourceId |
Sort-Object EventTimestamp -Descending | Format-Table -AutoSize
Get-AzActivityLog -ResourceId $rid -StartTime (Get-Date).AddDays(-7) |
Where-Object { $_.OperationName.Value -like '*/delete' }
Get-AzActivityLog -Caller 'ops.admin@contoso.com' -StartTime (Get-Date).AddDays(-3)
Who is this caller?
| Caller looks like | It is | Resolve with |
|---|---|---|
name@domain.com | A user | Check whether a PIM activation was active at that time |
| A GUID | A service principal or managed identity | az ad sp show --id <guid> --query "{name:displayName, appId:appId, type:servicePrincipalType}" |
| Empty, or a platform name | A platform or automatic action (autoscale, policy remediation, maintenance) | Read the event's claims and properties, and check Azure Policy remediation tasks and autoscale history |
# Where did the call come from? IP and the client app
az monitor activity-log list --correlation-id <id> \
--query "[0].{ip:claims.ipaddr, appid:claims.appid, upn:claims.\"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn\", http:httpRequest}"
The httpRequest.clientIpAddress and claims.appid fields tell you whether the change came from the portal, a pipeline agent or a script on someone's laptop. The claim keys vary by event, so dump the full JSON when a field is empty.
What exactly changed: Resource Graph
The Activity Log tells you a write happened. resourcechanges tells you which property changed. Install the extension with az extension add --name resource-graph.
// All changes to one resource in the last 7 days, with who and what
resourcechanges
| extend changeTime = todatetime(properties.changeAttributes.timestamp),
targetResourceId = tostring(properties.targetResourceId),
changeType = tostring(properties.changeType),
changedBy = tostring(properties.changeAttributes.changedBy),
clientType = tostring(properties.changeAttributes.clientType)
| where targetResourceId =~ "/subscriptions/<sub>/resourceGroups/rg-net/providers/Microsoft.Network/networkSecurityGroups/nsg-snet-app"
| where changeTime > ago(7d)
| project changeTime, changeType, changedBy, clientType, properties.changes
| order by changeTime desc
// Everything deleted in a resource group
resourcechanges
| where resourceGroup =~ "rg-app-prod"
| extend changeTime = todatetime(properties.changeAttributes.timestamp),
changeType = tostring(properties.changeType),
changedBy = tostring(properties.changeAttributes.changedBy)
| where changeType == "Delete"
| project changeTime, changedBy, targetResourceId = tostring(properties.targetResourceId)
| order by changeTime desc
// VM size changes
resourcechanges
| extend vmSize = properties.changes["properties.hardwareProfile.vmSize"],
changeTime = todatetime(properties.changeAttributes.timestamp)
| where isnotempty(vmSize)
| project changeTime, targetResourceId = tostring(properties.targetResourceId),
changedBy = tostring(properties.changeAttributes.changedBy),
previousSize = vmSize.previousValue, newSize = vmSize.newValue
Run these with az graph query -q "<query>" --first 100, or in Resource Graph Explorer. changedBy can be System (a platform change) or Unspecified (no identity information was available). In those cases, fall back to the Activity Log and match by correlation ID and time.
KQL on the AzureActivity table
Use this when the Activity Log is exported to Log Analytics and you need more than 90 days, or several subscriptions at once.
// Successful deletes in the last 7 days
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue endswith "/DELETE" and ActivityStatusValue == "Success"
| project TimeGenerated, Caller, CallerIpAddress, OperationNameValue, ResourceGroup, _ResourceId
| order by TimeGenerated desc
// Who touched production in the change freeze window
AzureActivity
| where TimeGenerated between (datetime(2026-10-25 18:00) .. datetime(2026-10-26 06:00))
| where ResourceGroup startswith "RG-PROD" or ResourceGroup startswith "rg-prod"
| where CategoryValue == "Administrative" and ActivityStatusValue in ("Start", "Success")
| summarize ops = count(), actions = make_set(OperationNameValue, 20) by Caller, CallerIpAddress
| order by ops desc
// Role assignments created or removed (privilege changes)
AzureActivity
| where TimeGenerated > ago(30d)
| where OperationNameValue has "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS"
| project TimeGenerated, Caller, OperationNameValue, ActivityStatusValue, _ResourceId, Properties_d
Status values in AzureActivity (Start, Success, Failure, Accepted) differ from the CLI's Started/Succeeded/Failed. The case of OperationNameValue and ResourceGroup can also vary. Use =~, has or endswith (all case-insensitive), not ==.
Prevent it next time
Keep more than 90 days and make it searchable. Send the subscription Activity Log to a central workspace:
az monitor diagnostic-settings subscription create -n activity-to-law --location westeurope \
--workspace /subscriptions/<sub>/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-central \
--logs "[{category:Administrative,enabled:true},{category:Security,enabled:true},{category:Policy,enabled:true},{category:ServiceHealth,enabled:true},{category:ResourceHealth,enabled:true}]"
At scale, assign the built-in policy that configures the Activity Log export at management group level instead.
Warning
Finding the caller after the fact doesn't bring a deleted resource back. Put
CanNotDeletelocks on production resource groups (az lock create --name no-delete --lock-type CanNotDelete -g rg-app-prod). Create activity log alerts on high-impact operations, such as resource group delete, role assignment write and Key Vault delete, so you hear about them within minutes instead of the next morning.
- Give operators standing Reader access, and grant write access through PIM so every change maps to a named, time-boxed activation.
- Use one service principal or managed identity per pipeline, with a clear display name, so a GUID in the Activity Log identifies one pipeline.