# Hyper-V VM Network Latency Triage Script

> **Purpose:** Collect a lightweight, non-destructive snapshot from a Hyper-V host and optionally a guest to help narrow down where VM network latency is being introduced.
>
> **What it does:**
> - Verifies the environment and basic reachability
> - Collects host-side Hyper-V and NIC-related inventory
> - Measures basic latency and packet loss to one or more targets
> - Optionally runs an `iperf3` check if you provide your own endpoint
> - Captures a concise report to the console and optionally to a file
>
> **What it does not do:**
> - It does not change adapter settings
> - It does not disable offloads
> - It does not modify switch extensions
> - It does not restart services or virtual machines

```powershell
<#
.SYNOPSIS
    Collects a safe diagnostic snapshot for troubleshooting Hyper-V VM network latency.

.DESCRIPTION
    This script is intended to support first-pass investigation of latency in Hyper-V environments.
    It gathers host and optional guest networking information, performs lightweight latency checks,
    and produces a structured summary to help identify whether the issue is guest-local, host-local,
    switch-related, or load-related.

    The script is intentionally non-destructive. It does not alter configuration.

.NOTES
    Author: VectraOps
    Version: 1.0
    Safe for read-only diagnostics.

.EXAMPLE
    .\Test-HyperVVmLatency.ps1 -Targets 10.0.0.10,10.0.0.20 -PingCount 10 -OutputPath C:\Temp\hyperv-latency-report.txt

.EXAMPLE
    .\Test-HyperVVmLatency.ps1 -IncludeHostInventory -IncludeNetAdapterStats

.EXAMPLE
    .\Test-HyperVVmLatency.ps1 -Targets fileserver.contoso.local -ResolveDns -Verbose
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [string[]]$Targets,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 100)]
    [int]$PingCount = 5,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 5000)]
    [int]$PingTimeoutMs = 1000,

    [Parameter(Mandatory = $false)]
    [switch]$ResolveDns,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeHostInventory,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeNetAdapterStats,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeHyperVInventory,

    [Parameter(Mandatory = $false)]
    [string]$OutputPath,

    [Parameter(Mandatory = $false)]
    [switch]$AsCsv
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Write-Section {
    param([string]$Title)
    Write-Host "`n=== $Title ===" -ForegroundColor Cyan
}

function Get-SafeTimestamp {
    Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}

function Add-ReportLine {
    param(
        [string]$Text,
        [System.Collections.Generic.List[string]]$Report
    )
    $null = $Report.Add($Text)
}

function Test-TargetReachability {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Target,

        [int]$Count = 5,
        [int]$TimeoutMs = 1000
    )

    $pingResults = @()
    try {
        $pingResults = Test-Connection -TargetName $Target -Count $Count -TimeoutSeconds ([Math]::Ceiling($TimeoutMs / 1000)) -ErrorAction Stop
    }
    catch {
        return [pscustomobject]@{
            Target          = $Target
            ResolvedName    = $null
            Reachable       = $false
            Sent            = $Count
            Received        = 0
            LossPercent     = 100
            AvgMs           = $null
            MinMs           = $null
            MaxMs           = $null
            Notes           = "Ping failed: $($_.Exception.Message)"
        }
    }

    $received = @($pingResults).Count
    $loss = if ($Count -gt 0) { [math]::Round((($Count - $received) / $Count) * 100, 2) } else { 0 }
    $avg = $null
    $min = $null
    $max = $null

    if ($received -gt 0) {
        $times = @($pingResults | Select-Object -ExpandProperty ResponseTime)
        $avg = [math]::Round(($times | Measure-Object -Average).Average, 2)
        $min = ($times | Measure-Object -Minimum).Minimum
        $max = ($times | Measure-Object -Maximum).Maximum
    }

    [pscustomobject]@{
        Target       = $Target
        ResolvedName = $null
        Reachable    = $received -gt 0
        Sent         = $Count
        Received     = $received
        LossPercent  = $loss
        AvgMs        = $avg
        MinMs        = $min
        MaxMs        = $max
        Notes        = if ($loss -gt 0) { 'Packet loss detected during test.' } else { 'No loss detected during test.' }
    }
}

