Src/Public/Get-AbrVbrLog.ps1


function Get-AbrVbrLog {
    <#
    .SYNOPSIS
        Collects diagnostic information for AsBuiltReport.Veeam.VBR troubleshooting.
    .DESCRIPTION
        Gathers environment, module, PowerShell session, and error information from
        the current session and the machine running the report. Output is written to
        a structured JSON file, and a status message is written to the host when the
        collection completes successfully.
    .PARAMETER OutputFolderPath
        Directory where the diagnostic bundle (JSON file) is saved.
        Defaults to the system temporary folder.
    .PARAMETER IncludeErrorDetails
        When specified, captures the full $Error collection including stack traces.
        By default only the most recent 25 errors are included (without stack traces).
    .PARAMETER PassThru
        Returns the diagnostic object to the pipeline in addition to writing the file.
    .EXAMPLE
        Get-AbrVbrLog
 
        Saves a diagnostic JSON to the system temp folder.
    .EXAMPLE
        Get-AbrVbrLog -OutputFolderPath 'C:\Logs' -IncludeErrorDetails -PassThru
 
        Saves a full diagnostic JSON (with stack traces) to C:\Logs and returns the
        object to the pipeline.
    .NOTES
        Version: 0.1.0
        Author: Jonathan Colon
        Github: rebelinux
    .LINK
        https://github.com/AsBuiltReport/AsBuiltReport.Veeam.VBR
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $false, HelpMessage = 'Directory where the diagnostic bundle is saved.')]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [String] $OutputFolderPath = ([System.IO.Path]::GetTempPath()),

        [Parameter(Mandatory = $false, HelpMessage = 'Include full stack traces for every error in $Error.')]
        [Switch] $IncludeErrorDetails,

        [Parameter(Mandatory = $false, HelpMessage = 'Return the diagnostic object to the pipeline.')]
        [Switch] $PassThru
    )

    begin {
        Write-Verbose 'Get-AbrVbrLog: Starting diagnostic collection.'
        $TimeStamp = Get-Date -Format 'yyyyMMdd_HHmmss'
        $FileName = "AbrVbrDiagnostics_$TimeStamp.json"
        $OutputFile = Join-Path -Path $OutputFolderPath -ChildPath $FileName

        # Compute platform once; used throughout process block.
        # PS 5.1 (Desktop) lacks $PSVersionTable.Platform, so fall back to env/API.
        $IsWindowsPlatform = if ($PSVersionTable.ContainsKey('Platform') -and $PSVersionTable.Platform) {
            $PSVersionTable.Platform -eq 'Win32NT'
        } else {
            ($env:OS -eq 'Windows_NT') -or ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
        }

        $Platform = if ($PSVersionTable.ContainsKey('Platform') -and $PSVersionTable.Platform) {
            $PSVersionTable.Platform
        } elseif ($IsWindowsPlatform) {
            'Win32NT'
        } else {
            [System.Environment]::OSVersion.Platform.ToString()
        }
    }

    process {
        $Diag = [ordered] @{}

        # --- Collection timestamp -----------------------------------------------
        $Diag['CollectedAt'] = (Get-Date -Format 'o')

        # --- PowerShell session info --------------------------------------------
        try {
            $Diag['PowerShellSession'] = [ordered] @{
                PSVersion = $PSVersionTable.PSVersion.ToString()
                PSEdition = $PSVersionTable.PSEdition
                CLRVersion = if ($PSVersionTable.CLRVersion) { $PSVersionTable.CLRVersion.ToString() } else { 'N/A' }
                WSManStackVersion = if ($PSVersionTable.WSManStackVersion) { $PSVersionTable.WSManStackVersion.ToString() } else { 'N/A' }
                OS = $PSVersionTable.OS
                Platform = $Platform
                ExecutionPolicy = (Get-ExecutionPolicy -Scope Process).ToString()
                CurrentPrincipal = if ($IsWindowsPlatform) {
                    [Security.Principal.WindowsIdentity]::GetCurrent().Name
                } else {
                    $EnvUser = [System.Environment]::GetEnvironmentVariable('USER')
                    if ($EnvUser) { $EnvUser } else { [System.Environment]::GetEnvironmentVariable('LOGNAME') }
                }
                IsAdministrator = if ($IsWindowsPlatform) {
                    ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
                } else {
                    try { (& id -u).Trim() -eq '0' } catch { 'N/A' }
                }
                HostName = $Host.Name
                HostVersion = $Host.Version.ToString()
                PID = $PID
            }
        } catch {
            $Diag['PowerShellSession'] = "Error collecting PowerShell session info: $($_.Exception.Message)"
        }

        # --- Machine / OS info --------------------------------------------------
        if ($IsWindowsPlatform) {
            try {
                $OS = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
                $CS = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
                $CPU = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | Select-Object -First 1
                $Diag['Machine'] = [ordered] @{
                    ComputerName = $env:COMPUTERNAME
                    Domain = $CS.Domain
                    Manufacturer = $CS.Manufacturer
                    Model = $CS.Model
                    TotalMemoryGB = [math]::Round($CS.TotalPhysicalMemory / 1GB, 2)
                    OSCaption = $OS.Caption
                    OSVersion = $OS.Version
                    OSBuildNumber = $OS.BuildNumber
                    OSArchitecture = $OS.OSArchitecture
                    OSLastBootUpTime = $OS.LastBootUpTime.ToString('o')
                    CPUName = $CPU.Name
                    CPUCores = $CPU.NumberOfCores
                    CPULogicalProc = $CPU.NumberOfLogicalProcessors
                    TimeZone = (Get-TimeZone).DisplayName
                }
            } catch {
                $Diag['Machine'] = "Error collecting machine info: $($_.Exception.Message)"
            }
        } else {
            # Unix (Linux / macOS)
            try {
                $KernelName = try { (& uname -s).Trim() } catch { 'N/A' }
                $KernelRelease = try { (& uname -r).Trim() } catch { 'N/A' }
                $Architecture = try { (& uname -m).Trim() } catch { 'N/A' }
                $HostName = [System.Net.Dns]::GetHostName()
                $EnvUser = [System.Environment]::GetEnvironmentVariable('USER')
                $CurrentUser = if ($EnvUser) { $EnvUser } else { [System.Environment]::GetEnvironmentVariable('LOGNAME') }
                $IsRoot = try { (& id -u).Trim() -eq '0' } catch { 'N/A' }

                if ($KernelName -eq 'Linux') {
                    $OSDescription = try {
                        $Release = Get-Content '/etc/os-release' -ErrorAction Stop
                        ($Release | Where-Object { $_ -match '^PRETTY_NAME=' } | Select-Object -First 1) -replace '^PRETTY_NAME=|"', ''
                    } catch { 'N/A' }

                    $MemInfo = Get-Content '/proc/meminfo' -ErrorAction SilentlyContinue
                    $MemKB = ($MemInfo | Where-Object { $_ -match '^MemTotal:' } | Select-Object -First 1) -replace '\D', ''
                    $MemGB = if ($MemKB) { [math]::Round([long]$MemKB / 1MB, 2) } else { 'N/A' }

                    $CpuInfo = Get-Content '/proc/cpuinfo' -ErrorAction SilentlyContinue
                    $CpuName = ($CpuInfo | Where-Object { $_ -match '^model name' } | Select-Object -First 1) -replace '^model name\s*:\s*', ''
                    $CpuCores = ($CpuInfo | Where-Object { $_ -match '^cpu cores' } | Select-Object -First 1) -replace '\D', ''
                    $CpuLogical = ($CpuInfo | Where-Object { $_ -match '^processor' }).Count
                } elseif ($KernelName -eq 'Darwin') {
                    $OSDescription = try { "$(& sw_vers -productName) $(& sw_vers -productVersion)".Trim() } catch { 'N/A' }
                    $MemBytes = try { [long](& sysctl -n hw.memsize) } catch { $null }
                    $MemGB = if ($null -ne $MemBytes) { [math]::Round($MemBytes / 1GB, 2) } else { 'N/A' }
                    $CpuName = try { (& sysctl -n machdep.cpu.brand_string).Trim() } catch { 'N/A' }
                    $CpuCores = try { (& sysctl -n hw.physicalcpu).Trim() } catch { 'N/A' }
                    $CpuLogical = try { (& sysctl -n hw.logicalcpu).Trim() } catch { 'N/A' }
                } else {
                    $OSDescription = "Unknown Unix ($KernelName)"
                    $MemGB = $CpuName = $CpuCores = $CpuLogical = 'N/A'
                }

                $Diag['Machine'] = [ordered] @{
                    ComputerName = $HostName
                    CurrentUser = $CurrentUser
                    IsRoot = $IsRoot
                    KernelName = $KernelName
                    KernelRelease = $KernelRelease
                    OSDescription = $OSDescription
                    Architecture = $Architecture
                    TotalMemoryGB = $MemGB
                    CPUName = if ($CpuName) { $CpuName }    else { 'N/A' }
                    CPUCores = if ($CpuCores) { $CpuCores }   else { 'N/A' }
                    CPULogicalProc = if ($CpuLogical) { $CpuLogical } else { 'N/A' }
                    TimeZone = (Get-TimeZone).DisplayName
                }
            } catch {
                $Diag['Machine'] = "Error collecting machine info: $($_.Exception.Message)"
            }
        }

        # --- Relevant installed modules -----------------------------------------
        try {
            $RelevantModuleNames = @(
                'AsBuiltReport.Veeam.VBR',
                'AsBuiltReport.Core',
                'AsBuiltReport.Chart',
                'AsBuiltReport.Diagram',
                'Veeam.Backup.PowerShell',
                'PScribo',
                'PSGraph'
            )
            $ModuleInfo = foreach ($ModName in $RelevantModuleNames) {
                $Mods = Get-Module -ListAvailable -Name $ModName -ErrorAction SilentlyContinue |
                Sort-Object -Property Version -Descending
                if ($Mods) {
                    foreach ($Mod in $Mods) {
                        [ordered] @{
                            Name = $Mod.Name
                            Version = $Mod.Version.ToString()
                            Path = $Mod.ModuleBase
                            Description = $Mod.Description
                        }
                    }
                } else {
                    [ordered] @{
                        Name = $ModName
                        Version = 'Not installed'
                        Path = $null
                        Description = $null
                    }
                }
            }
            $Diag['InstalledModules'] = @($ModuleInfo)
        } catch {
            $Diag['InstalledModules'] = "Error collecting module info: $($_.Exception.Message)"
        }

        # --- Currently loaded modules in session --------------------------------
        try {
            $Diag['LoadedModules'] = @(
                Get-Module | Sort-Object -Property Name | ForEach-Object {
                    [ordered] @{
                        Name = $_.Name
                        Version = $_.Version.ToString()
                        Path = $_.ModuleBase
                    }
                }
            )
        } catch {
            $Diag['LoadedModules'] = "Error collecting loaded modules: $($_.Exception.Message)"
        }

        # --- $Error variable collection -----------------------------------------
        try {
            $MaxErrors = if ($IncludeErrorDetails) { $global:Error.Count } else { [math]::Min(25, $global:Error.Count) }
            $ErrorCollection = for ($i = 0; $i -lt $MaxErrors; $i++) {
                $Err = $global:Error[$i]
                if ($null -eq $Err) { continue }
                $ErrObj = [ordered] @{
                    Index = $i
                    Message = $Err.Exception.Message
                    FullyQualifiedErrorId = $Err.FullyQualifiedErrorId
                    Type = $Err.Exception.GetType().FullName
                    Category = $Err.CategoryInfo.Category.ToString()
                    CategoryReason = $Err.CategoryInfo.Reason
                    TargetName = $Err.CategoryInfo.TargetName
                    ErrorDetails = if ($Err.ErrorDetails) { $Err.ErrorDetails.Message } else { $null }
                    ScriptName = $Err.InvocationInfo.ScriptName
                    LineNumber = $Err.InvocationInfo.ScriptLineNumber
                    Line = $Err.InvocationInfo.Line -replace '\s+', ' '
                    CommandName = $Err.InvocationInfo.MyCommand.Name
                }
                if ($IncludeErrorDetails) {
                    $ErrObj['StackTrace'] = $Err.Exception.StackTrace
                    # Build full inner exception chain
                    $InnerChain = [System.Collections.Generic.List[string]]::new()
                    $Inner = $Err.Exception.InnerException
                    while ($null -ne $Inner) {
                        $InnerChain.Add("[$($Inner.GetType().FullName)] $($Inner.Message)")
                        $Inner = $Inner.InnerException
                    }
                    $ErrObj['InnerExceptions'] = if ($InnerChain.Count -gt 0) { $InnerChain.ToArray() } else { $null }
                }
                $ErrObj
            }
            $Diag['ErrorLog'] = [ordered] @{
                TotalErrors = $global:Error.Count
                CapturedErrors = $MaxErrors
                FullDetails = $IncludeErrorDetails.IsPresent
                Errors = @($ErrorCollection)
            }
        } catch {
            $Diag['ErrorLog'] = "Error collecting `$Error log: $($_.Exception.Message)"
        }

        # --- Environment variables (safe subset) --------------------------------
        try {
            $SafeEnvVars = if ($IsWindowsPlatform) {
                @('COMPUTERNAME', 'USERNAME', 'USERDOMAIN', 'USERDNSDOMAIN',
                    'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS',
                    'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA', 'PSModulePath')
            } else {
                @('USER', 'LOGNAME', 'HOME', 'SHELL', 'HOSTNAME', 'TMPDIR', 'TEMP', 'TMP',
                    'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'PSModulePath')
            }
            $EnvInfo = [ordered] @{}
            foreach ($VarName in $SafeEnvVars) {
                $EnvInfo[$VarName] = [System.Environment]::GetEnvironmentVariable($VarName)
            }
            $Diag['EnvironmentVariables'] = $EnvInfo
        } catch {
            $Diag['EnvironmentVariables'] = "Error collecting environment variables: $($_.Exception.Message)"
        }

        # --- Write output file --------------------------------------------------
        $DiagObject = [pscustomobject] $Diag
        try {
            $DiagObject | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputFile -Encoding UTF8 -Force
            Write-Host " [Get-AbrVbrLog] Diagnostic bundle saved to: $OutputFile" -ForegroundColor Green
        } catch {
            Write-Warning "Get-AbrVbrLog: Failed to write diagnostic file '$OutputFile': $($_.Exception.Message)"
        }

        if ($PassThru) {
            $DiagObject
        }
    }

    end {
        Write-Verbose 'Get-AbrVbrLog: Diagnostic collection complete.'
    }
}