Public/Get-AACResourceUtilization.ps1

function Get-AACResourceUtilization {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        How much of what you pay for is used: each VM, scale set, App Service
        plan, database, cache, AKS cluster, storage account and Cosmos DB
        account rated idle, under-used, right sized or hot from its actual
        CPU, memory and activity - with the trend, what to resize it to and
        what that saves.
    .DESCRIPTION
        For each resource of those types in scope, reads its Azure Monitor
        metrics hour by hour over the last -Days (14): CPU (or RU
        consumption), memory (a VM's from its free memory and its size) and
        activity (transactions, connections, network). Each resource
        (AAC.ResourceUtilization) is then:
          Idle CPU 95th percentile under -IdlePercent (5%), or no
                       activity at all
          Under-used CPU 95th percentile under -LowPercent (30%), memory
                       under 60%
          Hot CPU or memory 95th percentile over -HighPercent (80%)
          Right sized in between - and its trend: Growing, Shrinking or
                       Stable (the window's second half against its first)
        An under-used VM is offered the size of its family with half the
        vCPUs, if the memory it uses fits - priced from the public Azure
        Retail Prices API for its region and OS (pay-as-you-go, USD): the
        saving a month.
 
        -IncludeCost reads each resource's cost (Cost Management, last
        month): every row then shows its monthly cost and the share of it
        paying for unused capacity (cost x (1 - average CPU)) - a chargeback
        of the waste - and idle and under-used resources an estimated
        saving.
 
        Deallocated VMs are left out: they aren't billed for compute. A VM
        that's stopped but not deallocated is still billed - and idle.
        Read-only; Reader (or Monitoring Reader) is enough, and Cost
        Management Reader for -IncludeCost.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only these resource groups.
    .PARAMETER ResourceType
        Only these types (wildcards work), e.g. 'microsoft.compute/*'.
    .PARAMETER Days
        How many days of metrics (1 to 30; 14 by default).
    .PARAMETER IncludeCost
        Read each resource's cost too: monthly cost, unused cost, savings.
    .PARAMETER IdlePercent
        CPU 95th percentile under this is idle (5 by default).
    .PARAMETER LowPercent
        CPU 95th percentile under this is under-used (30 by default).
    .PARAMETER HighPercent
        CPU or memory 95th percentile over this is hot (80 by default).
    .PARAMETER MaxResources
        At most this many resources are read (1 to 5000; 500 by default).
    .PARAMETER CsvPath
        Write the resources to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the resources.
    .PARAMETER NoDisplay
        Return the resources without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACResourceUtilization -IncludeCost
        Every resource's use against its cost, the idle and under-used first.
    .EXAMPLE
        Get-AACResourceUtilization -ResourceType 'microsoft.compute/virtualmachines' -Days 30 -HtmlPath .\out\RightSizing.html
        A month of VM use, with the sizes to move to, as a report.
    .EXAMPLE
        Get-AACResourceUtilization -NoDisplay | Where-Object Category -EQ 'Hot' | Where-Object Trend -EQ 'Growing'
        What's running hot and still growing.
    .OUTPUTS
        AAC.ResourceUtilization
    #>

    [CmdletBinding()]
    [OutputType('AAC.ResourceUtilization')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $ResourceType,

        [ValidateRange(1, 30)]
        [int] $Days = 14,

        [switch] $IncludeCost,

        [ValidateRange(1, 50)]
        [int] $IdlePercent = 5,

        [ValidateRange(2, 90)]
        [int] $LowPercent = 30,

        [ValidateRange(10, 100)]
        [int] $HighPercent = 80,

        [ValidateRange(1, 5000)]
        [int] $MaxResources = 500,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Resource utilization',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ })
        ResourceType = @($ResourceType | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() }); Days = $Days; IncludeCost = [bool]$IncludeCost; MaxResources = $MaxResources
    }
    $thresholds = @{ IdlePercent = $IdlePercent; LowPercent = $LowPercent; HighPercent = $HighPercent }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Resource utilization' -Color 'deepskyblue3_1' }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $definitions = Get-AACUtilizationMetric
        $types = @($definitions.Keys | Where-Object { $t = $_; -not $request.ResourceType.Count -or @($request.ResourceType | Where-Object { $t -like $_ }).Count } | Sort-Object)
        if (-not $types.Count) { throw "None of the types $($request.ResourceType -join ', ') is one this command measures: $(@($definitions.Keys | Sort-Object) -join ', ')." }
        $quote = { param([string] $Text) "'" + ($Text -replace "'", "\'") + "'" }
        $groupFilter = if ($request.ResourceGroupName.Count) { " | where resourceGroup in~ ($((@($request.ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' }
        Update-AACProgress -Id 'resources' -Indeterminate -Description 'Finding the resources to measure'
        $read = Invoke-AACGraphBatch -SubscriptionId $scope.GraphScope -Query ([ordered]@{
                resources = "resources | where type in~ ($((@($types | ForEach-Object { & $quote $_ })) -join ', '))$groupFilter | project id = tolower(id), name, type = tolower(type), resourceGroup, subscriptionId, location = tolower(location), size = tostring(properties.hardwareProfile.vmSize), os = tolower(tostring(properties.storageProfile.osDisk.osType)), sku = tostring(sku.name), power = tostring(properties.extended.instanceView.powerState.code)"
            })
        $all = @($read.Rows['resources'] | Where-Object { $_ })
        $notices = [System.Collections.Generic.List[string]]::new()
        $deallocated = @($all | Where-Object { [string]$_['power'] -eq 'PowerState/deallocated' })
        if ($deallocated.Count) { $notices.Add("$($deallocated.Count) deallocated VM(s) left out: they aren't billed for compute.") }
        $resources = @($all | Where-Object { [string]$_['power'] -ne 'PowerState/deallocated' } | Select-Object -First $request.MaxResources)
        if (@($all).Count - $deallocated.Count -gt $request.MaxResources) { $notices.Add("Only the first $($request.MaxResources) of $(@($all).Count - $deallocated.Count) resources were measured (-MaxResources).") }
        Update-AACProgress -Id 'resources' -Complete -Description ('Found {0:N0} resource(s) to measure' -f $resources.Count)

        # --- Their metrics, hour by hour ------------------------------------------------------------------------------
        $end = [datetime]::UtcNow; $start = $end.AddDays(-$request.Days)
        $span = "$($start.ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))"
        $uriFor = { param($Row, [string[]] $Names) "$($Row['id'])/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=$([System.Uri]::EscapeDataString(($Names -join ',')))&timespan=$span&interval=PT1H&aggregation=Average,Maximum,Minimum,Total" }
        $wanted = @{}
        foreach ($r in $resources) { $d = $definitions[[string]$r['type']]; $wanted[[string]$r['id']] = @(@($d.Cpu, $d.Memory, $d.Activity) | Where-Object { $_ }) }
        $uris = @{}
        foreach ($r in $resources) { if ($wanted[[string]$r['id']].Count) { $uris[[string]$r['id']] = & $uriFor $r $wanted[[string]$r['id']] } }
        Update-AACProgress -Id 'metrics' -Total ([Math]::Max(1, $uris.Count)) -Description "Reading $($request.Days) days of metrics for $($uris.Count) resource(s)"
        $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($MetricDone, $MetricTotal) Update-AACProgress -Id 'metrics' -Increment 1 }
        $metrics = @{}
        $retry = @{}
        foreach ($id in $uris.Keys) {
            $answer = $answers[$uris[$id]]
            if ($answer -and -not $answer.Error) { $metrics[$id] = @($answer.Body['value']) }
            elseif ($wanted[$id].Count -gt 1) { $row = @($resources | Where-Object { $_['id'] -eq $id }) | Select-Object -First 1; $retry[$id] = & $uriFor $row @($wanted[$id][0]) }
        }
        # A metric the resource doesn't have fails the whole request: those again, with the first metric only.
        if ($retry.Count) {
            $again = Invoke-AACArmParallel -Uri @($retry.Values)
            foreach ($id in $retry.Keys) { $answer = $again[$retry[$id]]; if ($answer -and -not $answer.Error) { $metrics[$id] = @($answer.Body['value']) } }
        }
        $unread = $uris.Count - $metrics.Count
        if ($unread) { $notices.Add("$unread resource(s)' metrics couldn't be read.") }
        Update-AACProgress -Id 'metrics' -Complete -Description ('Read the metrics of {0:N0} resource(s)' -f $metrics.Count)

        # --- VM sizes, per subscription and region -------------------------------------------------------------------
        $sizes = @{}
        $vms = @($resources | Where-Object { [string]$_['type'] -eq 'microsoft.compute/virtualmachines' })
        if ($vms.Count) {
            $sizeUris = @{}
            foreach ($v in $vms) { $sizeUris["$([string]$v['location'])"] = "/subscriptions/$($v['subscriptionId'])/providers/Microsoft.Compute/locations/$($v['location'])/vmSizes?api-version=2024-03-01" }
            $sizeAnswers = Invoke-AACArmParallel -Uri @($sizeUris.Values)
            foreach ($location in $sizeUris.Keys) {
                foreach ($s in @($sizeAnswers[$sizeUris[$location]].Items | Where-Object { $_ })) { $sizes["$location|$(([string]$s['name']).ToLowerInvariant())"] = @{ Cores = [int]$s['numberOfCores']; MemoryMB = [double]$s['memoryInMB'] } }
            }
        }

        # --- Cost ---------------------------------------------------------------------------------------------------------
        $costs = @{}
        if ($request.IncludeCost) {
            Update-AACProgress -Id 'cost' -Indeterminate -Description 'Reading each resource''s cost (Cost Management)'
            $costRead = Read-AACInventoryCost -TenantId ([string]$script:AACSession.TenantId) -Subscription @($scope.Subscriptions | ForEach-Object { @{ subscriptionId = [string]$_['subscriptionId']; name = [string]$_['name']; state = [string]$_['state'] } }) -ManagementGroupId $request.ManagementGroupId -PerSubscription:([bool]$request.SubscriptionId.Count)
            $months = @{}
            foreach ($row in @($costRead.Rows)) {
                $id = ([string](Get-AACPropertyValue -InputObject $row -Name 'ResourceId')).ToLowerInvariant()
                if (-not $id) { continue }
                $monthRaw = @('BillingMonth', 'UsageDate' | ForEach-Object { Get-AACPropertyValue -InputObject $row -Name $_ } | Where-Object { $_ }) | Select-Object -First 1
                $month = if ($monthRaw -is [datetime]) { $monthRaw.ToString('yyyy-MM') } else { ([string]$monthRaw).Substring(0, [Math]::Min(7, ([string]$monthRaw).Length)) -replace '^(\d{4})(\d{2}).*$', '$1-$2' }
                if (-not $months.Contains($id)) { $months[$id] = @{} }
                $months[$id][$month] = [double]$months[$id][$month] + [double](Get-AACPropertyValue -InputObject $row -Name 'Cost')
                $costs[$id] = @{ Cost = 0; Currency = [string](Get-AACPropertyValue -InputObject $row -Name 'Currency') }
            }
            $dayOfMonth = [datetime]::UtcNow.Day
            foreach ($id in $months.Keys) {
                $costs[$id].Cost = if ($months[$id].Contains($costRead.LastMonth)) { $months[$id][$costRead.LastMonth] } else { [double]$months[$id][$costRead.ThisMonth] / $dayOfMonth * 30 }
            }
            foreach ($line in @($costRead.Notice)) { if ($line) { $notices.Add($line) } }
            Update-AACProgress -Id 'cost' -Complete -Description ('Read the cost of {0:N0} resource(s)' -f $costs.Count)
        }

        # --- Rate them; price the VM sizes suggested; rate again with the prices --------------------------------------------
        $rate = { param([hashtable] $Prices) ConvertTo-AACResourceUtilization -Resource $resources -Metric $metrics -Size $sizes -Price $Prices -Cost $costs -SubscriptionName $scope.Names @thresholds }
        $result = & $rate @{}
        $priced = @{}
        $toPrice = @($result.Rows | Where-Object { $_.SuggestedSize } | ForEach-Object {
                $rated = $_
                $row = @($resources | Where-Object { $_['id'] -eq $rated.ResourceId }) | Select-Object -First 1
                $os = if ($row) { [string]$row['os'] } else { 'linux' }
                $location = if ($row) { [string]$row['location'] } else { '' }
                foreach ($size in $rated.Size, $rated.SuggestedSize) { "$location|$($size.ToLowerInvariant())|$os|$size" }
            } | Select-Object -Unique)
        if ($toPrice.Count) {
            Update-AACProgress -Id 'prices' -Total $toPrice.Count -Description 'Pricing the suggested VM sizes (Azure Retail Prices)'
            foreach ($key in $toPrice) {
                $location, $lowerSize, $os, $size = $key.Split('|')
                $price = Get-AACRetailPrice -Location $location -Size $size -Os $os
                if ($null -ne $price) { $priced["$location|$lowerSize|$os"] = $price }
                Update-AACProgress -Id 'prices' -Increment 1
            }
            Update-AACProgress -Id 'prices' -Complete -Description "Priced $($priced.Count) VM size(s)"
            $result = & $rate $priced
        }
        @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() }
    }

    $result = $state.Result
    $rows = @($result.Rows)
    $s = $result.Stats
    $money = $(if (@($s.Currencies).Count -eq 1) { @($s.Currencies)[0] } else { '' })
    $statusTones = @{ Idle = 'bad'; 'Under-used' = 'warn'; Hot = 'violet'; 'Right sized' = 'good'; 'No data' = 'neutral' }
    $report = @{
        Subtitle = "Resource utilization: the last $Days days"
        Facts    = [ordered]@{ Scope = $state.Scope.Label; Window = "$Days days, hourly"; Thresholds = "idle under $IdlePercent%, under-used under $LowPercent%, hot over $HighPercent% (95th percentile)" }
        Status   = $(if ($s.Idle -or $s.Hot) { 'Warning' } elseif ($s.Underused) { 'Warning' } else { 'Success' })
        Headline = "$($s.Resources) resource(s): $($s.Idle) idle, $($s.Underused) under-used, $($s.Hot) hot, $($s.Right) right sized$(if ($s.SavingText) { " - about $($s.SavingText) a month to save" })"
        Tiles    = @(
            @{ Value = '{0:N0}' -f $s.Idle; Label = 'idle'; Tone = $(if ($s.Idle) { 'bad' } else { 'good' }); Table = 'utilization'; Filters = @{ Category = 'Idle' } }
            @{ Value = '{0:N0}' -f $s.Underused; Label = 'under-used'; Tone = $(if ($s.Underused) { 'warn' } else { 'good' }); Table = 'utilization'; Filters = @{ Category = 'Under-used' } }
            @{ Value = '{0:N0}' -f $s.Hot; Label = 'running hot'; Tone = $(if ($s.Hot) { 'violet' } else { 'good' }); Table = 'utilization'; Filters = @{ Category = 'Hot' } }
            @{ Value = '{0:N0}' -f $s.Right; Label = 'right sized'; Tone = 'good'; Table = 'utilization'; Filters = @{ Category = 'Right sized' } }
            @{ Value = '{0:N0}' -f $s.Growing; Label = 'growing'; Tone = 'info'; Table = 'utilization'; Filters = @{ Trend = 'Growing' } }
            @{ Value = $(if ($s.SavingText) { $s.SavingText } else { '-' }); Label = 'a month to save'; Tone = 'good' }
            @{ Value = $(if ($s.UnusedCost) { '{0:N0}' -f $s.UnusedCost } else { '-' }); Label = "a month on unused capacity $money".TrimEnd(); Tone = $(if ($s.UnusedCost) { 'warn' } else { 'neutral' }) }
        )
        Notices  = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'Resources by use'; Kind = 'donut'; CenterLabel = 'resources'; Items = @(foreach ($c in 'Idle', 'Under-used', 'Hot', 'Right sized', 'No data') { $n = @($rows | Where-Object Category -EQ $c).Count; if ($n) { @{ Label = $c; Value = $n; Tone = $statusTones[$c]; Filter = $c } } }); Table = 'utilization'; Column = 'Category'; Console = $true }
            @{ Title = 'Largest savings'; Items = @($rows | Where-Object { $_.EstimatedSaving -gt 0 } | Sort-Object EstimatedSaving -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Resource; Value = $_.EstimatedSaving; Filter = $_.Resource } }); Table = 'utilization'; Column = 'Resource'; Tone = 'good' }
            @{ Title = 'CPU, 95th percentile (%)'; Items = @($rows | Where-Object { $null -ne $_.CpuP95 } | Sort-Object CpuP95 -Descending | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Resource; Value = $_.CpuP95; Filter = $_.Resource } }); Table = 'utilization'; Column = 'Resource'; Tone = 'violet' }
        )
        Tables   = @(
            @{ Id = 'utilization'; Title = 'Resources'; Section = 'Utilization'; Rows = $rows; Noun = 'resources'; GroupBy = @('Category', 'Kind', 'Trend', 'ResourceGroup', 'Subscription'); ConsoleLimit = 30
                Empty = 'No resources of the types measured in scope.'; EmptyStatus = 'Info'
                Columns = @(
                    @{ Key = 'Category'; Label = 'Use'; Type = 'badge'; Tones = $statusTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Kind'; Label = 'Kind'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Size'; Label = 'Size'; Type = 'mono'; Console = $true; Pdf = $true }
                    @{ Key = 'CpuP95'; Label = 'CPU p95 (%)'; Type = 'number'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'MemoryP95'; Label = 'Memory p95 (%)'; Type = 'number'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'Trend'; Label = 'Trend'; Type = 'badge'; Tones = @{ Growing = 'info'; Shrinking = 'neutral'; Stable = 'good' }; Facet = $true; Console = $true }
                    @{ Key = 'SuggestedSize'; Label = 'Resize to'; Type = 'mono'; Console = $true; Pdf = $true }
                    @{ Key = 'EstimatedSaving'; Label = 'Saving a month'; Type = 'money'; CurrencyKey = 'Currency'; Console = $true; Pdf = $true }
                    @{ Key = 'MonthlyCost'; Label = 'Cost a month'; Type = 'money'; CurrencyKey = 'Currency' }
                    @{ Key = 'UnusedCost'; Label = 'Unused (chargeback)'; Type = 'money'; CurrencyKey = 'Currency' }
                    @{ Key = 'CpuAverage'; Label = 'CPU average (%)'; Type = 'number'; Format = 'N1' }
                    @{ Key = 'CpuMax'; Label = 'CPU max (%)'; Type = 'number'; Format = 'N0' }
                    @{ Key = 'GrowthPercent'; Label = 'Growth (%)'; Type = 'number'; Format = 'N0' }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide' }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                    @{ Key = 'Currency'; Label = 'Currency'; Hidden = $true }
                ) }
        )
        Hint     = '-IncludeCost adds the cost and the chargeback; -Days sets the window; -NoDisplay returns the resources.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun 'resource' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $rows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}