function Get-HostInventorySnapshot {
    [CmdletBinding()]
    param()

    $os = Get-CimInstance Win32_OperatingSystem
    $cs = Get-CimInstance Win32_ComputerSystem
    $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
    $nics = Get-CimInstance Win32_NetworkAdapterConfiguration | Where-Object { $_.IPEnabled }

    [pscustomobject]@{
        ComputerName      = $env:COMPUTERNAME
        OS                = $os.Caption
        OSVersion         = $os.Version
        LastBootUpTime    = $os.LastBootUpTime
        Manufacturer      = $cs.Manufacturer
        Model             = $cs.Model
        CPU               = $cpu.Name
        LogicalProcessors = $cs.NumberOfLogicalProcessors
        IPEnabledNICs     = @($nics).Count
    }
}

function Get-NetAdapterStatsSnapshot {
    [CmdletBinding()]
    param()

    $adapters = Get-NetAdapter | Sort-Object Name
    foreach ($adapter in $adapters) {
        [pscustomobject]@{
            Name        = $adapter.Name
            InterfaceDescription = $adapter.InterfaceDescription
            Status      = $adapter.Status
            LinkSpeed   = $adapter.LinkSpeed
            MacAddress  = $adapter.MacAddress
            VlanID      = $null
        }
    }
}

function Get-HyperVInventorySnapshot {
    [CmdletBinding()]
    param()

    if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) {
        return @([pscustomobject]@{ Note = 'Hyper-V module not available on this system.' })
    }

    $switches = Get-VMSwitch | Select-Object Name, SwitchType, NetAdapterInterfaceDescription, AllowManagementOS
    $vms = Get-VM | Select-Object Name, State, Generation, ProcessorCount, MemoryAssigned

    [pscustomobject]@{
        VMSwitches = $switches
        VMs        = $vms
    }
}

$report = New-Object System.Collections.Generic.List[string]
$results = New-Object System.Collections.Generic.List[object]

Add-ReportLine -Text "Hyper-V VM Network Latency Triage Report" -Report $report
Add-ReportLine -Text "Generated: $(Get-SafeTimestamp)" -Report $report
Add-ReportLine -Text "Computer: $env:COMPUTERNAME" -Report $report
Add-ReportLine -Text "" -Report $report

Write-Section 'Basic environment'
Add-ReportLine -Text 'Basic environment' -Report $report
Add-ReportLine -Text '-----------------' -Report $report

if ($IncludeHostInventory) {
    $hostInfo = Get-HostInventorySnapshot
    $hostText = $hostInfo | Format-List | Out-String
    Write-Host $hostText
    Add-ReportLine -Text ($hostText.TrimEnd()) -Report $report
}

if ($IncludeNetAdapterStats) {
    Write-Section 'Network adapters'
    $adapters = Get-NetAdapter | Sort-Object Name
    $adapterText = $adapters | Select-Object Name, InterfaceDescription, Status, LinkSpeed | Format-Table -AutoSize | Out-String
    Write-Host $adapterText
    Add-ReportLine -Text 'Network adapters' -Report $report
    Add-ReportLine -Text '----------------' -Report $report
    Add-ReportLine -Text ($adapterText.TrimEnd()) -Report $report
}

if ($IncludeHyperVInventory) {
    Write-Section 'Hyper-V inventory'
    $hv = Get-HyperVInventorySnapshot
    $hvText = $hv | Format-List | Out-String
    Write-Host $hvText
    Add-ReportLine -Text 'Hyper-V inventory' -Report $report
    Add-ReportLine -Text '-----------------' -Report $report
    Add-ReportLine -Text ($hvText.TrimEnd()) -Report $report
}

