Public/Get-sqmServerUtilization.ps1

<#
.SYNOPSIS
    Collects SQL Server CPU and memory utilization trends over time.

.DESCRIPTION
    Captures multiple snapshots of SQL Server memory and CPU metrics from DMVs,
    calculates Min/Max/Avg trends, and generates reports (TXT, CSV, HTML).

.PARAMETER SqlInstance
    SQL Server instance. Default: local computer name.

.PARAMETER SqlCredential
    PSCredential for the connection.

.PARAMETER SampleCount
    Number of snapshots to collect. Default: 6.

.PARAMETER SampleIntervalSeconds
    Interval between snapshots in seconds. Default: 10.
    (Total sampling time = SampleCount * SampleIntervalSeconds)

.PARAMETER OutputPath
    Directory for report output. Default: from module config.

.PARAMETER NoOpen
    Suppress automatic report opening.

.PARAMETER EnableException
    Throw exceptions immediately.

.EXAMPLE
    Get-sqmServerUtilization -SqlInstance "SQL01"
    # Collects 6 snapshots (60 seconds) and generates HTML report

.EXAMPLE
    Get-sqmServerUtilization -SqlInstance "SQL01" -SampleCount 12 -SampleIntervalSeconds 5
    # Collects 12 snapshots (60 seconds total)

.NOTES
    Requires: dbatools, Invoke-sqmLogging, Get-sqmDefaultOutputPath
    Needs VIEW SERVER STATE and CONTROL SERVER permissions.
    Report includes: Memory, CPU, Worker Threads, Compilations.
#>

