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 } |