#Requires -Version 5.1 <# .SYNOPSIS Cosmos DB — Cost / RU-consumption report. .DESCRIPTION Identifies which database (product) and which container drives the most Azure Cosmos DB cost. For SERVERLESS accounts cost is dominated by Request Units (RU) consumed; for PROVISIONED accounts it is the provisioned throughput. The billing mode is auto-detected. Reports: 1. Account billing mode (serverless vs provisioned) + region 2. Actual billed cost (Cost Management, last 30d) by service 3. RU consumption by database, ranked, with est. monthly $ 4. RU consumption by container for the top databases 5. Storage (DataUsage) by database .PARAMETER Account Cosmos account name. Falls back to $env:COSMOS_ACCOUNT_NAME. .PARAMETER ResourceGroup Resource group. Falls back to $env:COSMOS_RESOURCE_GROUP. .PARAMETER Days Lookback window (days) for RU metrics. Default 7 (or $env:DAYS). .PARAMETER Top Rows per table. Default 15 (or $env:TOP). .PARAMETER Drill Number of top databases to drill into by container. Default 3. .PARAMETER Rate USD per 1M RU (serverless). Default 0.25 (or $env:SERVERLESS_RU_RATE). .EXAMPLE $env:COSMOS_ACCOUNT_NAME='cosmos-mywisprai'; $env:COSMOS_RESOURCE_GROUP='rg-mywisprai' ./scripts/cosmos-cost-report.ps1 .EXAMPLE ./scripts/cosmos-cost-report.ps1 -Account cosmos-mywisprai -ResourceGroup rg-mywisprai -Days 7 .NOTES Prerequisites: Azure CLI installed and authenticated (az login). #> [CmdletBinding()] param( [string]$Account = $env:COSMOS_ACCOUNT_NAME, [string]$ResourceGroup = $env:COSMOS_RESOURCE_GROUP, [int] $Days = $(if ($env:DAYS) { [int]$env:DAYS } else { 7 }), [int] $Top = $(if ($env:TOP) { [int]$env:TOP } else { 15 }), [int] $Drill = 3, [double]$Rate = $(if ($env:SERVERLESS_RU_RATE) { [double]$env:SERVERLESS_RU_RATE } else { 0.25 }) ) $ErrorActionPreference = 'Stop' if (-not $Account) { Write-Error 'Set COSMOS_ACCOUNT_NAME or pass -Account'; exit 2 } if (-not $ResourceGroup) { Write-Error 'Set COSMOS_RESOURCE_GROUP or pass -ResourceGroup'; exit 2 } if (-not (Get-Command az -ErrorAction SilentlyContinue)) { Write-Error 'az CLI not found'; exit 2 } $start = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ') $end = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') Write-Host '════════════════════════════════════════════════════════════════' Write-Host ' Cosmos DB Cost Report' Write-Host " Account: $Account Resource group: $ResourceGroup" Write-Host " RU window: last ${Days}d ($start -> $end)" Write-Host '════════════════════════════════════════════════════════════════' $rid = az cosmosdb show -n $Account -g $ResourceGroup --query id -o tsv $caps = az cosmosdb show -n $Account -g $ResourceGroup --query "capabilities[].name" -o tsv $region = az cosmosdb show -n $Account -g $ResourceGroup --query "locations[0].locationName" -o tsv $mode = if ($caps -match 'EnableServerless') { 'SERVERLESS' } else { 'PROVISIONED' } Write-Host '' Write-Host "▶ Billing mode : $mode" Write-Host "▶ Region : $region" Write-Host ("▶ Est. RU rate : `$$Rate per 1M RU (serverless)") # ── 1. Actual billed cost (Cost Management, last 30d) ───────────── Write-Host '' Write-Host '── Actual billed cost — last 30d by service (resource group) ──' $sub = az account show --query id -o tsv $cmFrom = (Get-Date).ToUniversalTime().AddDays(-30).ToString('yyyy-MM-ddT00:00:00Z') $cmTo = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddT00:00:00Z') $cmBody = @{ type = 'ActualCost' timeframe = 'Custom' timePeriod = @{ from = $cmFrom; to = $cmTo } dataset = @{ granularity = 'None' aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } grouping = @(@{ type = 'Dimension'; name = 'ServiceName' }) } } | ConvertTo-Json -Depth 10 -Compress $tmp = New-TemporaryFile Set-Content -Path $tmp -Value $cmBody -NoNewline try { $rows = az rest --method post ` --url "https://management.azure.com/subscriptions/$sub/resourceGroups/$ResourceGroup/providers/Microsoft.CostManagement/query?api-version=2023-11-01" ` --body "@$tmp" --headers "Content-Type=application/json" ` --query "properties.rows" -o json 2>$null | ConvertFrom-Json if ($rows) { $rows | Sort-Object { [double]$_[0] } -Descending | ForEach-Object { ' {0,-28} ${1,8:N2} {2}' -f ([string]$_[1]).Substring(0,[Math]::Min(28,([string]$_[1]).Length)), [double]$_[0], $_[2] } } else { Write-Host ' (no cost data — needs Cost Management reader on the subscription)' } } catch { Write-Host ' (cost query unavailable — continuing with RU metrics)' } finally { Remove-Item $tmp -ErrorAction SilentlyContinue } # Helper: query TotalRequestUnits split by a dimension, return [pscustomobject]@{ k; ru } function Get-RuByDimension { param([string]$Filter) $json = az monitor metrics list --resource $rid --metric TotalRequestUnits ` --aggregation Total --interval P1D --start-time $start --end-time $end ` --filter $Filter --top 500 ` --query "value[0].timeseries[].{k: metadatavalues[0].value, ru: sum(data[].total)}" ` -o json 2>$null if (-not $json) { return @() } $parsed = $json | ConvertFrom-Json if (-not $parsed) { return @() } ,@($parsed | ForEach-Object { [pscustomobject]@{ k = $(if ($_.k) { $_.k } else { '' }); ru = [double]($_.ru) } }) } function Show-RuTable { param([object[]]$Rows) $ranked = @($Rows | Sort-Object ru -Descending) $total = ($ranked | Measure-Object ru -Sum).Sum if (-not $total -or $total -eq 0) { $total = 1.0 } ' {0,-28} {1,16} {2,8} {3,10}' -f 'name', "RU (${Days}d)", 'share', 'est $/mo' | Write-Host ' ' + ('-' * 68) | Write-Host foreach ($r in ($ranked | Select-Object -First $Top)) { $est = $r.ru / $Days * 30.0 / 1000000.0 * $Rate $share = 100.0 * $r.ru / $total $name = ([string]$r.k).Substring(0,[Math]::Min(28,([string]$r.k).Length)) ' {0,-28} {1,16} {2,7:N1}% {3,9:N2}' -f $name, ('{0:N0}' -f $r.ru), $share, $est | Write-Host } $proj = $total / $Days * 30.0 / 1000000.0 * $Rate ' ' + ('-' * 68) | Write-Host ' {0,-28} {1,16} {2,8} {3,9:N2}' -f 'TOTAL', ('{0:N0}' -f $total), '', $proj | Write-Host } # ── 2. RU by database ───────────────────────────────────────────── Write-Host '' Write-Host "── RU consumption by database (product) — last ${Days}d ──" $dbRows = Get-RuByDimension -Filter "DatabaseName eq '*'" Show-RuTable -Rows $dbRows # ── 3. Drill into the top databases by container ────────────────── $topDbs = @($dbRows | Where-Object { $_.ru -gt 0 -and $_.k -ne '' } | Sort-Object ru -Descending | Select-Object -First $Drill -ExpandProperty k) foreach ($db in $topDbs) { Write-Host '' Write-Host "── RU by container in '$db' — last ${Days}d ──" $coll = Get-RuByDimension -Filter "DatabaseName eq '$db' and CollectionName eq '*'" Show-RuTable -Rows $coll } # ── 4. Storage by database ──────────────────────────────────────── Write-Host '' Write-Host '── Storage (DataUsage) by database — latest snapshot ──' $storeJson = az monitor metrics list --resource $rid --metric DataUsage ` --aggregation Total --interval PT1H --start-time $start --end-time $end ` --filter "DatabaseName eq '*'" --top 200 ` --query "value[0].timeseries[].{k: metadatavalues[0].value, b: max(data[].total)}" ` -o json 2>$null $store = if ($storeJson) { @($storeJson | ConvertFrom-Json) } else { @() } if ($store.Count -eq 0) { Write-Host ' (no storage data)' } else { foreach ($s in ($store | Sort-Object { [double]$_.b } -Descending)) { $name = ([string]$s.k).Substring(0,[Math]::Min(28,([string]$s.k).Length)) ' {0,-28} {1,10:N2} MB' -f $name, ([double]$s.b / 1MB) | Write-Host } } Write-Host '' Write-Host '════════════════════════════════════════════════════════════════' Write-Host ' Notes:' Write-Host " - Serverless cost ~= RU consumed x `$$Rate/1M + storage(`$~0.25/GB-mo)." Write-Host " - 'est `$/mo' linearly projects the ${Days}d window to 30 days." Write-Host ' - High RU + low request count => expensive per-op (cross-partition' Write-Host ' queries / large docs) — prime rightsizing target.' Write-Host ' - A *_locks container burning RU is usually lock-polling overhead.' Write-Host '════════════════════════════════════════════════════════════════'