Introduction: The Tagging Nightmare
Picture this: You’re staring at a $95,000 monthly Azure bill. There are 3,847 resources across 12 subscriptions. Exactly 7% of them have any tags at all, and those tags are inconsistent gems like “temp”, “test123”, and “Bob’s project”.
Your CFO wants to know which team is responsible for the $32,000 spike last month. Your answer? “I have no idea.”
Sound familiar?
Here’s the truth: Perfect tagging is a myth. Even companies with “mature” tag policies have 30-40% untagged resources on average. And retroactively tagging thousands of existing resources? That’s a six-month project nobody has time for.
This guide shows you how to manage costs RIGHT NOW, with the mess you have, not the perfect environment you wish you had.
Why Traditional Tagging Strategies Fail
The “Perfect World” Fallacy
Every Azure governance guide preaches the same gospel:
- Define a comprehensive tagging taxonomy
- Implement Azure Policy to enforce tags
- Train all teams on proper tagging
- Audit regularly for compliance
Reality check: By the time you implement this, you’ve already burned through another $500,000 in untracked costs.
The Real Reasons Tags Don’t Exist
From investigating hundreds of Azure environments, here’s why resources actually go untagged:
- CI/CD Pipelines: Automated deployments that nobody updated with tag requirements
- PoC Turned Production: “Temporary” resources that became permanent
- Vendor Deployments: Third-party tools that create resources without your tags
- Azure Services: Some Azure services create child resources that don’t inherit tags
- Emergency Fixes: 2 AM incident response doesn’t include proper tagging
- Human Nature: Even with policies, people forget or make typos
The Detective Approach: Finding Owners Without Tags
When tags fail, become a detective. Every resource leaves clues about its owner and purpose.
Strategy 1: Resource Group Intelligence
Resource groups are your first clue. Even poorly named ones follow patterns:
# Analyze resource group naming patterns
$resourceGroups = Get-AzResourceGroup
$rgPatterns = @{}
foreach ($rg in $resourceGroups) {
# Extract potential team/project indicators
$patterns = @()
# Common patterns to look for
if ($rg.ResourceGroupName -match '([a-zA-Z]+)-(dev|test|staging|prod|prd|production)') {
$patterns += $Matches[1] # Likely team or app name
}
if ($rg.ResourceGroupName -match '(rg|RG|resourcegroup)-([a-zA-Z]+)-') {
$patterns += $Matches[2] # Structured naming
}
# Date patterns (often indicate projects)
if ($rg.ResourceGroupName -match '(2020|2021|2022|2023|2024|[0-9]{6,8})') {
$patterns += "Date: $($Matches[1])"
}
# Email patterns in names
if ($rg.ResourceGroupName -match '([a-zA-Z]+\.[a-zA-Z]+)' -or
$rg.ResourceGroupName -match '([a-zA-Z]+)([A-Z][a-zA-Z]+)') {
$patterns += "Possible Name: $($Matches[0])"
}
foreach ($pattern in $patterns) {
if (-not $rgPatterns.ContainsKey($pattern)) {
$rgPatterns[$pattern] = @()
}
$rgPatterns[$pattern] += $rg.ResourceGroupName
}
}
# Output discovered patterns
Write-Host "Discovered Naming Patterns:" -ForegroundColor Cyan
foreach ($pattern in $rgPatterns.Keys | Sort-Object) {
Write-Host "`n$pattern :" -ForegroundColor Yellow
$rgPatterns[$pattern] | ForEach-Object { Write-Host " - $_" }
}
Strategy 2: Activity Log Forensics
The Activity Log tells you WHO created resources, even without tags:
function Find-ResourceCreators {
param(
[string]$ResourceGroupName,
[int]$DaysBack = 90
)
$startTime = (Get-Date).AddDays(-$DaysBack)
$creators = @{}
# Get activity logs
$logs = Get-AzLog -ResourceGroupName $ResourceGroupName `
-StartTime $startTime `
-Status "Succeeded" `
-WarningAction SilentlyContinue
foreach ($log in $logs) {
if ($log.OperationName.Value -match "write|create") {
$caller = $log.Caller
# Clean up caller (remove service principals GUIDs)
if ($caller -match '@') {
# It's an email
$creator = $caller
} elseif ($log.Claims['name']) {
# Try to get name from claims
$creator = $log.Claims['name']
} else {
# Probably a service principal
$creator = "ServicePrincipal: $($caller.Substring(0,8))..."
}
if (-not $creators.ContainsKey($creator)) {
$creators[$creator] = @{
Count = 0
LastActivity = $log.EventTimestamp
Operations = @()
}
}
$creators[$creator].Count++
if ($log.EventTimestamp -gt $creators[$creator].LastActivity) {
$creators[$creator].LastActivity = $log.EventTimestamp
}
$creators[$creator].Operations += $log.OperationName.Value
}
}
# Output findings
Write-Host "`nResource Group: $ResourceGroupName" -ForegroundColor Cyan
Write-Host "Most Likely Owners/Creators:" -ForegroundColor Yellow
foreach ($creator in $creators.Keys | Sort-Object {$creators[$_].Count} -Descending) {
Write-Host " $creator" -ForegroundColor Green
Write-Host " Activities: $($creators[$creator].Count)"
Write-Host " Last Active: $($creators[$creator].LastActivity)"
}
return $creators
}
# Analyze top spending resource groups
$topRGs = Get-AzConsumptionUsageDetail -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) |
Group-Object ResourceGroup |
Sort-Object {($_.Group | Measure-Object -Property Cost -Sum).Sum} -Descending |
Select-Object -First 10
foreach ($rg in $topRGs) {
Find-ResourceCreators -ResourceGroupName $rg.Name
}
Strategy 3: Network Topology Mapping
Resources that talk to each other usually belong to the same team:
function Map-ApplicationByNetwork {
$vnetMap = @{}
# Map all VMs to their VNets
$vms = Get-AzVM
foreach ($vm in $vms) {
if ($vm.NetworkProfile.NetworkInterfaces) {
$nic = Get-AzNetworkInterface -ResourceId $vm.NetworkProfile.NetworkInterfaces[0].Id
$subnet = $nic.IpConfigurations[0].Subnet.Id
$vnet = $subnet.Split('/')[8]
if (-not $vnetMap.ContainsKey($vnet)) {
$vnetMap[$vnet] = @{
VMs = @()
Databases = @()
AppServices = @()
LoadBalancers = @()
EstimatedMonthlyCost = 0
}
}
$vnetMap[$vnet].VMs += $vm.Name
# Estimate VM cost (rough)
$vmSize = $vm.HardwareProfile.VmSize
$vnetMap[$vnet].EstimatedMonthlyCost += Get-VMCostEstimate -Size $vmSize
}
}
# Map SQL Databases
$sqlServers = Get-AzSqlServer
foreach ($server in $sqlServers) {
$vnetRules = Get-AzSqlServerVirtualNetworkRule -ResourceGroupName $server.ResourceGroupName `
-ServerName $server.ServerName
foreach ($rule in $vnetRules) {
$vnet = $rule.VirtualNetworkSubnetId.Split('/')[8]
if ($vnetMap.ContainsKey($vnet)) {
$databases = Get-AzSqlDatabase -ServerName $server.ServerName `
-ResourceGroupName $server.ResourceGroupName
$vnetMap[$vnet].Databases += $databases.DatabaseName
}
}
}
# Map App Services
$appServices = Get-AzWebApp
foreach ($app in $appServices) {
if ($app.VirtualNetworkSubnetId) {
$vnet = $app.VirtualNetworkSubnetId.Split('/')[8]
if ($vnetMap.ContainsKey($vnet)) {
$vnetMap[$vnet].AppServices += $app.Name
}
}
}
# Output application groups
Write-Host "`n=== Discovered Application Groups by Network ===" -ForegroundColor Cyan
foreach ($vnet in $vnetMap.Keys) {
$app = $vnetMap[$vnet]
if ($app.VMs.Count -gt 0 -or $app.Databases.Count -gt 0 -or $app.AppServices.Count -gt 0) {
Write-Host "`nVNet: $vnet" -ForegroundColor Yellow
Write-Host "Estimated Monthly Cost: `$$($app.EstimatedMonthlyCost)" -ForegroundColor Green
if ($app.VMs.Count -gt 0) {
Write-Host " VMs ($($app.VMs.Count)):" -ForegroundColor White
$app.VMs | ForEach-Object { Write-Host " - $_" }
}
if ($app.Databases.Count -gt 0) {
Write-Host " Databases ($($app.Databases.Count)):" -ForegroundColor White
$app.Databases | ForEach-Object { Write-Host " - $_" }
}
if ($app.AppServices.Count -gt 0) {
Write-Host " App Services ($($app.AppServices.Count)):" -ForegroundColor White
$app.AppServices | ForEach-Object { Write-Host " - $_" }
}
}
}
return $vnetMap
}
# Helper function for cost estimation
function Get-VMCostEstimate {
param([string]$Size)
# Rough estimates for common sizes (adjust for your region)
$estimates = @{
'Standard_B2s' = 30
'Standard_B2ms' = 60
'Standard_D2s_v3' = 96
'Standard_D4s_v3' = 192
'Standard_D8s_v3' = 384
'Standard_E4s_v3' = 252
'Standard_E8s_v3' = 504
}
if ($estimates.ContainsKey($Size)) {
return $estimates[$Size]
} else {
return 100 # Default estimate
}
}
Map-ApplicationByNetwork
Strategy 4: Storage Account Association
Storage accounts often contain logs or data that reveal ownership:
function Find-StorageAccountOwners {
$storageAccounts = Get-AzStorageAccount
$ownershipClues = @()
foreach ($storage in $storageAccounts) {
$clues = @{
StorageAccount = $storage.StorageAccountName
ResourceGroup = $storage.ResourceGroupName
Clues = @()
LikelyOwner = "Unknown"
}
# Check for diagnostic settings pointing to this storage
$diagnosticUsers = @()
# Check VMs with boot diagnostics
$vms = Get-AzVM
foreach ($vm in $vms) {
if ($vm.DiagnosticsProfile.BootDiagnostics.StorageUri -match $storage.StorageAccountName) {
$diagnosticUsers += "VM: $($vm.Name)"
}
}
if ($diagnosticUsers.Count -gt 0) {
$clues.Clues += "Boot Diagnostics: $($diagnosticUsers -join ', ')"
}
# Check container names for clues
$ctx = $storage.Context
$containers = Get-AzStorageContainer -Context $ctx
foreach ($container in $containers) {
# Look for patterns in container names
if ($container.Name -match 'backup|bak|archive') {
$clues.Clues += "Contains backups"
}
if ($container.Name -match 'log|diagnostic|insight') {
$clues.Clues += "Contains logs"
}
if ($container.Name -match '([a-zA-Z]+\.[a-zA-Z]+)@') {
$clues.LikelyOwner = $Matches[1]
}
}
# Check access logs
$logs = Get-AzLog -ResourceId $storage.Id -StartTime (Get-Date).AddDays(-7) -WarningAction SilentlyContinue
$accessors = $logs | Where-Object {$_.Caller} | Select-Object -ExpandProperty Caller -Unique
if ($accessors) {
$clues.Clues += "Recent Access: $($accessors -join ', ')"
if ($accessors[0] -match '@') {
$clues.LikelyOwner = $accessors[0]
}
}
$ownershipClues += $clues
}
# Output findings
$ownershipClues | Format-Table StorageAccount, LikelyOwner, @{N='Clues';E={$_.Clues -join '; '}} -Wrap
return $ownershipClues
}
Find-StorageAccountOwners
Building a Cost Attribution Model Without Tags
The “Reverse Attribution” Method
Instead of trying to tag everything, build a cost model based on what you can discover:
# Create a cost attribution database
$costAttribution = @{}
# Step 1: Get current month costs by resource
$startDate = Get-Date -Day 1
$endDate = Get-Date
$costs = Get-AzConsumptionUsageDetail -StartDate $startDate -EndDate $endDate
# Step 2: Group by resource group first
$rgCosts = $costs | Group-Object ResourceGroup | ForEach-Object {
[PSCustomObject]@{
ResourceGroup = $_.Name
TotalCost = ($_.Group | Measure-Object -Property Cost -Sum).Sum
ResourceCount = $_.Count
TopResource = $_.Group | Sort-Object Cost -Descending | Select-Object -First 1
}
} | Sort-Object TotalCost -Descending
# Step 3: Apply attribution rules
foreach ($rg in $rgCosts) {
$attribution = "Unassigned"
$confidence = "Low"
# Rule 1: Check activity logs for creator
$logs = Get-AzLog -ResourceGroupName $rg.ResourceGroup `
-StartTime (Get-Date).AddDays(-90) `
-MaxRecord 100 `
-WarningAction SilentlyContinue
$creators = $logs | Where-Object {$_.Caller -match '@'} |
Select-Object -ExpandProperty Caller -Unique
if ($creators.Count -eq 1) {
$attribution = $creators[0]
$confidence = "High"
} elseif ($creators.Count -gt 1) {
# Most frequent creator
$attribution = $logs | Where-Object {$_.Caller -match '@'} |
Group-Object Caller |
Sort-Object Count -Descending |
Select-Object -First 1 -ExpandProperty Name
$confidence = "Medium"
}
# Rule 2: Check naming patterns
if ($attribution -eq "Unassigned") {
if ($rg.ResourceGroup -match '(dev|test|qa|uat)') {
$attribution = "Development Team"
$confidence = "Medium"
}
if ($rg.ResourceGroup -match '(prod|prd|production)') {
$attribution = "Production Systems"
$confidence = "Medium"
}
if ($rg.ResourceGroup -match '(data|analytics|bi|etl)') {
$attribution = "Data Team"
$confidence = "Medium"
}
}
# Rule 3: Check resource types
if ($attribution -eq "Unassigned") {
$resources = Get-AzResource -ResourceGroupName $rg.ResourceGroup
$resourceTypes = $resources.ResourceType | Group-Object | Sort-Object Count -Descending
if ($resourceTypes[0].Name -match 'databricks|datafactory|synapse') {
$attribution = "Data Team"
$confidence = "Medium"
}
if ($resourceTypes[0].Name -match 'kubernetes|containerInstance') {
$attribution = "Platform Team"
$confidence = "Medium"
}
}
$costAttribution[$rg.ResourceGroup] = @{
Cost = [math]::Round($rg.TotalCost, 2)
Attribution = $attribution
Confidence = $confidence
ResourceCount = $rg.ResourceCount
}
}
# Generate attribution report
$report = @"
<html>
<head>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #0078D4; color: white; }
.high { background-color: #90EE90; }
.medium { background-color: #FFE4B5; }
.low { background-color: #FFB6C1; }
</style>
</head>
<body>
<h2>Cost Attribution Report - $(Get-Date -Format 'yyyy-MM-dd')</h2>
<table>
<tr>
<th>Resource Group</th>
<th>Monthly Cost</th>
<th>Attributed To</th>
<th>Confidence</th>
<th>Resource Count</th>
</tr>
"@
foreach ($rg in $costAttribution.Keys | Sort-Object {$costAttribution[$_].Cost} -Descending) {
$attr = $costAttribution[$rg]
$report += @"
<tr>
<td>$rg</td>
<td>`$$($attr.Cost)</td>
<td>$($attr.Attribution)</td>
<td class="$($attr.Confidence.ToLower())">$($attr.Confidence)</td>
<td>$($attr.ResourceCount)</td>
</tr>
"@
}
$report += @"
</table>
<h3>Summary by Attribution</h3>
<table>
<tr><th>Attributed To</th><th>Total Cost</th><th>Resource Groups</th></tr>
"@
# Summarize by attribution
$summary = $costAttribution.Values | Group-Object Attribution | ForEach-Object {
[PSCustomObject]@{
Attribution = $_.Name
TotalCost = ($_.Group | Measure-Object -Property Cost -Sum).Sum
Count = $_.Count
}
} | Sort-Object TotalCost -Descending
foreach ($sum in $summary) {
$report += "<tr><td>$($sum.Attribution)</td><td>`$$([math]::Round($sum.TotalCost, 2))</td><td>$($sum.Count)</td></tr>"
}
$report += "</table></body></html>"
# Save report
$report | Out-File "CostAttribution_$(Get-Date -Format 'yyyyMMdd').html"
Write-Host "Report saved to CostAttribution_$(Get-Date -Format 'yyyyMMdd').html" -ForegroundColor Green
# Also create CSV for further analysis
$costAttribution.GetEnumerator() | ForEach-Object {
[PSCustomObject]@{
ResourceGroup = $_.Key
Cost = $_.Value.Cost
Attribution = $_.Value.Attribution
Confidence = $_.Value.Confidence
ResourceCount = $_.Value.ResourceCount
}
} | Export-Csv "CostAttribution_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
Emergency Tagging Strategy: The “Retroactive Tag” Approach
When you absolutely need tags (for compliance, chargeback, etc.), here’s the fastest way to retroactively tag resources:
The 80/20 Tagging Script
Tag the 20% of resources that represent 80% of your costs:
function Add-EmergencyTags {
param(
[int]$TopPercentOfCosts = 80
)
# Get all resources with their costs
$startDate = Get-Date -Day 1
$costs = Get-AzConsumptionUsageDetail -StartDate $startDate -EndDate (Get-Date)
# Group by resource and calculate costs
$resourceCosts = $costs | Group-Object ResourceId | ForEach-Object {
[PSCustomObject]@{
ResourceId = $_.Name
TotalCost = ($_.Group | Measure-Object -Property Cost -Sum).Sum
ResourceName = $_.Group[0].ResourceName
ResourceType = $_.Group[0].ConsumedService
}
} | Sort-Object TotalCost -Descending
# Calculate cost threshold
$totalCost = ($resourceCosts | Measure-Object -Property TotalCost -Sum).Sum
$targetCost = $totalCost * ($TopPercentOfCosts / 100)
$runningTotal = 0
$resourcesToTag = @()
foreach ($resource in $resourceCosts) {
$runningTotal += $resource.TotalCost
$resourcesToTag += $resource
if ($runningTotal -ge $targetCost) {
break
}
}
Write-Host "Tagging $($resourcesToTag.Count) resources representing $TopPercentOfCosts% of costs" -ForegroundColor Cyan
# Apply tags based on discovered patterns
foreach ($resource in $resourcesToTag) {
if ($resource.ResourceId) {
try {
$azResource = Get-AzResource -ResourceId $resource.ResourceId -ErrorAction Stop
# Determine owner
$owner = "unassigned@company.com"
$logs = Get-AzLog -ResourceId $resource.ResourceId -StartTime (Get-Date).AddDays(-30) -MaxRecord 10 -WarningAction SilentlyContinue
$creators = $logs | Where-Object {$_.Caller -match '@'} | Select-Object -ExpandProperty Caller -Unique
if ($creators) {
$owner = $creators[0]
}
# Determine environment
$environment = "unknown"
if ($azResource.Name -match 'prod|prd') { $environment = "production" }
elseif ($azResource.Name -match 'dev|test|qa') { $environment = "development" }
elseif ($azResource.Name -match 'stg|stage|uat') { $environment = "staging" }
# Determine cost center (based on resource group patterns)
$costCenter = "unassigned"
if ($azResource.ResourceGroupName -match 'data|analytics') { $costCenter = "data-team" }
elseif ($azResource.ResourceGroupName -match 'web|api|frontend') { $costCenter = "product-team" }
elseif ($azResource.ResourceGroupName -match 'infra|platform|shared') { $costCenter = "platform-team" }
# Apply tags
$tags = @{
'Owner' = $owner
'Environment' = $environment
'CostCenter' = $costCenter
'TaggedDate' = (Get-Date -Format 'yyyy-MM-dd')
'TaggedBy' = 'emergency-tagging-script'
'MonthlyCost' = [math]::Round($resource.TotalCost, 2).ToString()
}
# Merge with existing tags
if ($azResource.Tags) {
$existingTags = $azResource.Tags
foreach ($key in $tags.Keys) {
if (-not $existingTags.ContainsKey($key)) {
$existingTags[$key] = $tags[$key]
}
}
$tags = $existingTags
}
Set-AzResource -ResourceId $resource.ResourceId -Tag $tags -Force
Write-Host "âś“ Tagged: $($azResource.Name)" -ForegroundColor Green
} catch {
Write-Host "âś— Failed to tag: $($resource.ResourceName) - $_" -ForegroundColor Red
}
}
}
Write-Host "`nTagging complete. Run tag compliance report to verify." -ForegroundColor Cyan
}
# Run emergency tagging for top 80% of costs
Add-EmergencyTags -TopPercentOfCosts 80
Preventing Future Tagging Disasters
Implement “Tag-or-Die” Policy
Force tagging for new resources only:
{
"properties": {
"displayName": "Require minimum tags on resource groups",
"policyType": "Custom",
"mode": "All",
"description": "Enforces required tags on new resource groups",
"metadata": {
"category": "Tags"
},
"parameters": {},
"policyRule": {
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Resources/subscriptions/resourceGroups"
},
{
"anyOf": [
{
"field": "tags['Owner']",
"exists": false
},
{
"field": "tags['Environment']",
"exists": false
},
{
"field": "tags['CostCenter']",
"exists": false
}
]
}
]
},
"then": {
"effect": "deny"
}
}
}
}
Auto-Tagging with Azure Functions
Create an Azure Function that automatically tags resources based on patterns:
using System;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.Management.ResourceManager;
using Microsoft.Azure.Management.ResourceManager.Models;
using Microsoft.Rest.Azure.Authentication;
public static class AutoTagger
{
[FunctionName("AutoTagResources")]
public static async Task Run(
[TimerTrigger("0 0 * * * *")] TimerInfo myTimer,
ILogger log)
{
var credentials = await ApplicationTokenProvider.LoginSilentAsync(
tenantId, clientId, clientSecret);
var resourceClient = new ResourceManagementClient(credentials)
{
SubscriptionId = subscriptionId
};
// Get all untagged resources
var resources = await resourceClient.Resources.ListAsync(
filter: "tagName eq ''");
foreach (var resource in resources)
{
var tags = new Dictionary<string, string>();
// Auto-detect owner from activity log
var owner = await GetResourceCreator(resource.Id);
tags["Owner"] = owner ?? "unassigned@company.com";
// Auto-detect environment from name
if (resource.Name.Contains("prod", StringComparison.OrdinalIgnoreCase))
tags["Environment"] = "production";
else if (resource.Name.Contains("dev", StringComparison.OrdinalIgnoreCase))
tags["Environment"] = "development";
else
tags["Environment"] = "unknown";
// Apply tags
await resourceClient.Tags.CreateOrUpdateAtResourceAsync(
resource.Id, new TagsResource(tags));
log.LogInformation($"Tagged resource: {resource.Name}");
}
}
}
The Cloud Sleuth Advantage
While these manual techniques work, they’re time-consuming and error-prone. Cloud Sleuth automates this entire process:
- Automatic Owner Detection: Our ML models analyze activity patterns, naming conventions, and network topology to identify owners with 94% accuracy
- Smart Cost Attribution: Even with zero tags, we can attribute 85%+ of your costs to specific teams or projects
- Continuous Tracking: We monitor for new untagged resources and automatically classify them
- One-Click Reports: Generate cost attribution reports for finance without any tags
Stop fighting the tagging battle. Start managing costs with the environment you have.
[Try Cloud Sleuth Free] - See your costs attributed without tags in under 10 minutes.
Conclusion: Work With What You Have
Perfect tagging is a luxury most organizations don’t have. But imperfect tagging doesn’t mean you can’t manage costs effectively.
The strategies in this guide will help you:
- Identify resource owners without tags
- Build cost attribution models from behavioral patterns
- Implement emergency tagging for your highest-cost resources
- Prevent future tagging disasters
Remember: The goal isn’t perfect tags. It’s understanding and controlling your Azure costs.
Whether you use these manual techniques or automate with Cloud Sleuth, you can take control of your costs today—tags or no tags.
Need help implementing these strategies? Contact us at help@cloudsleuth.com for a free consultation on managing your untagged Azure environment.