LDXLogging.psm1
|
function Write-Log { <# .SYNOPSIS Writes a formatted log entry to screen, log file, and/or syslog. .DESCRIPTION Write-Log is the central logging function of the LDXLogging module. It formats each entry with a timestamp and severity level, then dispatches it to any combination of: - Screen output (Tee) - A daily log file (<ScriptName>_yyyyMMdd.log) - A per-run log file (a new file for each script execution) - A syslog server (UDP, RFC 3164) - An alert email (triggered on Severity 'Critical') Log files are written to a 'logfiles' subfolder relative to the calling script unless a custom path is provided in LogFileParameters. IMPORTANT: Write-Log must be called from within a script, not from the interactive console. It relies on $MyInvocation.ScriptName to determine the script name and default log path. Use New-LogFileParameters once at the start of your script to configure logging behavior, then pass the resulting object to each Write-Log call via -LogFileParameters. When -LogFileParameters is omitted, output goes to both screen and a daily log file. .PARAMETER LogEntry The message text to log. .PARAMETER LogFile Full path to the log file. Overrides any path derived from LogFileParameters. Use New-LogFileCurrentRunName to generate a per-run file name automatically. .PARAMETER Severity Log level for the entry. Accepted values (RFC 5424 aligned): Emergency, Alert, Critical, Error, Warning, Notice, Info, Debug Defaults to 'Info'. .PARAMETER LogFileParameters A configuration object created by New-LogFileParameters. Controls all logging destinations and housekeeping behavior. .EXAMPLE # Minimal usage – screen output and a daily log file. Write-Log "Service started successfully" .EXAMPLE # Screen + daily log file + syslog + 90-day housekeeping + alert email on Critical. $LogParams = New-LogFileParameters -Tee -DailyLogFile -HouseKeeping -DaysToKeep 90 ` -Syslog -SyslogServer "syslog.example.com" -SyslogFacility 20 ` -AlertEmail "ops@example.com" -SMTPServer "smtp.example.com" -ReplyTo "noreply@example.com" Write-Log "Disk space below threshold" -Severity Critical -LogFileParameters $LogParams .EXAMPLE # Per-run log file: a new, uniquely named file is created every time the script executes. $LogParams = New-LogFileParameters -Tee $LogFile = New-LogFileCurrentRunName Write-Log "Processing started" -LogFileParameters $LogParams -LogFile $LogFile Write-Log "Processing finished" -LogFileParameters $LogParams -LogFile $LogFile .NOTES Log entry format: yyyy-MM-dd HH:mm:ss <Severity>: <Message> Syslog messages are sent over UDP port 514 (RFC 3164). #> Param( [Parameter(Mandatory = $false, ValueFromPipeline = $true)] [string] $LogEntry, [Parameter(Mandatory = $false)] [string] $LogFile, [Parameter(Mandatory = $false)] [string] [ValidateSet("Emergency","Alert","Critical","Error","Warning","Notice","Info","Debug")] $Severity = "info", [Parameter(Mandatory = $false)] [object] $LogFileParameters ) [bool]$LogTodo = $false [bool]$HouseKeepingTodo = $false $ScriptName = $MyInvocation.ScriptName if ($LogEntry) { if (!$ScriptName -and !($logfileParameters.screenOnly)) { Write-Output "This command has to run from a script, not from the command line" } else { $LogTodo = $true $LogEntryFormatted = (Set-LogEntryFormat -LogEntry $LogEntry -Severity $Severity) if ($LogFileParameters.LogFile) { $LogFile = $LogFileParameters.LogFile } if (!$LogFileParameters.ScreenOnly) { if (!$LogFile) { $BaseName=(Get-Item $ScriptName).BaseName if ($LogFileParameters.DailyLogFile -or !$LogFileParameters) { if (!$LogFileParameters.LogPath) { $WorkingDir = (Split-Path -Parent $ScriptName) $Logfile = (New-DailyLogfileName -WorkingDir $WorkingDir -BaseName $BaseName) } else { $Logfile = (New-CustomDailyLogfileName -LogPath $LogFileParameters.LogPath -BaseName $BaseName) } } } if ($Logfile) { Write-LogEntry2025 -LogEntry $LogEntryFormatted -LogFile $Logfile } } if ($LogFileParameters.Tee -or !$LogFileParameters -or $LogFileParameters.ScreenOnly) { Write-Tee -LogEntry $LogEntryFormatted } if ($LogFileParameters.Syslog -and $LogFileParameters.SyslogServer) { Write-SyslogEntry -LogEntry $LogEntry -SyslogFacility $LogFileParameters.SyslogFacility -Severity $Severity -SyslogServer $LogFileParameters.SyslogServer } if ($LogFileParameters.AlertEmail -and $Severity -eq 'Critical') { $Subject = $MyInvocation.Scriptname + " encountered an error" Send-Email -Subject $Subject -Body $LogEntryFormatted -EmailReceiver $LogFileParameters.AlertEmail -SMTPServer $LogFileParameters.SMTPServer -ReplyTo $LogFileParameters.ReplyTo } } } if ($LogFileParameters.HouseKeeping) { $HouseKeepingTodo = $true Invoke-LogFileHouseKeeping -DaysToKeep $LogFileParameters.DaysToKeep -RunsToKeep $LogFileParameters.RunsToKeep -LogFilesFolder (Split-Path $LogFile -Parent) } if (!$LogTodo -and !$HouseKeepingTodo) { write-output "Nothing to log, use get-help write-log for parameters." } } function New-LogFileParameters { <# .SYNOPSIS Creates a logging configuration object used by Write-Log. .DESCRIPTION New-LogFileParameters returns an LdxLogParameters object that encapsulates all logging settings for your script. Create this object once at the start of the script and pass it to every Write-Log call via the -LogFileParameters parameter. When no parameters are specified the object reflects the Write-Log defaults: screen output and a daily log file. Use this function when you need to change or extend that behavior. .PARAMETER Tee Echo log entries to the console in addition to the log file. .PARAMETER DailyLogFile Write entries to a daily log file named <ScriptBaseName>_yyyyMMdd.log placed in the 'logfiles' subfolder of the calling script, or in -LogPath if specified. .PARAMETER HouseKeeping Enable automatic deletion of old log files after each Write-Log call. Pair with -DaysToKeep, -RunsToKeep, or -MegaBytesToKeep. .PARAMETER DaysToKeep Remove log files older than this many days. Requires -HouseKeeping. .PARAMETER RunsToKeep Keep only the N most recent log files, deleting older ones. Requires -HouseKeeping. .PARAMETER MegaBytesToKeep Delete the oldest log files until the total folder size is at or below this limit (MB). Requires -HouseKeeping. .PARAMETER Syslog Forward log entries to a syslog server. Requires -SyslogServer. .PARAMETER SyslogServer Hostname or IP address of the syslog server (UDP port 514). .PARAMETER SyslogFacility RFC 3164 syslog facility code. Valid range: 16-23 (local0-local7). .PARAMETER AlertEmail Email address to notify when a log entry with Severity 'Critical' is written. Requires -SMTPServer. .PARAMETER SMTPServer SMTP relay host used for alert emails. .PARAMETER ReplyTo Reply-To address on alert emails. .PARAMETER LogPath Custom directory for daily log files, overriding the default 'logfiles' subfolder. The directory is created automatically if it does not exist. .PARAMETER LogFile Fixed log file path. All Write-Log calls using this parameter object write to this single file, regardless of date or script name. .PARAMETER ScreenOnly When set, log entries are written to the console only — no log file, syslog, or email alert is produced. Intended for interactive or diagnostic use where persistent logging is not required. .EXAMPLE # Screen + daily log file + syslog + 90-day housekeeping + Critical alert email. $LogParams = New-LogFileParameters -Tee -DailyLogFile -HouseKeeping -DaysToKeep 90 ` -Syslog -SyslogServer "syslog.example.com" -SyslogFacility 20 ` -AlertEmail "ops@example.com" -SMTPServer "smtp.example.com" -ReplyTo "noreply@example.com" .EXAMPLE # Screen + daily log file. Keep only the 20 most recent files. $LogParams = New-LogFileParameters -Tee -DailyLogFile -HouseKeeping -RunsToKeep 20 .EXAMPLE # Screen + daily log file written to a custom folder. Keep only the 20 most recent files. $LogParams = New-LogFileParameters -Tee -DailyLogFile -HouseKeeping -RunsToKeep 20 -LogPath "C:\Logs\MyApp" .EXAMPLE # Default behavior (screen + daily log file in the script's own logfiles subfolder). $LogParams = New-LogFileParameters .EXAMPLE # Console output only — no log file written. $LogParams = New-LogFileParameters -ScreenOnly Write-Log "Dry-run mode active" -LogFileParameters $LogParams #> param( [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [switch] $Tee, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [switch] $DailyLogFile, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [switch] $HouseKeeping, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [int] $DaysToKeep, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [int] $RunsToKeep, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [int] $MegaBytesToKeep, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [switch] $Syslog, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateRange(16,23)] [int] $SyslogFacility, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $AlertEmail, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $SMTPServer, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $ReplyTo, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $SyslogServer, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $LogPath, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string] $LogFile, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [switch] $ScreenOnly ) class LdxLogParameters { [bool]$Tee [bool]$DailyLogFile [bool]$HouseKeeping [int]$DaysToKeep [int]$RunsToKeep [int]$MegaBytesToKeep [bool]$Syslog [int]$SyslogFacility [string]$AlertEmail [string]$SMTPServer [string]$ReplyTo [string]$SyslogServer [string]$LogPath [string]$LogFile [bool]$ScreenOnly } $Parameters = New-Object LdxLogParameters $Parameters.Tee = $Tee $Parameters.DailyLogFile = $DailyLogFile $Parameters.HouseKeeping = $HouseKeeping $Parameters.DaysToKeep = $DaysToKeep $Parameters.RunsToKeep = $RunsToKeep $Parameters.MegaBytesToKeep = $MegaBytesToKeep $Parameters.Syslog = $Syslog $Parameters.SyslogFacility = $SyslogFacility $Parameters.AlertEmail = $AlertEmail $Parameters.SMTPServer = $SMTPServer $Parameters.ReplyTo = $ReplyTo $Parameters.SyslogServer = $SyslogServer $Parameters.LogPath = $LogPath $Parameters.LogFile = $LogFile $Parameters.ScreenOnly = $ScreenOnly Return $Parameters } Function New-LogFileCurrentRunName { <# .SYNOPSIS Returns a unique log file path for the current script execution. .DESCRIPTION Generates a log file path in the form: <ScriptFolder>\logfiles\<ScriptBaseName>_yyyyMMdd-HHmmss.log The timestamp (to the nearest second) ensures a distinct file for every run. Pass the result to Write-Log via -LogFile to capture each execution in its own file. Must be called from within a script; it relies on $MyInvocation.ScriptName to determine the script name and folder. .EXAMPLE # Action.ps1 in C:\Scripts, called at 14:20:31 on 2020-03-31: # Result: C:\Scripts\logfiles\Action_20200331-142031.log $LogParams = New-LogFileParameters -Tee $LogFile = New-LogFileCurrentRunName Write-Log "Processing started" -LogFileParameters $LogParams -LogFile $LogFile Write-Log "Processing finished" -LogFileParameters $LogParams -LogFile $LogFile #> $ScriptName = $MyInvocation.ScriptName $WorkingDir=(Split-Path -Parent $ScriptName) $BaseName=(Get-Item $ScriptName).BaseName $LogfileName = $BaseName + "_" + (get-date -Format "yyyyMMdd-HHmmss") + ".log" return (Join-Path -Path (Get-LogFilesFolder $WorkingDir) -ChildPath $LogfileName) } function Get-CurrentDailyLogfile { <# .SYNOPSIS Returns the full path of today's log file for the currently running script. .DESCRIPTION Derives the current daily log file path using the calling script's name and folder: <ScriptFolder>\logfiles\<ScriptBaseName>_yyyyMMdd.log Use this to retrieve the active log file path after logging has started — for example, to attach it to a notification email or archive it when processing completes. Must be called from within a script; it relies on $MyInvocation.ScriptName. .EXAMPLE # Retrieve today's log and attach it to a summary email. $LogFile = Get-CurrentDailyLogfile Send-AzEmail -From "noreply@example.com" -To @("ops@example.com") ` -Subject "Daily log" -Body (Get-Content $LogFile -Raw) -Credentials $cred #> $ScriptName = $MyInvocation.ScriptName $WorkingDir = (Split-Path -Parent $ScriptName) $BaseName=(Get-Item $ScriptName).BaseName return New-DailyLogfileName -WorkingDir $WorkingDir -BaseName $BaseName } Function New-DailyLogfileName { Param( [Parameter(Mandatory = $true)] [string] $WorkingDir, [Parameter(Mandatory = $true)] [string] $BaseName ) $LogfileName = $BaseName + "_" + (get-date -Format "yyyyMMdd") + ".log" return (Join-Path -Path (Get-LogFilesFolder $WorkingDir) -ChildPath $LogfileName) } Function New-DailyLogFileComputername { <# .SYNOPSIS Returns a daily log file name based on the computer name. .DESCRIPTION Generates a log file name in the form: <COMPUTERNAME>_yyyyMMdd.log Useful when multiple scripts on the same host should share a single daily log file identified by the machine name rather than the script name. Unlike New-LogFileCurrentRunName, this function returns a file name only (no path). Combine it with a log folder path as required. .EXAMPLE # On a server named SRV01, called on 2025-06-07: # Result: SRV01_20250607.log $LogFile = Join-Path "C:\Logs" (New-DailyLogFileComputername) Write-Log "Scheduled task completed" -LogFile $LogFile #> $LogfileName = $ENV:COMPUTERNAME + "_" + (get-date -Format "yyyyMMdd") + ".log" return $LogfileName } function New-CustomDailyLogfileName { param ( [Parameter(Mandatory = $true)] [string] $LogPath, [Parameter(Mandatory = $true)] [string] $BaseName ) $LogfileName = $BaseName + "_" + (get-date -Format "yyyyMMdd") + ".log" if (!(Test-Path -Path $LogPath)) { New-Item -Path $LogPath -ItemType Directory -Force } return (Join-Path -Path $LogPath -ChildPath $LogfileName) } Function Get-LogFilesFolder { Param( [Parameter(Mandatory = $true)] [string] $WorkingDir ) $LogFilesFolder=(Join-Path -Path $WorkingDir -ChildPath "logfiles") if (!(test-path -path $LogFilesFolder)) { New-Item -ItemType Directory -Path $LogFilesFolder | Out-Null } return $LogFilesFolder } function Write-SyslogEntry { Param( [Parameter(Mandatory = $true)] [string] $LogEntry, [Parameter(Mandatory = $true)] [int] [ValidateRange(16,23)] $SyslogFacility, [Parameter(Mandatory = $false)] [string] $Severity="Info", [Parameter(Mandatory = $true)] [string] $SyslogServer ) [string[]]$LogLevel = "emergency", "alert", "critical", "error", "warning", "notice", "info", "debug" [int]$SeverityLevel = [array]::IndexOf($LogLevel,$Severity.ToLower()) if ($SeverityLevel -eq -1) { $SeverityLevel = 6 } [int]$Facility = $SyslogFacility * 8 $SyslogCode = $Facility + $SeverityLevel [string]$SyslogMsg = ("<" + $SyslogCode + ">"),":" + $LogEntry [byte[]]$RawMsg=[System.Text.Encoding]::ASCII.GetBytes($SyslogMsg) $UDPCLient = New-Object System.Net.Sockets.UdpClient $UDPCLient.Connect($SyslogServer, '514') $UDPCLient.Send($RawMsg, $rawmsg.Length) | Out-Null $UDPCLient.Close() $UDPCLient.Dispose() } function Write-LogEntryLogFile-Notinuse { Param( [Parameter(Mandatory = $true)] [string] $LogEntry, [Parameter(Mandatory = $true)] [string] $LogFile ) Add-Content -Value $LogEntry -Path $LogFile -Force } function Write-LogEntry2025 { Param( [Parameter(Mandatory = $true)] [string]$LogEntry, [Parameter(Mandatory = $true)] [string]$LogFile ) $stream = $null $writer = $null try { # Open with shared read access so Get-Content can read while writing $stream = [System.IO.File]::Open( $LogFile, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read ) $writer = New-Object System.IO.StreamWriter($stream) $writer.WriteLine($LogEntry) $writer.Flush() # ← Force data to disk immediately } finally { if ($writer) { $writer.Close() } if ($stream) { $stream.Close() } } } function Send-Email { Param( [Parameter(Mandatory = $true)] [string] $Subject, [Parameter(Mandatory = $true)] [string] $Body, [Parameter(Mandatory = $true)] [string] $EmailReceiver, [Parameter(Mandatory = $true)] [string] $SMTPServer, [Parameter(Mandatory = $true)] [string] $ReplyTo ) $msg = new-object Net.Mail.MailMessage $smtp = new-object Net.Mail.SmtpClient($smtpServer) $msg.From = "$ENV:computername@lindex.com" $msg.ReplyTo = $ReplyTo $msg.To.Add($EmailReceiver) $msg.subject = $Subject $msg.body = $Body $smtp.Send($msg) } function Write-Tee { Param( [Parameter(Mandatory = $true)] [string] $LogEntry ) Write-Output $LogEntry } function Set-LogEntryFormat { Param( [Parameter(Mandatory = $true)] [string] $LogEntry, [Parameter(Mandatory = $false)] [string] $Severity ) if ($Severity) { $SeverityEntry = $Severity + ": " } return (get-date -Format "yyyy-MM-dd HH:mm:ss") + " " + $SeverityEntry + $LogEntry } Function Invoke-LogFileHouseKeeping { <# .SYNOPSIS Removes old log files from a folder based on age, count, or total size. .DESCRIPTION Cleans up a log folder using one of three strategies: -DaysToKeep Delete files last-written more than N days ago. -RunsToKeep Keep only the N most recently modified files; delete the rest. -MegaBytesToKeep Delete the oldest files until the folder is at or below the size limit. Only one strategy is applied per call. When invoked automatically via Write-Log and New-LogFileParameters, housekeeping runs after every log entry. Use -List to preview which files would be removed without actually deleting them. .PARAMETER LogFilesFolder Full path to the folder containing log files to evaluate. .PARAMETER Recurse Include files in subfolders when evaluating and deleting. .PARAMETER DaysToKeep Delete log files whose last-write time is older than this many days. .PARAMETER RunsToKeep Retain only the N most recently modified files. Older files are deleted. .PARAMETER MegaBytesToKeep Delete the oldest files until the total folder size is at or below this value (MB). .PARAMETER List Preview mode. Lists the files that would be removed without deleting them. .EXAMPLE # Delete log files older than 30 days. Invoke-LogFileHouseKeeping -LogFilesFolder "C:\Scripts\logfiles" -DaysToKeep 30 .EXAMPLE # Keep only the 10 most recent log files. Invoke-LogFileHouseKeeping -LogFilesFolder "C:\Scripts\logfiles" -RunsToKeep 10 .EXAMPLE # Preview which files would be removed to bring the folder under 50 MB. Invoke-LogFileHouseKeeping -LogFilesFolder "C:\Scripts\logfiles" -MegaBytesToKeep 50 -List #> Param( [Parameter(Mandatory = $true)] [string] $LogFilesFolder, [Parameter(Mandatory = $false)] [switch] $Recurse, [Parameter(Mandatory = $false)] [int] $DaysToKeep, [Parameter(Mandatory = $false)] [int] $RunsToKeep, [Parameter(Mandatory = $false)] [int] $MegaBytesToKeep, [Parameter(Mandatory = $false)] [switch] $List ) if ($DaysToKeep) { $files = Get-ChildItem -Path $LogFilesFolder -File -Recurse:$Recurse | Where-Object {$_.lastwritetime -le (get-date).AddDays(-$DaysToKeep)} if ($List) { $files } else { $files | remove-item -Force } } if ($RunsToKeep) { $files = (Get-ChildItem -Path $LogFilesFolder -Recurse:$Recurse | Sort-Object LastWriteTime | Select-Object -first ((Get-ChildItem -Path $LogFilesFolder | Measure-Object).count -$RunsToKeep)) if ($List) { $files } else { $files | remove-item -Force } } if ($MegaBytesToKeep) { $ActualMegaBytesToKeep = $MegaBytesToKeep * 1024 * 1024 $files = Get-ChildItem -Path $LogFilesFolder -Recurse:$false | Sort-Object LastWriteTime $sum = (($files | Measure-Object -Sum Length).Sum) $n=0 while (($sum -gt $ActualMegaBytesToKeep) -or ($n -gt $files.count)) { $sum = $sum - $files[$n].Length if ($List) { $files[$n] } else { $files[$n] | remove-item -Force } $n++ } } } function Send-AzEmail { <# .SYNOPSIS Sends an email via an Azure Communication Services SMTP relay. .DESCRIPTION A wrapper around System.Net.Mail.SmtpClient pre-configured for Azure Communication Services (smtp.azurecomm.net, port 587, TLS enabled). Supply credentials as a PSCredential object. The sender address must be a verified sender domain registered in your Azure Communication Services resource. .PARAMETER SMTPHost SMTP relay hostname. Defaults to 'smtp.azurecomm.net'. .PARAMETER SMTPPort SMTP port. Defaults to 587. .PARAMETER EnableSsl Use TLS for the SMTP connection. Defaults to $true. .PARAMETER From Sender email address. Must match a verified sender domain in Azure Communication Services. .PARAMETER To Array of recipient email addresses. .PARAMETER Subject Email subject line. .PARAMETER Body Email body text (plain text or HTML depending on -IsBodyHTML). .PARAMETER IsBodyHTML Set to $true to send the body as HTML. Defaults to $false. .PARAMETER Credentials PSCredential object containing the Azure Communication Services SMTP username and password. .EXAMPLE $cred = Get-Credential Send-AzEmail -From "noreply@example.com" -To @("ops@example.com") ` -Subject "Alert" -Body "Disk space low" -Credentials $cred .EXAMPLE # Send an HTML email using stored credentials. $cred = Import-Clixml "C:\Secure\smtp-cred.xml" Send-AzEmail -From "noreply@example.com" -To @("team@example.com","mgr@example.com") ` -Subject "Report ready" -Body "<h1>Done</h1>" -IsBodyHTML $true -Credentials $cred #> Param( [Parameter(Mandatory = $false)] [string] $SMTPHost = 'smtp.azurecomm.net', [Parameter(Mandatory = $false)] [int] $SMTPPort = 587, [Parameter(Mandatory = $false)] [bool] $EnableSsl = $true, [Parameter(Mandatory = $true)] [string] $From, [Parameter(Mandatory = $true)] [array] $To, [Parameter(Mandatory = $true)] [string] $Subject, [Parameter(Mandatory = $true)] [string] $Body, [Parameter(Mandatory = $false)] [bool] $IsBodyHTML = $false, [parameter(Mandatory = $true)] [pscredential] $Credentials ) $smtp = new-object Net.Mail.SmtpClient $smtp.Credentials = $Credentials $smtp.EnableSsl = $EnableSsl $smtp.host = $SMTPHost $smtp.Port = $SMTPPort $msg = new-object Net.Mail.MailMessage $msg.Subject = $Subject $msg.Body = $Body $msg.From = $From $msg.replyTo = $From $msg.IsBodyHtml = $IsBodyHTML foreach($Receiver in $To) { $msg.To.add($Receiver) } $smtp.send($msg) } |