Private/Get-VMMetricSeries.ps1

function Get-VMMetricSeries {
    <#
    .SYNOPSIS
        Pulls a raw Azure Monitor platform-metric time series for one VM, paging the
        window into <=30-day chunks (the per-query limit) and honouring 429/Retry-After.

    .DESCRIPTION
        This is the only function that calls Get-AzMetric. It returns a flat list of
        datapoints { TimeStamp, Average, Minimum, Maximum } across the whole window so
        callers (Measure-Percentile) can aggregate without knowing about paging.

        Platform metrics are retained ~93 days at 1-minute granularity; a single query
        may span at most 30 days. We therefore walk the window in <=30-day slices.

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

    .PARAMETER MetricName
        Platform metric name, e.g. 'Percentage CPU' or '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 MaxRetry
        Max retries on throttling (HTTP 429), default 5.

    .OUTPUTS
        [pscustomobject[]] with TimeStamp (datetime), Average, Minimum, Maximum (double?).
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Generic.List[pscustomobject]])]
    param(
        [Parameter(Mandatory)] [string]   $ResourceId,
        [Parameter(Mandatory)] [string]   $MetricName,
        [Parameter(Mandatory)] [datetime] $StartTime,
        [Parameter(Mandatory)] [datetime] $EndTime,
        [timespan] $TimeGrain = ([timespan]'00:05:00'),
        [int]      $MaxRetry  = 5
    )

    $points = [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 {
                $metric = Get-AzMetric -ResourceId $ResourceId -MetricName $MetricName `
                    -TimeGrain $TimeGrain -StartTime $cursor -EndTime $sliceEnd `
                    -AggregationType Average -WarningAction SilentlyContinue -ErrorAction Stop

                # Get-AzMetric returns one Metric object; Average requested but Min/Max
                # come free on the same datapoint objects when present.
                foreach ($m in $metric) {
                    foreach ($dp in $m.Data) {
                        # Skip empty buckets (no emission in that interval).
                        if ($null -eq $dp.Average -and $null -eq $dp.Minimum -and $null -eq $dp.Maximum) {
                            continue
                        }
                        $points.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) on '$MetricName' for $ResourceId; retry $attempt/$MaxRetry after ${retryAfter}s."
                    Start-Sleep -Seconds $retryAfter
                    continue
                }
                throw
            }
        }

        $cursor = $sliceEnd
    }

    return $points
}