Action.Logging.psm1
# ======================================== # Action.Logging Module # ======================================== # # Module Version : 1.2.0 # Date Created: 06-21-2025 # Last Revised: 08-19-2025 # Created by : Gonzalo More # Required files: None # Required folders: None # # Licensed under the MIT License. # See LICENSE file in the project root for full license information. # # ======================================== <# .SYNOPSIS Action.Logging - Enhanced PowerShell Logging Module with async support. .LINK For full documentation, see README.md #> # Enhanced logging structure with severity levels enum LogLevel { ERROR = 1 WARN = 2 INFO = 3 DEBUG = 4 TRACE = 5 } # Module-level variables for async logging $script:LogQueue = [System.Collections.Concurrent.ConcurrentQueue[PSObject]]::new() $script:LoggerRunspace = $null $script:LoggerPowerShell = $null $script:CurrentLogFile = $null $script:AsyncLoggingEnabled = $false $script:LoggingStopRequested = $false $script:ConsoleOutputEnabled = $true $script:DefaultLogRetentionCount = 10 $script:VerifiedPaths = @{} $script:SessionFiles = @{} <# .SYNOPSIS Writes enhanced log entries with severity levels and color coding. .DESCRIPTION Creates timestamped log entries with severity levels, saves to log files, and displays color-coded output to the console. Automatically creates log directories if they don't exist. .PARAMETER Message The message to log. .PARAMETER Level The severity level of the log entry. Default is INFO. .PARAMETER LogPath The path where log files will be stored. Default is based on script name. .EXAMPLE Write-EnhancedLog -Message "Script started" -Level INFO .EXAMPLE Write-EnhancedLog -Message "Critical error occurred" -Level ERROR .EXAMPLE Write-EnhancedLog -Message "Debug information" -Level DEBUG -LogPath "C:\custom\logs" #> function Write-EnhancedLog { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [LogLevel]$Level = [LogLevel]::INFO, [Parameter(Mandatory = $false)] [string]$LogPath ) # If no LogPath specified, use calling script name if (-not $LogPath) { $callingScript = (Get-PSCallStack)[1].ScriptName if ($callingScript) { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($callingScript) $LogPath = "C:\temp\logs\$scriptName" } else { $LogPath = "C:\temp\logs\Action.Logging" } } try { # Only verify path once per session (optimization) if (-not $script:VerifiedPaths.ContainsKey($LogPath)) { if (-not (Test-Path $LogPath)) { New-Item -ItemType Directory -Path $LogPath -Force | Out-Null } $script:VerifiedPaths[$LogPath] = $true # Initialize session file for this path with script name prefix $folderName = Split-Path $LogPath -Leaf if ($folderName -eq "Action.Logging") { $logFileName = "ActionLogging_$(Get-Date -Format 'MMddyy-HHmmss').log" } else { $logFileName = "${folderName}_$(Get-Date -Format 'MMddyy-HHmmss').log" } $script:SessionFiles[$LogPath] = "$LogPath\$logFileName" Clear-OldLog -LogPath $LogPath } # Use path-specific session file $currentLogFile = $script:SessionFiles[$LogPath] $timestamp = Get-Date -Format 'MM-dd-yyyy HH:mm:ss' $logEntry = "$timestamp [$Level] $Message" # Write to log file Add-Content -Path $currentLogFile -Value $logEntry -Encoding UTF8 -ErrorAction SilentlyContinue # Output to console with color coding if enabled if ($script:ConsoleOutputEnabled) { switch ($Level) { ([LogLevel]::ERROR) { Write-Host $logEntry -ForegroundColor Red } ([LogLevel]::WARN) { Write-Host $logEntry -ForegroundColor Yellow } ([LogLevel]::INFO) { Write-Host $logEntry -ForegroundColor Green } ([LogLevel]::DEBUG) { Write-Host $logEntry -ForegroundColor Cyan } ([LogLevel]::TRACE) { Write-Host $logEntry -ForegroundColor Gray } } } } catch { if ($script:ConsoleOutputEnabled) { Write-Warning "Failed to write to log: $($_.Exception.Message)" # Fallback to console output only Write-Host "$timestamp [$Level] $Message" -ForegroundColor White } } } <# .SYNOPSIS Cleans up old log files, keeping only the most recent ones. .DESCRIPTION Removes old log files from the specified path, keeping only the last 10 log files to manage disk space. Files are sorted by creation time. .PARAMETER LogPath The path containing log files to clean up. .PARAMETER KeepCount Number of recent log files to keep. Default is 10. .EXAMPLE Clear-OldLogs -LogPath "C:\temp\logs\MyScript" .EXAMPLE Clear-OldLogs -LogPath "C:\temp\logs\MyScript" -KeepCount 5 #> function Clear-OldLog { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$LogPath, [Parameter(Mandatory = $false)] [int]$KeepCount = $script:DefaultLogRetentionCount ) try { if (Test-Path $LogPath) { $logFiles = Get-ChildItem -Path $LogPath -Filter "*.log" | Sort-Object CreationTime -Descending if ($logFiles.Count -gt $KeepCount) { $filesToDelete = $logFiles[$KeepCount..($logFiles.Count - 1)] $filesToDelete | Remove-Item -Force -ErrorAction SilentlyContinue Write-Verbose "Cleaned up $($filesToDelete.Count) old log files from $LogPath" } } } catch { Write-Warning "Failed to clean up old logs in ${LogPath}: $($_.Exception.Message)" } } <# .SYNOPSIS Starts an asynchronous logging background worker for high-performance logging. .DESCRIPTION Initializes a background PowerShell runspace that handles log writing asynchronously. This provides better performance for high-volume logging scenarios by queuing log entries and writing them in batches to reduce I/O overhead. .PARAMETER LogPath Optional path where log files will be stored. If not specified, uses calling script name detection to determine the appropriate log directory. .EXAMPLE Start-AsyncLogger Starts async logger using automatic path detection based on calling script. .EXAMPLE Start-AsyncLogger -LogPath "C:\temp\logs\MyApplication" Starts async logger with a specific log directory path. .NOTES - Only one async logger can run at a time per PowerShell session - Use Write-EnhancedLogAsync to write to the async logger - Use Stop-AsyncLogger to properly shutdown the background worker - Automatically creates log directories if they don't exist #> function Start-AsyncLogger { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $false)] [string]$LogPath ) # Don't start if already running if ($script:AsyncLoggingEnabled) { Write-Warning "Async logging is already running" return } if ($PSCmdlet.ShouldProcess("Async Logger", "Start background logging")) { # Initialize log path if (-not $LogPath) { $callingScript = (Get-PSCallStack)[1].ScriptName if ($callingScript) { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($callingScript) $LogPath = "C:\temp\logs\$scriptName" } else { $LogPath = "C:\temp\logs\Action.Logging" } } # Ensure log directory exists (once) if (-not $script:VerifiedPaths.ContainsKey($LogPath)) { if (-not (Test-Path $LogPath)) { New-Item -ItemType Directory -Path $LogPath -Force | Out-Null } $script:VerifiedPaths[$LogPath] = $true } # Create session log file with script name prefix $folderName = Split-Path $LogPath -Leaf if ($folderName -eq "Action.Logging") { $logFileName = "ActionLogging_$(Get-Date -Format 'MMddyy-HHmmss').log" } else { $logFileName = "${folderName}_$(Get-Date -Format 'MMddyy-HHmmss').log" } $script:CurrentLogFile = "$LogPath\$logFileName" Clear-OldLog -LogPath $LogPath # Create runspace for background logging $script:LoggerRunspace = [RunspaceFactory]::CreateRunspace() $script:LoggerRunspace.ApartmentState = "STA" $script:LoggerRunspace.ThreadOptions = "ReuseThread" $script:LoggerRunspace.Open() # Create synchronized hashtable for shared state $syncHash = [hashtable]::Synchronized(@{ LogQueue = $script:LogQueue CurrentLogFile = $script:CurrentLogFile StopRequested = $false ConsoleOutputEnabled = $script:ConsoleOutputEnabled }) # Share synchronized hashtable with runspace $script:LoggerRunspace.SessionStateProxy.SetVariable("syncHash", $syncHash) # Create PowerShell instance for background work $script:LoggerPowerShell = [PowerShell]::Create() $script:LoggerPowerShell.Runspace = $script:LoggerRunspace # Define background worker script $workerScript = { $logBuffer = [System.Collections.Generic.List[string]]::new() $lastFlush = [DateTime]::Now $flushInterval = [TimeSpan]::FromMilliseconds(500) while (-not $syncHash.StopRequested) { $hasMessages = $false $message = $null # Collect messages from queue while ($syncHash.LogQueue.TryDequeue([ref]$message)) { $hasMessages = $true $logBuffer.Add($message.Entry) # Console output based on level if enabled if ($syncHash.ConsoleOutputEnabled) { switch ($message.Level) { 'ERROR' { Write-Host $message.Entry -ForegroundColor Red } 'WARN' { Write-Host $message.Entry -ForegroundColor Yellow } 'INFO' { Write-Host $message.Entry -ForegroundColor Green } 'DEBUG' { Write-Host $message.Entry -ForegroundColor Cyan } 'TRACE' { Write-Host $message.Entry -ForegroundColor Gray } } } } # Flush buffer to file periodically or when buffer is large $shouldFlush = $logBuffer.Count -gt 0 -and ( $logBuffer.Count -ge 50 -or ([DateTime]::Now - $lastFlush) -gt $flushInterval ) if ($shouldFlush) { try { Add-Content -Path $syncHash.CurrentLogFile -Value $logBuffer -Encoding UTF8 $logBuffer.Clear() $lastFlush = [DateTime]::Now } catch { Write-Warning "Async logger failed to write: $_" } } # Sleep briefly if no messages if (-not $hasMessages) { Start-Sleep -Milliseconds 50 } } # Final flush on shutdown if ($logBuffer.Count -gt 0) { try { Add-Content -Path $syncHash.CurrentLogFile -Value $logBuffer -Encoding UTF8 } catch { Write-Warning "Final flush failed: $_" } } } # Start background worker $null = $script:LoggerPowerShell.AddScript($workerScript) $null = $script:LoggerPowerShell.BeginInvoke() # Store sync hash reference $script:SyncHash = $syncHash $script:AsyncLoggingEnabled = $true Write-Host "Async logging started for $LogPath" -ForegroundColor Green } } <# .SYNOPSIS Stops the asynchronous logging background worker gracefully. .DESCRIPTION Properly shuts down the background PowerShell runspace used for async logging. Ensures all queued log entries are written before stopping and cleans up all resources including runspaces and PowerShell instances. .EXAMPLE Stop-AsyncLogger Stops the currently running async logger. .NOTES - Waits up to 5 seconds for remaining log entries to be processed - Properly disposes of PowerShell runspace and instances - Safe to call even if async logger is not running - Should always be called before script termination if async logging was used #> function Stop-AsyncLogger { [CmdletBinding(SupportsShouldProcess)] param() if (-not $script:AsyncLoggingEnabled) { return } if ($PSCmdlet.ShouldProcess("Async Logger", "Stop background logging")) { Write-Host "Stopping async logger..." -ForegroundColor Yellow # Signal stop $script:SyncHash.StopRequested = $true # Wait for queue to empty (max 5 seconds) $timeout = [DateTime]::Now.AddSeconds(5) while ($script:LogQueue.Count -gt 0 -and [DateTime]::Now -lt $timeout) { Start-Sleep -Milliseconds 100 } # Clean up resources if ($script:LoggerPowerShell) { $script:LoggerPowerShell.Stop() $script:LoggerPowerShell.Dispose() $script:LoggerPowerShell = $null } if ($script:LoggerRunspace) { $script:LoggerRunspace.Close() $script:LoggerRunspace.Dispose() $script:LoggerRunspace = $null } $script:AsyncLoggingEnabled = $false $script:LoggingStopRequested = $false Write-Host "Async logger stopped" -ForegroundColor Green } } <# .SYNOPSIS Writes log entries asynchronously using the background logging worker. .DESCRIPTION Queues log messages for asynchronous processing by the background worker. Provides high-performance logging by avoiding blocking I/O operations. Falls back to synchronous logging if async logger is not running. .PARAMETER Message The message to log. This parameter is mandatory. .PARAMETER Level The severity level of the log entry. Default is INFO. Valid values: ERROR, WARN, INFO, DEBUG, TRACE .EXAMPLE Write-EnhancedLogAsync -Message "Process completed successfully" Writes an INFO level message asynchronously. .EXAMPLE Write-EnhancedLogAsync -Message "Critical error occurred" -Level ERROR Writes an ERROR level message asynchronously. .NOTES - Requires Start-AsyncLogger to be called first - Falls back to Write-EnhancedLog if async logger not running - Messages are queued and written in batches for performance - Use Stop-AsyncLogger to ensure all messages are written before exit #> function Write-EnhancedLogAsync { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [LogLevel]$Level = [LogLevel]::INFO ) # Fall back to sync logging if async not enabled if (-not $script:AsyncLoggingEnabled) { Write-EnhancedLog -Message $Message -Level $Level return } $timestamp = Get-Date -Format 'MM-dd-yyyy HH:mm:ss' $logEntry = "$timestamp [$Level] $Message" # Queue the message $logMessage = [PSCustomObject]@{ Entry = $logEntry Level = $Level.ToString() } $script:LogQueue.Enqueue($logMessage) } <# .SYNOPSIS Controls whether log entries are displayed in the console. .DESCRIPTION Enables or disables console output for all logging functions in the module. When disabled, log entries are still written to files but not displayed in the console. Affects both synchronous and asynchronous logging. .PARAMETER Enabled Boolean value to enable (True) or disable (False) console output. This parameter is mandatory. .EXAMPLE Set-LoggingConsoleOutput -Enabled $false Disables console output for all logging functions. .EXAMPLE Set-LoggingConsoleOutput -Enabled $true Enables console output for all logging functions. .NOTES - Affects both Write-EnhancedLog and Write-EnhancedLogAsync functions - Changes are applied immediately to running async logger if active - Log files are still created regardless of console output setting - Useful for silent automation scenarios or reducing console noise #> function Set-LoggingConsoleOutput { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $true)] [bool]$Enabled ) if ($PSCmdlet.ShouldProcess("Console Output", "Set logging console output to $Enabled")) { $script:ConsoleOutputEnabled = $Enabled # Update async logger if running if ($script:AsyncLoggingEnabled -and $script:SyncHash) { $script:SyncHash.ConsoleOutputEnabled = $Enabled } $status = if ($Enabled) { "enabled" } else { "disabled" } Write-Verbose "Console output $status for logging module" } } <# .SYNOPSIS Sets the default log retention count for automatic cleanup. .DESCRIPTION Configures how many log files to keep when automatic cleanup occurs. This setting applies to all logging functions in the module. .PARAMETER Count Number of log files to keep. Must be between 5 and 100. Default is 10. .EXAMPLE Set-LogRetention -Count 25 Sets log retention to keep 25 most recent log files. .EXAMPLE Set-LogRetention -Count 5 Sets minimal log retention to keep only 5 most recent log files. #> function Set-LogRetention { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $false)] [ValidateRange(5, 100)] [int]$Count = 10 ) if ($PSCmdlet.ShouldProcess("Log Retention", "Set default log retention count to $Count")) { $script:DefaultLogRetentionCount = $Count Write-Verbose "Log retention count set to $Count files" } } # Export module functions and enum Export-ModuleMember -Function Write-EnhancedLog, Clear-OldLog, Start-AsyncLogger, Stop-AsyncLogger, Write-EnhancedLogAsync, Set-LoggingConsoleOutput, Set-LogRetention # Make LogLevel enum available in global scope for external access $global:LogLevel = [LogLevel] |