learning_ai_common_plat/scripts/cosmos-cost-report.ps1
Saravanakumar D 6d66355a22 feat(scripts): add Cosmos DB cost report tooling (.sh + .ps1)
Reports billed cost, RU by database, RU by container drill-down, and
storage for the cosmos-mywisprai account. Auto-detects serverless vs
provisioned billing mode.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 23:30:22 -07:00

195 lines
9.0 KiB
PowerShell

#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 { '<none>' }); 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 '<none>' } |
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 '════════════════════════════════════════════════════════════════'