Public/Invoke-FslDiagnostic.ps1

function Invoke-FslDiagnostic {
    <#
    .SYNOPSIS
        Runs the full FSLogixDoctor diagnostic on a session host and answers the
        question 'why are profiles failing here?' in one command.
    .DESCRIPTION
        Combines every FSLogixDoctor check into one read-only run:
 
          1. Environment - install state, service, version
          2. Configuration - Test-FslConfiguration rule set
          3. SessionState - per-session Status/Reason/Error, translated
          4. LogFile - recent errors from the FSLogix text logs, decoded
          5. EventLog - FSLogix event channels, bucketed and explained
          6. ContextEvents - curated events from the surrounding Windows logs
                              (User Profile Service, NTFS, disk), correlated
                              with the FSLogix findings
 
        Returns FSLogixDoctor.Finding objects and optionally renders them into a
        self-contained HTML report (-ReportPath).
 
        Two safeguards keep noise from drowning out real problems: messages that
        match the known-benign noise database (Data\BenignPatterns.psd1) are
        reported as Info instead of Critical, and when every recorded session
        attached cleanly, remaining Critical log/event findings are downgraded to
        Warning because no user-visible impact exists yet.
    .PARAMETER Hours
        Look-back window for log and event analysis. Defaults to 24.
    .PARAMETER LogPath
        Root of the FSLogix log directory. Defaults to %ProgramData%\FSLogix\Logs.
    .PARAMETER IncludeWarnings
        Also surface WARN-level log lines, not only errors.
    .PARAMETER ReportPath
        Where to write the HTML report. When omitted, a report is still written
        automatically to %ProgramData%\FSLogixDoctor\Reports (timestamped); the
        findings stay the pipeline output and the path travels in the summary
        and verbose stream. Disable the automatic report with -NoReport.
    .PARAMETER PassThru
        Emit the finding objects even when -ReportPath is used.
    .PARAMETER ComputerName
        Fleet mode: run the diagnostic on one or more session hosts via
        PowerShell remoting and aggregate the results. FSLogixDoctor must be
        installed on each target (Install-Module FSLogixDoctor). Findings that
        are identical across hosts (typically configuration drift) are merged
        into one finding listing every affected host; host-specific findings
        stay separate. Unreachable hosts become Critical 'Fleet connectivity'
        findings instead of aborting the run.
    .PARAMETER AsSummary
        Return one FSLogixDoctor.Summary object (severity counts, worst
        severity, monitoring-friendly ExitCode, plus the findings) instead of
        the raw finding stream.
    .PARAMETER AsJson
        Like -AsSummary, but returns the summary serialized as JSON - made for
        RMM/monitoring sensors (PRTG, Zabbix, scheduled tasks).
    .PARAMETER HistoryPath
        Run-history folder; defaults to %ProgramData%\FSLogixDoctor\History and
        is active by default (disable with -NoHistory; an explicitly bound
        -HistoryPath always wins). Each run is written as JSON and diffed
        against the previous run of the same scope: findings get a ChangeStatus
        (New/Persisting/Resolved), alert-worthy messages are annotated, vanished
        Critical/Warning findings come back as 'Resolved' Info findings, and
        the summary/report gain change counts.
    .PARAMETER IncludeProfileStore
        Force the profile store scan. Normally unnecessary: when neither this
        switch nor -ProfileStorePath is given, the store path is auto-detected
        from VHDLocations (locally, or from the first reachable fleet host) and
        scanned automatically. Containers at >=85% of their maximum size become
        Warnings (>=95% Critical), structural anomalies become findings. The
        scan runs once from the coordinating machine, not per host.
    .PARAMETER ProfileStorePath
        Explicit profile share path(s) for the store scan; overrides the
        auto-detection.
    .PARAMETER NoProfileStore
        Skip the automatic profile store scan (e.g. on very large shares).
    .PARAMETER NoHistory
        Skip the automatic run history.
    .PARAMETER NoReport
        Skip the automatic HTML report.
    .PARAMETER HistoryRetentionDays
        Rotate run-history files older than this many days (default 90).
    .PARAMETER AcceptedFindingsPath
        JSON list of deliberate, documented deviations (default
        %ProgramData%\FSLogixDoctor\accepted.json). Entries are objects with
        Check (exact), optional Target (wildcard, default *), Reason (required)
        and optional ExpiresOn. Matching Critical/Warning findings drop to Info
        with the reason attached and stop driving the ExitCode - accepted, not
        hidden.
    .EXAMPLE
        Invoke-FslDiagnostic
 
        Full diagnostic of the local session host, findings on the pipeline.
    .EXAMPLE
        Invoke-FslDiagnostic -Hours 4 -ReportPath C:\Temp\fslogix-report.html
    .EXAMPLE
        Invoke-FslDiagnostic -ComputerName avd-0, avd-1 -ReportPath .\fleet.html
 
        Fleet diagnostic of two session hosts, merged into one report.
    .EXAMPLE
        $r = Invoke-FslDiagnostic -AsSummary; exit $r.ExitCode
 
        Monitoring wrapper: exit 0 = healthy, 1 = warnings, 2 = critical.
    .EXAMPLE
        Invoke-FslDiagnostic -ComputerName avd-0, avd-1
 
        The daily driver needs nothing else: the store path is auto-detected,
        the run history diffs against yesterday, and the HTML report lands in
        %ProgramData%\FSLogixDoctor\Reports - all without further parameters.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [ValidateRange(1, 8760)]
        [int]$Hours = 24,

        [string]$LogPath = (Join-Path $env:ProgramData 'FSLogix\Logs'),

        [switch]$IncludeWarnings,

        [string]$ReportPath,

        [switch]$PassThru,

        [string[]]$ComputerName = @(),

        [switch]$AsSummary,

        [switch]$AsJson,

        [string]$HistoryPath = (Join-Path $env:ProgramData 'FSLogixDoctor\History'),

        [switch]$IncludeProfileStore,

        [string[]]$ProfileStorePath = @(),

        [switch]$NoProfileStore,

        [switch]$NoHistory,

        [switch]$NoReport,

        [ValidateRange(1, 3650)]
        [int]$HistoryRetentionDays = 90,

        [string]$AcceptedFindingsPath = (Join-Path $env:ProgramData 'FSLogixDoctor\accepted.json')
    )

    $findings = New-Object System.Collections.Generic.List[object]

    # --- Fleet mode: fan out via PowerShell remoting, merge, then fall through
    # to the shared output section. Each host runs the normal single-host path.
    if (@($ComputerName).Count -gt 0) {
        foreach ($computer in $ComputerName) {
            $hostFindings = @()
            if ($computer -in @($env:COMPUTERNAME, 'localhost', '.')) {
                Write-Verbose ("Fleet: running locally on {0}..." -f $env:COMPUTERNAME)
                # Per-host runs collect findings only - store scan, history and
                # report happen once at the fleet level, never per host.
                $hostFindings = @(Invoke-FslDiagnostic -Hours $Hours -LogPath $LogPath -IncludeWarnings:$IncludeWarnings -NoProfileStore -NoHistory -NoReport)
            }
            else {
                Write-Verbose ("Fleet: running remotely on {0}..." -f $computer)
                try {
                    $remoteFindings = @(Invoke-Command -ComputerName $computer -ErrorAction Stop -ScriptBlock {
                            Import-Module FSLogixDoctor -ErrorAction Stop
                            $diagCommand = Get-Command Invoke-FslDiagnostic
                            if ($diagCommand.Parameters.ContainsKey('NoReport')) {
                                Invoke-FslDiagnostic -Hours $using:Hours -IncludeWarnings:$using:IncludeWarnings -NoProfileStore -NoHistory -NoReport
                            }
                            else {
                                # Older module version on the target: no auto behaviors to suppress.
                                Invoke-FslDiagnostic -Hours $using:Hours -IncludeWarnings:$using:IncludeWarnings
                            }
                        })
                    # Rebuild as clean local finding objects - Invoke-Command
                    # tacks PSComputerName/RunspaceId metadata onto everything
                    # it returns, which pollutes the merged output.
                    $hostFindings = @($remoteFindings | ForEach-Object {
                            New-FslFinding -Category ([string]$_.Category) -Check ([string]$_.Check) -Severity ([string]$_.Severity) `
                                -Target ([string]$_.Target) -Message ([string]$_.Message) -Evidence ([string]$_.Evidence) `
                                -Recommendation ([string]$_.Recommendation) -HelpUri ([string]$_.HelpUri)
                        })
                }
                catch {
                    $hostFindings = @(New-FslFinding -Category Environment -Check 'Fleet connectivity' -Severity Critical -Target $computer `
                            -Message ("Could not run the diagnostic on '{0}': {1}" -f $computer, $_.Exception.Message) `
                            -Recommendation 'Verify PowerShell remoting is enabled on the target (Enable-PSRemoting) and that FSLogixDoctor is installed there: Install-Module FSLogixDoctor.')
                }
            }
            foreach ($hostFinding in $hostFindings) { $findings.Add($hostFinding) }
        }

        $merged = @(Merge-FslFleetFinding -Finding $findings.ToArray())
        $findings = New-Object System.Collections.Generic.List[object]
        foreach ($mergedFinding in $merged) { $findings.Add($mergedFinding) }

        # Fleet mode compares findings; two hosts with different raw settings,
        # each unremarkable on its own, only surface via the drift check.
        if (@($ComputerName).Count -gt 1) {
            Write-Verbose 'Comparing core FSLogix settings across the fleet...'
            foreach ($driftFinding in @(Get-FslFleetConfigDrift -ComputerName $ComputerName)) { $findings.Add($driftFinding) }
        }
    }
    else {
        foreach ($localFinding in @(Invoke-FslLocalDiagnostic -Hours $Hours -LogPath $LogPath -IncludeWarnings:$IncludeWarnings)) {
            $findings.Add($localFinding)
        }
    }

    # Profile store scan - runs once (on the coordinating machine in fleet
    # mode), not per host. Explicit paths win; otherwise the store path is
    # auto-detected from VHDLocations (locally, or from the first reachable
    # fleet host) unless -NoProfileStore opts out.
    $storeMetrics = @()
    $storeParams = $null
    if ($IncludeProfileStore -or @($ProfileStorePath | Where-Object { $_ }).Count -gt 0) {
        $storeParams = @{ Path = $ProfileStorePath }
    }
    elseif (-not $NoProfileStore) {
        $autoStorePaths = @(Resolve-FslProfileStorePath -ComputerName $ComputerName)
        if ($autoStorePaths.Count -gt 0) {
            Write-Verbose ("Auto-detected profile store: {0}" -f ($autoStorePaths -join ', '))
            $storeParams = @{ Path = $autoStorePaths; SkipUnreachable = $true }
        }
    }
    if ($storeParams) {
        Write-Verbose 'Scanning the profile store...'
        # Cross-check event 33 against the store: a container that FSLogix
        # reports as nearly full but the store sees far from its maximum was
        # resized without extending the partition inside.
        $event33Paths = @()
        foreach ($event33Finding in @($findings | Where-Object { $_.Check -eq 'Event 33' -and $_.Severity -in @('Critical', 'Warning') })) {
            foreach ($pathMatch in [regex]::Matches([string]$event33Finding.Evidence, '(?i)VHDPath:\s*(?<path>\\\\[^|)]+?\.vhdx?)')) {
                $event33Paths += $pathMatch.Groups['path'].Value.Trim()
            }
        }
        if ($event33Paths.Count -gt 0) { $storeParams['CrossCheckPath'] = @($event33Paths | Select-Object -Unique) }

        foreach ($storeItem in @(Get-FslProfileStoreFinding @storeParams)) {
            if ($storeItem.PSObject.TypeNames -contains 'FSLogixDoctor.StoreMetric') {
                $storeMetrics += @($storeItem.Disks)
            }
            else {
                $findings.Add($storeItem)
            }
        }
    }

    # Accepted findings: deliberate, documented deviations drop to Info with
    # the reason attached - they stop paging the monitoring without vanishing
    # from the report.
    $acceptedEntries = @(Get-FslAcceptedFinding -Path $AcceptedFindingsPath)
    if ($acceptedEntries.Count -gt 0) {
        foreach ($candidate in @($findings | Where-Object { $_.Severity -in @('Critical', 'Warning') })) {
            foreach ($accepted in $acceptedEntries) {
                if ([string]$candidate.Check -ne [string]$accepted.Check) { continue }
                $targetPattern = [string]$accepted.Target
                if (-not $targetPattern) { $targetPattern = '*' }
                if ([string]$candidate.Target -notlike $targetPattern) { continue }
                $candidate.Severity = 'Info'
                $acceptedNote = (" (Accepted: {0}" -f $accepted.Reason)
                if ($accepted.ExpiresOn) { $acceptedNote += (", until {0}" -f $accepted.ExpiresOn) }
                $candidate.Message += ($acceptedNote + '.)')
                Add-Member -InputObject $candidate -NotePropertyName Accepted -NotePropertyValue $true -Force
                break
            }
        }
    }

    # Run history: on by default (opt out with -NoHistory); an explicitly
    # bound -HistoryPath always wins. Tags New/Persisting, resurfaces vanished
    # alerts as Resolved, persists this run for the next diff.
    $historyEnabled = ($PSBoundParameters.ContainsKey('HistoryPath') -or (-not $NoHistory))
    if ($historyEnabled) {
        $historyScope = $env:COMPUTERNAME
        if (@($ComputerName).Count -gt 0) { $historyScope = ((@($ComputerName) | Sort-Object) -join ',') }
        try {
            $tracked = @(Update-FslRunHistory -Finding $findings.ToArray() -HistoryPath $HistoryPath -Scope $historyScope -StoreMetric $storeMetrics -RetentionDays $HistoryRetentionDays)
            $findings = New-Object System.Collections.Generic.List[object]
            foreach ($trackedFinding in $tracked) { $findings.Add($trackedFinding) }
        }
        catch {
            Write-Warning ("Run history could not be updated ({0}) - continuing without a diff." -f $_.Exception.Message)
        }
    }

    # Output: report, summary/JSON and/or pipeline.
    $severityOrder = @{ 'Critical' = 0; 'Warning' = 1; 'Info' = 2; 'Pass' = 3 }
    $sorted = @($findings | Sort-Object -Property @{ Expression = { $severityOrder[$_.Severity] } }, Category, Check)

    # Report: on by default (opt out with -NoReport). An explicit -ReportPath
    # keeps the classic behavior (returns the FileInfo); the automatic report
    # goes to %ProgramData%\FSLogixDoctor\Reports and the findings stay the
    # pipeline output - the path is carried in the summary and verbose stream.
    $reportEnabled = ($PSBoundParameters.ContainsKey('ReportPath') -or (-not $NoReport))
    $reportWritten = ''
    if ($reportEnabled) {
        $autoReport = (-not $PSBoundParameters.ContainsKey('ReportPath'))
        $effectiveReportPath = $ReportPath
        try {
            if ($autoReport) {
                $reportDir = Join-Path $env:ProgramData 'FSLogixDoctor\Reports'
                if (-not (Test-Path -LiteralPath $reportDir)) { New-Item -ItemType Directory -Path $reportDir -Force | Out-Null }
                $effectiveReportPath = Join-Path $reportDir ('fslogix-report-{0}.html' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
            }
            $report = $sorted | New-FslReport -Path $effectiveReportPath -LookbackHours $Hours
            $reportWritten = [string]$report.FullName
            Write-Verbose ("Report written to {0}" -f $reportWritten)
            if (-not $autoReport -and -not $PassThru -and -not $AsSummary -and -not $AsJson) {
                return $report
            }
        }
        catch {
            Write-Warning ("Report could not be written ({0})." -f $_.Exception.Message)
        }
    }

    if ($AsSummary -or $AsJson) {
        $counts = @{}
        foreach ($severityName in @('Critical', 'Warning', 'Info', 'Pass')) {
            $counts[$severityName] = @($sorted | Where-Object Severity -eq $severityName).Count
        }
        # Worst severity maps to a monitoring-friendly exit code:
        # 0 = healthy (Pass/Info only), 1 = warnings, 2 = critical.
        $worst = 'None'
        $exitCode = 0
        if ($counts['Critical'] -gt 0) { $worst = 'Critical'; $exitCode = 2 }
        elseif ($counts['Warning'] -gt 0) { $worst = 'Warning'; $exitCode = 1 }
        elseif ($counts['Info'] -gt 0) { $worst = 'Info' }
        elseif ($counts['Pass'] -gt 0) { $worst = 'Pass' }

        $summaryObject = [pscustomobject]@{
            PSTypeName    = 'FSLogixDoctor.Summary'
            Target        = ((@($sorted | ForEach-Object { [string]$_.Target } | Select-Object -Unique | Sort-Object)) -join ', ')
            GeneratedAt   = Get-Date
            CriticalCount = $counts['Critical']
            WarningCount  = $counts['Warning']
            InfoCount     = $counts['Info']
            PassCount     = $counts['Pass']
            WorstSeverity = $worst
            ExitCode      = $exitCode
            NewCount      = @($sorted | Where-Object { $null -ne $_.PSObject.Properties['ChangeStatus'] -and $_.ChangeStatus -eq 'New' -and $_.Severity -in @('Critical', 'Warning') }).Count
            ResolvedCount = @($sorted | Where-Object { $null -ne $_.PSObject.Properties['ChangeStatus'] -and $_.ChangeStatus -eq 'Resolved' }).Count
            ReportPath    = $reportWritten
            Findings      = $sorted
        }
        if ($AsJson) {
            return ($summaryObject | ConvertTo-Json -Depth 5)
        }
        return $summaryObject
    }

    $sorted
}