Public/Invoke-VMMetricsCollection.ps1

function Invoke-VMMetricsCollection {
    <#
    .SYNOPSIS
        Discovers VMs in a subscription, pulls their platform metrics, and exports
        VMPerformance-compatible rows for FinOps rightsizing.

    .DESCRIPTION
        End-to-end orchestrator:
          1. Sets the subscription context (if -SubscriptionId given).
          2. Discovers VMs via Get-AzVM (optionally filtered by resource group / name).
          3. For each VM pulls 'Percentage CPU' + 'Available Memory Bytes' and aggregates
             to monthly Min/Median/P95/P99/Max rows.
          4. Writes one CSV (+ manifest) and returns a run report.

        Platform metrics retain only ~93 days; -WindowDays above that is capped with a
        warning. For longer history, run this on a schedule so rows accumulate, or stand
        up a diagnostic-setting export to Log Analytics (see README).

    .PARAMETER SubscriptionId
        Subscription to operate in. If omitted, the current Az context is used.

    .PARAMETER ResourceGroupName
        Optional resource-group filter.

    .PARAMETER VMName
        Optional VM-name filter (one or more exact names).

    .PARAMETER WindowDays
        Look-back window in days, default 90 (capped at 93 -- platform retention).

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

    .PARAMETER OutputPath
        Directory to write the CSV + manifest into. Default: current directory.

    .PARAMETER Format
        'CSV' (default) or 'Parquet'.

    .PARAMETER StorageAccountName
        Optional. When set, the CSV (+ manifest) is uploaded to this storage account under
        {Customer}/{date}/ after the local write. Requires -Customer.

    .PARAMETER ContainerName
        Blob container for the upload (default 'vmperformance'); created if missing.

    .PARAMETER Customer
        Real customer name; first blob path segment. Required when -StorageAccountName is set.

    .PARAMETER StorageAccountKey
        Account key for the upload. If omitted, -StorageAccountResourceGroup is used to fetch it.

    .PARAMETER StorageAccountResourceGroup
        Resource group of the storage account, used to fetch the key when none is supplied.

    .OUTPUTS
        [pscustomobject] run report: OutputFile, BlobUri, VMsProcessed, VMsSkipped, RowCount,
        Skipped (per-VM reasons), Window.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [string]   $SubscriptionId,
        [string]   $ResourceGroupName,
        [string[]] $VMName,
        [ValidateRange(1, 93)] [int] $WindowDays = 90,
        [timespan] $TimeGrain = ([timespan]'00:05:00'),
        [string]   $OutputPath = (Get-Location).Path,
        [ValidateSet('CSV', 'Parquet')] [string] $Format = 'CSV',

        # --- Optional blob upload (account-key auth) ---
        [string]   $StorageAccountName,
        [string]   $ContainerName = 'vmperformance',
        [string]   $Customer,
        [string]   $StorageAccountKey,
        [string]   $StorageAccountResourceGroup
    )

    if ($StorageAccountName -and -not $Customer) {
        throw "-Customer is required when -StorageAccountName is set (it is the first blob path segment)."
    }

    if ($SubscriptionId) {
        Write-Verbose "Setting subscription context to $SubscriptionId"
        Set-AzContext -Subscription $SubscriptionId -ErrorAction Stop | Out-Null
    }
    $ctx = Get-AzContext -ErrorAction Stop
    if (-not $ctx) { throw "No Azure context. Run Connect-AzAccount first." }

    $endTime   = (Get-Date).ToUniversalTime()
    $startTime = $endTime.AddDays(-$WindowDays)

    # Discover VMs.
    $vmParams = @{ Status = $false }
    if ($ResourceGroupName) { $vmParams['ResourceGroupName'] = $ResourceGroupName }
    $vms = Get-AzVM @vmParams -ErrorAction Stop
    if ($VMName) { $vms = $vms | Where-Object { $_.Name -in $VMName } }

    if (-not $vms) {
        Write-Warning "No VMs found for the given filters."
        return [pscustomobject]@{
            OutputFile = $null; BlobUri = $null; VMsProcessed = 0; VMsSkipped = 0; RowCount = 0
            Skipped = @(); Window = @{ Start = $startTime; End = $endTime; Days = $WindowDays }
        }
    }

    Write-Verbose "Discovered $($vms.Count) VM(s)."

    $allRows = [System.Collections.Generic.List[object]]::new()
    $skipped = [System.Collections.Generic.List[object]]::new()
    $processed = 0

    foreach ($vm in $vms) {
        try {
            $rows = Get-AzVMUtilization -VM $vm -StartTime $startTime -EndTime $endTime -TimeGrain $TimeGrain
            if ($rows -and $rows.Count -gt 0) {
                foreach ($r in $rows) { $allRows.Add($r) }
                $processed++
            }
            else {
                $skipped.Add([pscustomobject]@{ VM = $vm.Name; Reason = 'No metric data in window' })
            }
        }
        catch {
            Write-Warning "Failed for $($vm.Name): $($_.Exception.Message)"
            $skipped.Add([pscustomobject]@{ VM = $vm.Name; Reason = $_.Exception.Message })
        }
    }

    $stamp    = $endTime.ToString('yyyy-MM-dd')
    $fileName = "VMPerformance-$stamp.$($Format.ToLowerInvariant())"
    $dataPath = Join-Path $OutputPath $fileName

    $manifest = @{
        subscriptionId = $ctx.Subscription.Id
        windowStartUtc = $startTime.ToString('o')
        windowEndUtc   = $endTime.ToString('o')
        windowDays     = $WindowDays
        timeGrain      = $TimeGrain.ToString()
        vmsProcessed   = $processed
        vmsSkipped     = $skipped.Count
    }
    if ($Customer) { $manifest['customer'] = $Customer }

    $written = $null
    if ($PSCmdlet.ShouldProcess($dataPath, "Export $($allRows.Count) rows")) {
        $written = Export-VMPerformanceData -Row $allRows.ToArray() -Path $dataPath -Format $Format -Manifest $manifest
    }

    # Optional upload to blob storage.
    $blobUri = $null
    if ($StorageAccountName -and $written) {
        $uploaded = Publish-VMPerformanceData -Path $written -Customer $Customer `
            -StorageAccountName $StorageAccountName -ContainerName $ContainerName `
            -StorageAccountKey $StorageAccountKey -StorageAccountResourceGroup $StorageAccountResourceGroup `
            -Date $endTime.ToString('yyyy-MM-dd')
        $blobUri = $uploaded.BlobUri
    }

    [pscustomobject]@{
        OutputFile   = $written
        BlobUri      = $blobUri
        VMsProcessed = $processed
        VMsSkipped   = $skipped.Count
        RowCount     = $allRows.Count
        Skipped      = $skipped.ToArray()
        Window       = @{ Start = $startTime; End = $endTime; Days = $WindowDays }
    }
}