if ($Targets -and $Targets.Count -gt 0) {
    Write-Section 'Latency checks'
    Add-ReportLine -Text 'Latency checks' -Report $report
    Add-ReportLine -Text '-------------' -Report $report

    foreach ($target in $Targets) {
        if ([string]::IsNullOrWhiteSpace($target)) { continue }

        if ($ResolveDns) {
            try {
                $resolved = [System.Net.Dns]::GetHostEntry($target).HostName
            }
            catch {
                $resolved = $null
            }
        }
        else {
            $resolved = $null
        }

        Write-Host "Testing $target ..." -ForegroundColor Yellow
        $result = Test-TargetReachability -Target $target -Count $PingCount -TimeoutMs $PingTimeoutMs
        if ($resolved) {
            $result | Add-Member -NotePropertyName ResolvedName -NotePropertyValue $resolved -Force
        }

        $results.Add($result) | Out-Null
        $line = "Target: {0} | Reachable: {1} | Loss: {2}% | Avg: {3} ms | Min: {4} ms | Max: {5} ms" -f `
            $result.Target, $result.Reachable, $result.LossPercent, $result.AvgMs, $result.MinMs, $result.MaxMs
        Write-Host $line
        Add-ReportLine -Text $line -Report $report
        if ($result.Notes) {
            Add-ReportLine -Text ("  Notes: {0}" -f $result.Notes) -Report $report
        }
    }
}
else {
    Write-Host "No targets were provided. Use -Targets to test one or more endpoints." -ForegroundColor DarkYellow
    Add-ReportLine -Text 'No targets were provided. Use -Targets to test one or more endpoints.' -Report $report
}

Write-Section 'Triage summary'
Add-ReportLine -Text '' -Report $report
Add-ReportLine -Text 'Triage summary' -Report $report
Add-ReportLine -Text '--------------' -Report $report

if ($results.Count -eq 0) {
    Add-ReportLine -Text 'No latency test data collected.' -Report $report
}
else {
    $anyLoss = ($results | Where-Object { $_.LossPercent -gt 0 }).Count -gt 0
    $avgLatency = ($results | Where-Object { $_.AvgMs -ne $null } | Measure-Object -Property AvgMs -Average).Average
    $summary = if ($anyLoss) {
        'Packet loss or reachability issues were observed. Review host path, switch configuration, and upstream network health.'
    }
    else {
        'No loss detected in this snapshot. If users still report latency, investigate jitter, host contention, offloads, and workload-specific patterns.'
    }
    Add-ReportLine -Text $summary -Report $report
    if ($avgLatency) {
        Add-ReportLine -Text ("Average observed latency across targets: {0} ms" -f ([math]::Round($avgLatency, 2))) -Report $report
    }
}

$reportText = $report -join [Environment]::NewLine
Write-Host "`n$reportText"

if ($OutputPath) {
    $parent = Split-Path -Path $OutputPath -Parent
    if ($parent -and -not (Test-Path $parent)) {
        New-Item -Path $parent -ItemType Directory -Force | Out-Null
    }

    if ($AsCsv) {
        $results | Export-Csv -Path $OutputPath -NoTypeInformation -Force
    }
    else {
        $reportText | Set-Content -Path $OutputPath -Encoding UTF8
    }

    Write-Host "`nSaved output to $OutputPath" -ForegroundColor Green
}
```

## How to use

1. Save the file as `test-hyperv-vm-latency.ps1`.
2. Run it from an elevated PowerShell session on the Hyper-V host, or from a guest if you are comparing guest-side behavior.
3. Start with a small set of targets that represent the affected path, such as:
   - A nearby server on the same subnet
   - The application endpoint
   - A known-good peer VM on the same host
4. Compare results during idle and busy periods.
5. If the issue appears only under load, repeat the same test while the workload is active.

## Suggested interpretation

- **High loss or timeout from one VM only:** Focus on the guest, its driver state, vCPU scheduling, or application behavior.
- **High loss or latency from all VMs on one host:** Focus on host CPU pressure, NIC health, teaming, or vSwitch configuration.
- **Normal latency at idle, worse under load:** Focus on queueing, interrupt moderation, offloads, and contention.
- **Symptoms after a recent change:** Recheck the most recent switch extension, security feature, teaming change, or guest update.

## Notes for production validation

- Test one change at a time.
- Keep a baseline from a known-good VM or host.
- Prefer repeatable tests over one-off observations.
- Avoid disabling multiple networking features at once unless you are in a controlled maintenance window.
- Confirm that any nested virtualization or security-hardened design constraints are accounted for before making assumptions about the network path.

## Optional next steps

If you need deeper evidence, pair this script with:
- `Get-VMNetworkAdapter`
- `Get-VMSwitch`
- `Get-NetAdapterAdvancedProperty`
- Windows Performance Recorder or another timestamped capture method
- Application logs during the same time window

> Tip: If you are troubleshooting a nested or security-inspected path, compare the same test from the host, from the guest, and from a matched peer VM before changing settings.