Private/Get-VMMetricSeries.ps1

function Get-VMMetricSeries {
    <#
    .SYNOPSIS
        Pulls one or more Azure Monitor platform-metric time series for a VM in a single
        request per 30-day chunk, returning a map of metricName -> datapoints.

    .DESCRIPTION
        The only function that calls Get-AzMetric. It requests ALL the named metrics in one
        call (Get-AzMetric accepts an array), which halves the number of round-trips versus
        one call per metric. The window is paged in <=30-day slices because the metrics API
        silently caps a single response to ~31 days regardless of grain -- without chunking
        you lose everything older than ~31 days.

        Pass -AzContext to bind the call to an explicit Azure context (concurrency-safe under
        ForEach-Object -Parallel, where there is no shared default context).

    .PARAMETER ResourceId
        The full ARM resource id of the VM.

    .PARAMETER MetricName
        One or more platform metric names, e.g. 'Percentage CPU','Available Memory Bytes'.

    .PARAMETER StartTime
        UTC start of the window (inclusive).

    .PARAMETER EndTime
        UTC end of the window (exclusive).

    .PARAMETER TimeGrain
        Aggregation grain as a TimeSpan, default 00:05:00 (PT5M).

    .PARAMETER AzContext
        Optional Azure context object (from Get-AzContext) passed to Get-AzMetric as
        -DefaultProfile. Required when running in a parallel runspace.

    .PARAMETER MaxRetry
        Max retries on throttling (HTTP 429), default 5.

    .OUTPUTS
        [hashtable] metricName -> System.Collections.Generic.List[pscustomobject] of
        { TimeStamp, Average, Minimum, Maximum }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)] [string]   $ResourceId,
        [Parameter(Mandatory)] [string[]] $MetricName,
        [Parameter(Mandatory)] [datetime] $StartTime,
        [Parameter(Mandatory)] [datetime] $EndTime,
        [timespan] $TimeGrain = ([timespan]'00:05:00'),
        [object]   $AzContext,
        [int]      $MaxRetry  = 5
    )

    $result = @{}
    foreach ($n in $MetricName) { $result[$n] = [System.Collections.Generic.List[pscustomobject]]::new() }

    $chunk  = [timespan]::FromDays(30)
    $cursor = $StartTime

    while ($cursor -lt $EndTime) {
        $sliceEnd = $cursor + $chunk
        if ($sliceEnd -gt $EndTime) { $sliceEnd = $EndTime }

        $attempt = 0
        while ($true) {
            try {
                $callArgs = @{
                    ResourceId      = $ResourceId
                    MetricName      = $MetricName
                    TimeGrain       = $TimeGrain
                    StartTime       = $cursor
                    EndTime         = $sliceEnd
                    AggregationType = 'Average'
                    WarningAction   = 'SilentlyContinue'
                    ErrorAction     = 'Stop'
                }
                if ($AzContext) { $callArgs['DefaultProfile'] = $AzContext }
                $metric = Get-AzMetric @callArgs

                foreach ($mObj in $metric) {
                    $name = $mObj.Name.Value
                    if (-not $result.ContainsKey($name)) {
                        $result[$name] = [System.Collections.Generic.List[pscustomobject]]::new()
                    }
                    foreach ($dp in $mObj.Data) {
                        if ($null -eq $dp.Average -and $null -eq $dp.Minimum -and $null -eq $dp.Maximum) {
                            continue
                        }
                        $result[$name].Add([pscustomobject]@{
                            TimeStamp = [datetime] $dp.TimeStamp
                            Average   = $dp.Average
                            Minimum   = $dp.Minimum
                            Maximum   = $dp.Maximum
                        })
                    }
                }
                break
            }
            catch {
                $status = $null
                if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
                if ($status -eq 429 -and $attempt -lt $MaxRetry) {
                    $attempt++
                    $retryAfter = 5 * $attempt
                    $hdr = $_.Exception.Response.Headers
                    if ($hdr -and $hdr['Retry-After']) {
                        [int]::TryParse([string]$hdr['Retry-After'], [ref]$retryAfter) | Out-Null
                    }
                    Write-Warning "Throttled (429) for $ResourceId; retry $attempt/$MaxRetry after ${retryAfter}s."
                    Start-Sleep -Seconds $retryAfter
                    continue
                }
                throw
            }
        }

        $cursor = $sliceEnd
    }

    return $result
}