function Get-sqmServerUtilization
{
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $false)]
        [string]$SqlInstance,
        [Parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$SqlCredential,
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 100)]
        [int]$SampleCount = 6,
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 60)]
        [int]$SampleIntervalSeconds = 10,
        [Parameter(Mandatory = $false)]
        [string]$OutputPath,
        [Parameter(Mandatory = $false)]
        [switch]$EnableException,
        [Parameter(Mandatory = $false)]
        [switch]$NoOpen
    )

    begin
    {
        $functionName = $MyInvocation.MyCommand.Name

        if (-not $PSBoundParameters.ContainsKey('SqlInstance') -or [string]::IsNullOrWhiteSpace($SqlInstance))
        {
            $SqlInstance = $env:COMPUTERNAME
        }

        if (-not $script:dbatoolsAvailable)
        {
            $errMsg = "dbatools module is not available. Install it first."
            Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR"
            throw $errMsg
        }

        if (-not $OutputPath)
        {
            $OutputPath = Get-sqmDefaultOutputPath
        }

        Invoke-sqmLogging -Message "Starting $functionName for $SqlInstance (Samples: $SampleCount, Interval: $SampleIntervalSeconds sec)" `
            -FunctionName $functionName -Level "INFO"
    }

    process
    {
        try
        {
            $connParams = @{ SqlInstance = $SqlInstance }
            if ($SqlCredential) { $connParams['SqlCredential'] = $SqlCredential }

            # ===================================================================
            # SAMPLING LOOP
            # ===================================================================
            $samples = @()
            $startTime = Get-Date

            for ($i = 0; $i -lt $SampleCount; $i++)
            {
                $sampleTime = Get-Date

                # Query 1: Memory snapshot (single result set via CROSS JOIN)
                $memSql = @"
SELECT
    pm.SQLPhysicalMemoryBytes,
    sm.ServerTotalMemoryBytes,
    sm.AvailableMemoryBytes
FROM
    (SELECT CAST(physical_memory_in_use_kb AS BIGINT) * 1024 AS SQLPhysicalMemoryBytes
     FROM sys.dm_os_process_memory) AS pm
CROSS JOIN
    (SELECT CAST(total_physical_memory_kb AS BIGINT) * 1024 AS ServerTotalMemoryBytes,
            CAST(available_physical_memory_kb AS BIGINT) * 1024 AS AvailableMemoryBytes
     FROM sys.dm_os_sys_memory) AS sm;
"@


                $memResult = Invoke-DbaQuery @connParams -Database master -Query $memSql -ErrorAction Stop

                # Extract values (single row)
                $sqlMemoryBytes = [int64]($memResult.SQLPhysicalMemoryBytes)
                $serverTotalMemory = [int64]($memResult.ServerTotalMemoryBytes)
                $availableMemory = [int64]($memResult.AvailableMemoryBytes)

                # Query 2: Worker threads (T-SQL: CASE, not PostgreSQL FILTER)
                $threadSql = @"
SELECT
    ISNULL(SUM(CASE WHEN state = 'RUNNABLE' THEN 1 ELSE 0 END), 0) AS RunnableThreads,
    ISNULL(SUM(CASE WHEN state IN ('SUSPENDED', 'RUNNING') THEN 1 ELSE 0 END), 0) AS ActiveThreads
FROM sys.dm_os_workers;
"@


                $threadResult = Invoke-DbaQuery @connParams -Database master -Query $threadSql -ErrorAction Stop
                $threadRow = @($threadResult)[0]
                $runnableThreads = [int]($threadRow.RunnableThreads)
                $activeThreads = [int]($threadRow.ActiveThreads)

                # Query 3: CPU utilization from ring buffer (record column is XML, not JSON)
                $cpuSql = @"
SELECT TOP 1
    x.record.value('(./Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]', 'int') AS CPUUtilizationPercent
FROM
    (SELECT CONVERT(XML, record) AS record, [timestamp]
     FROM sys.dm_os_ring_buffers
     WHERE ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR') AS x
ORDER BY x.[timestamp] DESC;
"@


                $cpuResult = Invoke-DbaQuery @connParams -Database master -Query $cpuSql -ErrorAction Stop
                $cpuRaw = @($cpuResult)[0].CPUUtilizationPercent
                $cpuUtilPercent = if ($null -eq $cpuRaw -or $cpuRaw -is [System.DBNull]) { 0.0 } else { [double]$cpuRaw }

                # Query 4: Compilations
                $compSql = @"
SELECT
    COUNT(*) AS CachedPlans,
    ISNULL(SUM(CAST(plan_generation_num AS BIGINT)), 0) AS TotalCompilations,
    ISNULL(SUM(CAST(execution_count AS BIGINT)), 0) AS TotalExecutions
FROM sys.dm_exec_query_stats;
"@


                $compResult = Invoke-DbaQuery @connParams -Database master -Query $compSql -ErrorAction Stop
                $compRow = @($compResult)[0]
                $cachedPlans = [int64]($compRow.CachedPlans)
                $totalCompilations = [int64]($compRow.TotalCompilations)

                # Build sample object
                $sample = [PSCustomObject]@{
                    Timestamp             = $sampleTime
                    CPUUtilizationPercent = $cpuUtilPercent
                    SQLMemoryMB           = [math]::Round($sqlMemoryBytes / 1MB, 2)
                    AvailableMemoryMB     = [math]::Round($availableMemory / 1MB, 2)
                    ServerTotalMemoryMB   = [math]::Round($serverTotalMemory / 1MB, 2)
                    RunnableThreads       = $runnableThreads
                    ActiveThreads         = $activeThreads
                    CachedPlans           = $cachedPlans
                    TotalCompilations     = $totalCompilations
                }

                $samples += $sample

                # Wait before next sample (except on last iteration)
                if ($i -lt ($SampleCount - 1))
                {
                    Start-Sleep -Seconds $SampleIntervalSeconds
                }
            }

            $endTime = Get-Date

            # ===================================================================
            # AGGREGATION (Min, Max, Avg)
            # ===================================================================
            $aggregated = [PSCustomObject]@{
                StartTime                 = $startTime
                EndTime                   = $endTime
                SampleCount               = $SampleCount
                CPUUtilization_Current    = $samples[-1].CPUUtilizationPercent
                CPUUtilization_Min        = ($samples.CPUUtilizationPercent | Measure-Object -Minimum).Minimum
                CPUUtilization_Max        = ($samples.CPUUtilizationPercent | Measure-Object -Maximum).Maximum
                CPUUtilization_Avg        = [math]::Round(($samples.CPUUtilizationPercent | Measure-Object -Average).Average, 2)
                SQLMemory_Current         = $samples[-1].SQLMemoryMB
                SQLMemory_Min             = ($samples.SQLMemoryMB | Measure-Object -Minimum).Minimum
                SQLMemory_Max             = ($samples.SQLMemoryMB | Measure-Object -Maximum).Maximum
                SQLMemory_Avg             = [math]::Round(($samples.SQLMemoryMB | Measure-Object -Average).Average, 2)
                AvailableMemory_Current   = $samples[-1].AvailableMemoryMB
                AvailableMemory_Min       = ($samples.AvailableMemoryMB | Measure-Object -Minimum).Minimum
                AvailableMemory_Max       = ($samples.AvailableMemoryMB | Measure-Object -Maximum).Maximum
                AvailableMemory_Avg       = [math]::Round(($samples.AvailableMemoryMB | Measure-Object -Average).Average, 2)
                RunnableThreads_Current   = $samples[-1].RunnableThreads
                RunnableThreads_Min       = ($samples.RunnableThreads | Measure-Object -Minimum).Minimum
                RunnableThreads_Max       = ($samples.RunnableThreads | Measure-Object -Maximum).Maximum
                RunnableThreads_Avg       = [math]::Round(($samples.RunnableThreads | Measure-Object -Average).Average, 2)
                ActiveThreads_Current     = $samples[-1].ActiveThreads
                ActiveThreads_Min         = ($samples.ActiveThreads | Measure-Object -Minimum).Minimum
                ActiveThreads_Max         = ($samples.ActiveThreads | Measure-Object -Maximum).Maximum
                ActiveThreads_Avg         = [math]::Round(($samples.ActiveThreads | Measure-Object -Average).Average, 2)
                CachedPlans               = $samples[-1].CachedPlans
                TotalCompilations         = $samples[-1].TotalCompilations
            }

            # ===================================================================
            # GENERATE REPORTS
            # ===================================================================
            $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
            $reportBaseName = "ServerUtilization_${SqlInstance}_$timestamp"

            # TXT Report
            $txtReport = Generate-UtilizationTxtReport -Agg $aggregated -SqlInstance $SqlInstance -Samples $samples
            $txtPath = Join-Path $OutputPath "$reportBaseName.txt"
            $txtReport | Out-File -FilePath $txtPath -Encoding UTF8 -Force
            Invoke-sqmLogging -Message "TXT report saved: $txtPath" -FunctionName $functionName -Level "INFO"

            # CSV Report (raw sample data)
            $samples | Export-Csv -Path (Join-Path $OutputPath "$reportBaseName.csv") -NoTypeInformation -Encoding UTF8 -Force
            Invoke-sqmLogging -Message "CSV report saved: $(Join-Path $OutputPath "$reportBaseName.csv")" -FunctionName $functionName -Level "INFO"

            # HTML Report
            $htmlContent = Generate-UtilizationHtmlReport -Agg $aggregated -SqlInstance $SqlInstance -Samples $samples
            $htmlPath = Join-Path $OutputPath "$reportBaseName.html"
            $htmlContent | Out-File -FilePath $htmlPath -Encoding UTF8 -Force
            Invoke-sqmLogging -Message "HTML report saved: $htmlPath" -FunctionName $functionName -Level "INFO"

            # Open report
            if (-not $NoOpen)
            {
                Invoke-sqmOpenReport -HtmlFile $htmlPath -NoOpen:$NoOpen
            }

            Write-Verbose "Utilization data collection complete. Reports saved to: $OutputPath"
            return $aggregated
        }
        catch
        {
            $errMsg = "Error during utilization collection: $($_.Exception.Message)"
            Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR"
            if ($EnableException) { throw } else { Write-Error $errMsg }
        }
    }

    end
    {
        Invoke-sqmLogging -Message "Completed $functionName" -FunctionName $functionName -Level "INFO"
    }
}

# ===================================================================
# HELPER FUNCTIONS (Private)
# ===================================================================

function Generate-UtilizationTxtReport
{
    param(
        [PSCustomObject]$Agg,
        [string]$SqlInstance,
        [array]$Samples
    )

    $sb = New-Object System.Text.StringBuilder

    $sb.AppendLine("═══════════════════════════════════════════════════════════════") | Out-Null
    $sb.AppendLine(" SQL Server Utilization Report — $SqlInstance — $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") | Out-Null
    $sb.AppendLine("═══════════════════════════════════════════════════════════════") | Out-Null
    $sb.AppendLine() | Out-Null

    $sb.AppendLine("CPU UTILIZATION (%)") | Out-Null
    $sb.AppendLine("──────────────────") | Out-Null
    $sb.AppendLine(("{0,-30} {1,12} {2,12} {3,12} {4,12}" -f "Metric", "Current", "Min", "Max", "Avg")) | Out-Null
    $sb.AppendLine(("{0,-30} {1,12:F2} {2,12:F2} {3,12:F2} {4,12:F2}" -f "Processor Utilization %", $Agg.CPUUtilization_Current, $Agg.CPUUtilization_Min, $Agg.CPUUtilization_Max, $Agg.CPUUtilization_Avg)) | Out-Null
    $sb.AppendLine() | Out-Null

    $sb.AppendLine("MEMORY UTILIZATION (MB)") | Out-Null
    $sb.AppendLine("───────────────────────") | Out-Null
    $sb.AppendLine(("{0,-30} {1,12} {2,12} {3,12} {4,12}" -f "Metric", "Current", "Min", "Max", "Avg")) | Out-Null
    $sb.AppendLine(("{0,-30} {1,12:N0} {2,12:N0} {3,12:N0} {4,12:N0}" -f "SQL Memory (MB)", $Agg.SQLMemory_Current, $Agg.SQLMemory_Min, $Agg.SQLMemory_Max, $Agg.SQLMemory_Avg)) | Out-Null
    $sb.AppendLine(("{0,-30} {1,12:N0} {2,12:N0} {3,12:N0} {4,12:N0}" -f "Available Memory (MB)", $Agg.AvailableMemory_Current, $Agg.AvailableMemory_Min, $Agg.AvailableMemory_Max, $Agg.AvailableMemory_Avg)) | Out-Null
    $sb.AppendLine() | Out-Null

    $sb.AppendLine("WORKER THREADS") | Out-Null
    $sb.AppendLine("──────────────") | Out-Null
    $sb.AppendLine(("{0,-30} {1,12} {2,12} {3,12} {4,12}" -f "Metric", "Current", "Min", "Max", "Avg")) | Out-Null
    $sb.AppendLine(("{0,-30} {1,12:N0} {2,12:N0} {3,12:N0} {4,12:N0}" -f "Runnable Threads", $Agg.RunnableThreads_Current, $Agg.RunnableThreads_Min, $Agg.RunnableThreads_Max, $Agg.RunnableThreads_Avg)) | Out-Null
    $sb.AppendLine(("{0,-30} {1,12:N0} {2,12:N0} {3,12:N0} {4,12:N0}" -f "Active Threads", $Agg.ActiveThreads_Current, $Agg.ActiveThreads_Min, $Agg.ActiveThreads_Max, $Agg.ActiveThreads_Avg)) | Out-Null
    $sb.AppendLine() | Out-Null

    $sb.AppendLine("COMPILATION & EXECUTION") | Out-Null
    $sb.AppendLine("───────────────────────") | Out-Null
    $sb.AppendLine("Cached Query Plans: $($Agg.CachedPlans.ToString('N0'))") | Out-Null
    $sb.AppendLine("Total Compilations: $($Agg.TotalCompilations.ToString('N0'))") | Out-Null
    $sb.AppendLine() | Out-Null

    $sb.AppendLine("SAMPLING DETAILS") | Out-Null
    $sb.AppendLine("────────────────") | Out-Null
    $sb.AppendLine("Period: $($Agg.StartTime.ToString('yyyy-MM-dd HH:mm:ss')) — $($Agg.EndTime.ToString('HH:mm:ss'))") | Out-Null
    $sb.AppendLine("Duration: $((($Agg.EndTime - $Agg.StartTime).TotalSeconds).ToString('F1')) seconds") | Out-Null
    $sb.AppendLine("Samples: $($Agg.SampleCount)") | Out-Null
    $sb.AppendLine() | Out-Null

    $reference = Get-sqmReportReference
    $sb.AppendLine("$reference") | Out-Null

    return $sb.ToString()
}

function Generate-UtilizationHtmlReport
{
    param(
        [PSCustomObject]$Agg,
        [string]$SqlInstance,
        [array]$Samples
    )

    $htmlTable = $Samples | ConvertTo-Html -Fragment | Out-String

    # Pre-format all values (here-strings can't execute pipes)
    $cpuCurr = $Agg.CPUUtilization_Current.ToString("F2")
    $cpuMin = $Agg.CPUUtilization_Min.ToString("F2")
    $cpuMax = $Agg.CPUUtilization_Max.ToString("F2")
    $cpuAvg = $Agg.CPUUtilization_Avg.ToString("F2")
    $sqlMemCurr = $Agg.SQLMemory_Current.ToString("N0")
    $sqlMemAvg = $Agg.SQLMemory_Avg.ToString("N0")
    $availMemCurr = $Agg.AvailableMemory_Current.ToString("N0")
    $availMemAvg = $Agg.AvailableMemory_Avg.ToString("N0")
    $runnAvg = $Agg.RunnableThreads_Avg.ToString("F2")
    $activeAvg = $Agg.ActiveThreads_Avg.ToString("F2")
    $duration = (($Agg.EndTime - $Agg.StartTime).TotalSeconds).ToString("F1")
    $reference = Get-sqmReportReference

    $html = @"
<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <title>SQL Server Utilization Report</title>
    <style>
        *{box-sizing:border-box;margin:0;padding:0}
        body{font-family:'Segoe UI',Arial,sans-serif;background:#0f172a;color:#e2e8f0;font-size:13px}
        .page-header{background:linear-gradient(160deg,#1e3a8a 0%,#2e5090 100%);padding:28px 32px;border-left:4px solid #3b82f6;margin-bottom:24px}
        .page-header h1{font-size:1.4rem;font-weight:700;color:#60a5fa;margin:0 0 4px 0}
        .page-header .sub{font-size:.82rem;color:#94a3b8}
        .wrap{padding:0 24px 40px}
        h2{font-size:1rem;font-weight:600;color:#60a5fa;margin:28px 0 12px;border-bottom:1px solid #1e3a5f;padding-bottom:6px}
        table{width:100%;border-collapse:collapse;background:#0b1e3d;border-radius:6px;overflow:hidden;margin-bottom:20px}
        th{background:#1e3a8a;color:#60a5fa;padding:9px 12px;text-align:left;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:2px solid #3b82f6}
        td{padding:8px 12px;border-bottom:1px solid #1e3a5f;color:#e2e8f0;vertical-align:top}
        tr:hover td{background:#0f2744}
        .summary-table td:first-child{color:#94a3b8;font-weight:600;width:28%}
        .footer{margin-top:32px;padding-top:16px;border-top:1px solid #1e3a5f;font-size:.75rem;color:#475569;text-align:center}
        .footer a{color:#5dade2;text-decoration:none}
    </style>
</head>
<body>
<div class='page-header'>
  <h1>SQL Server Utilization Report</h1>
  <div class='sub'>Instance: $SqlInstance &bull; Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</div>
</div>
<div class='wrap'>

    <h2>Summary Metrics</h2>
    <table class='summary-table'>
        <tr>
            <td>CPU Utilization (Current / Min / Max / Avg %)</td>
            <td>$cpuCurr / $cpuMin / $cpuMax / $cpuAvg</td>
        </tr>
        <tr>
            <td>SQL Memory (Current / Avg MB)</td>
            <td>$sqlMemCurr / $sqlMemAvg</td>
        </tr>
        <tr>
            <td>Available Memory (Current / Avg MB)</td>
            <td>$availMemCurr / $availMemAvg</td>
        </tr>
        <tr>
            <td>Runnable Threads (Current / Avg)</td>
            <td>$($Agg.RunnableThreads_Current) / $runnAvg</td>
        </tr>
        <tr>
            <td>Active Threads (Current / Avg)</td>
            <td>$($Agg.ActiveThreads_Current) / $activeAvg</td>
        </tr>
        <tr>
            <td>Cached Plans</td>
            <td>$($Agg.CachedPlans.ToString('N0'))</td>
        </tr>
        <tr>
            <td>Total Compilations</td>
            <td>$($Agg.TotalCompilations.ToString('N0'))</td>
        </tr>
    </table>

    <h2>Sample Timeline</h2>
    $htmlTable

    <div class='footer'>
        <p>$reference</p>
        <p>Sampling Period: $($Agg.StartTime.ToString('yyyy-MM-dd HH:mm:ss')) — $($Agg.EndTime.ToString('HH:mm:ss')) ($duration sec, $($Agg.SampleCount) samples)</p>
    </div>
</div>
</body>
</html>
"@


    return $html
}