public/Get-MsecAzureCost.ps1
|
function Get-MsecAzureCost { <# .SYNOPSIS Actual Azure spend for a subscription or a resource group over a window, from Cost Management. .DESCRIPTION Queries the Cost Management API for pre-tax cost, one row per scope asked about. THE CURRENCY IS RETURNED, NEVER DISCARDED. Cost Management answers with an amount AND the billing currency, and dropping the currency is how a report ends up adding SEK to EUR and printing a total that means nothing. It is a column here, and a run spanning two billing currencies warns rather than summing them. NOT ROUNDED. The API answers to full precision and this passes it through; round at the point of display, where you know how many places you want. Rounding in the collector loses the difference between "0.4" and "0" - the second reads as free. WHY Invoke-AzRestMethod AND NOT A HAND-BUILT REQUEST. Cost Management has no Az cmdlet for this query shape, so the obvious implementation acquires a token with Get-AzAccessToken and builds the call by hand. That breaks twice over: the ARM endpoint differs per cloud and has to be branched on, and since Az.Accounts 5 the token comes back as a SecureString, so interpolating it yields the literal string 'Bearer System.Security.SecureString' and every call answers 401. Invoke-AzRestMethod handles both - it signs with the current context and resolves the endpoint for the cloud the context is in. COST DATA LAGS. Cost Management is not real time; the most recent day or two is usually incomplete, and a window ending today will under-report slightly. That is a property of the source, not of this command - but it means a day-over-day comparison of the last two days is measuring latency, not spending. .PARAMETER ResourceGroupName Scope to these resource groups. Accepts pipeline input. Omit for the whole subscription. .PARAMETER SubscriptionId The subscription to query. Defaults to the current Az context. .PARAMETER DaysBack How far back the window runs from today. Default 30. .PARAMETER MonthToDate Query the current billing month instead of a rolling window - which is what an invoice is reconciled against. Overrides -DaysBack. .EXAMPLE Connect-AzAccount Get-MsecAzureCost .EXAMPLE # Per resource group, dearest first. Get-AzResourceGroup | Get-MsecAzureCost -DaysBack 30 | Sort-Object Cost -Descending | Format-Table Scope, Cost, Currency, From, To .EXAMPLE # What the invoice will say. Get-MsecAzureCost -MonthToDate .OUTPUTS PSCustomObject per scope, PSTypeName 'MsecAzureCost'. .NOTES Uses your Az context, not the msec app session. Needs Cost Management Reader, or a role that includes it - Reader on the subscription is NOT enough for this API, which is the usual reason for a 401 here. Costs are PRE-TAX and exclude credits, reservations amortisation and marketplace charges billed separately - the same figure the portal's Cost Analysis shows for 'Actual cost'. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [string[]] $ResourceGroupName, [string] $SubscriptionId, [ValidateRange(1, 730)] [int] $DaysBack = 30, [switch] $MonthToDate ) begin { $context = Get-AzContext -ErrorAction SilentlyContinue if (-not $context) { throw 'No Azure context. Run Connect-AzAccount, then Select-MsecAzureContext to pick the subscription to report on.' } $subscription = if ($SubscriptionId) { $SubscriptionId } else { [string] $context.Subscription.Id } if (-not $subscription) { throw 'The Az context has no subscription. Run Select-MsecAzureContext to pick one.' } $to = [DateTime]::UtcNow.Date $from = $to.AddDays(-$DaysBack) $dataset = @{ granularity = 'None' aggregation = @{ totalCost = @{ name = 'PreTaxCost'; function = 'Sum' } } } $payload = if ($MonthToDate) { @{ type = 'ActualCost'; timeframe = 'MonthToDate'; dataset = $dataset } } else { @{ type = 'ActualCost'; timeframe = 'Custom' timePeriod = @{ from = $from.ToString('yyyy-MM-dd'); to = $to.ToString('yyyy-MM-dd') } dataset = $dataset } } $body = $payload | ConvertTo-Json -Depth 20 $currencies = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $query = { param($Scope, $ScopeName) $path = "$Scope/providers/Microsoft.CostManagement/query?api-version=2023-03-01" # COST MANAGEMENT THROTTLES HARD, and this is the command most likely to feel it: # the natural use is a loop over every resource group, which is exactly the shape # that trips the limit. Without retrying, a 30-group report silently loses the # groups that happened to land after the quota ran out - the total comes back # plausible and too low, which is the worst way for a cost report to be wrong. # # The API answers 429 with a Retry-After header saying how long to wait, which is # authoritative when present; the fallback is capped exponential backoff so one # throttled scope cannot stall a run for minutes. $response = $null $maxAttempts = 5 for ($attempt = 1; ; $attempt++) { try { $response = Invoke-AzRestMethod -Method POST -Path $path -Payload $body -ErrorAction Stop } catch { Write-Warning "Cost query failed for '$ScopeName': $($_.Exception.Message)" return } if ($response.StatusCode -ne 429 -or $attempt -ge $maxAttempts) { break } $wait = 0 try { $retryAfter = @($response.Headers | Where-Object { $_.Key -eq 'Retry-After' }) if ($retryAfter.Count) { $wait = [int] (@($retryAfter[0].Value)[0]) } } catch { # Header shapes vary by platform and Az version; backoff covers it. } if ($wait -le 0) { $wait = [Math]::Min(60, [Math]::Pow(2, $attempt + 1)) } Write-Verbose "Cost Management throttled '$ScopeName'. Waiting $wait s, then retry $attempt of $($maxAttempts - 1)." Start-Sleep -Seconds $wait } if ($response.StatusCode -eq 429) { # Said loudly rather than returning nothing: a missing scope makes the total # too low, and a total that is quietly too low is worse than no total. Write-Warning "Cost Management kept throttling '$ScopeName' after $maxAttempts attempts, so it contributes NO row and any total below is short by its cost. Re-run, or narrow the scope." return } if ($response.StatusCode -ne 200) { $detail = if ($response.Content) { $response.Content } else { '(no body)' } if ($response.StatusCode -in 401, 403) { # Reader is not enough for this API and the message should say so, or the # reader goes looking at subscription access that is already correct. Write-Warning "Cost query for '$ScopeName' was denied (HTTP $($response.StatusCode)). This API needs Cost Management Reader - Reader on the subscription is not sufficient. $detail" } else { Write-Warning "Cost query for '$ScopeName' returned HTTP $($response.StatusCode): $detail" } return } $content = $response.Content | ConvertFrom-Json $columns = @($content.properties.columns.name) $rows = @($content.properties.rows) # Column ORDER is not guaranteed, so the indices are looked up by name. Reading # rows[0][0] as the cost happens to work today and would silently return a currency # code as a number the day the API reorders them. $costIndex = [array]::IndexOf($columns, 'PreTaxCost') $currencyIndex = [array]::IndexOf($columns, 'Currency') # No rows means no CHARGES, which is a real answer - an empty resource group costs # nothing. Distinct from a failed query, which returned above. $cost = 0.0 $currency = $null if ($rows.Count -and $costIndex -ge 0) { $cost = [double] $rows[0][$costIndex] if ($currencyIndex -ge 0) { $currency = [string] $rows[0][$currencyIndex] } } if ($currency) { [void] $currencies.Add($currency) } [PSCustomObject]@{ PSTypeName = 'MsecAzureCost' Scope = $ScopeName Cost = $cost Currency = $currency From = $(if ($MonthToDate) { [DateTime]::new($to.Year, $to.Month, 1) } else { $from }) To = $to Timeframe = $(if ($MonthToDate) { 'MonthToDate' } else { "Last $DaysBack days" }) ResourceGroupName = $(if ($ScopeName -ne $subscription) { $ScopeName } else { $null }) SubscriptionId = $subscription } } } process { if ($ResourceGroupName) { foreach ($name in $ResourceGroupName) { & $query "/subscriptions/$subscription/resourceGroups/$name" $name } } else { & $query "/subscriptions/$subscription" $subscription } } end { if ($currencies.Count -gt 1) { # Adding SEK to EUR produces a number that means nothing, and nothing downstream # can tell it happened unless this says so. Write-Warning "This run spans $($currencies.Count) billing currencies ($(($currencies | Sort-Object) -join ', ')). Do NOT sum the Cost column across them." } } } |