PowerShell Logging Framework: Structured Script Logging

A Write-Host statement is not a log. It leaves no file, has no timestamp, carries no severity level, and disappears when the console closes. Production automation scripts need a real PowerShell logging script framework: timestamps, severity levels, file output, and rotation. This post builds a reusable logging module from scratch that you can import into every script you write.
Quick Answer / TL;DR
Define a Write-Log function that accepts a message and severity, writes to both a file and the console, and stores state in module-level variables. Import the module at the top of every automation script.
Write-Log Function with Timestamp and Level
The core logging function writes a formatted line to a log file. The format includes an ISO8601 timestamp (UTC), severity level, and the caller function name for context. Using UTC timestamps prevents DST-related log ordering issues.
# Core Write-Log function
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Message,
[ValidateSet('INFO','WARN','ERROR','DEBUG')]
[string]$Level = 'INFO',
[string]$LogFile = $script:LogFilePath
)
$timestamp = (Get-Date -AsUTC -ErrorAction SilentlyContinue) ?? (Get-Date).ToUniversalTime()
$caller = (Get-PSCallStack)[1].Command
$logLine = "$($timestamp.ToString('yyyy-MM-ddTHH:mm:ssZ')) [$Level] [$caller] $Message"
# Write to file
if ($LogFile) {
Add-Content -Path $LogFile -Value $logLine -Encoding UTF8
}
$logLine # pass through for console display
}
Log to File and Console Simultaneously
Combine Write-Log with color-coded console output. Use the appropriate Write-* cmdlet for each severity level so PowerShell’s output streams are used correctly — errors go to the error stream, warnings to the warning stream, and informational messages to standard output.
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet('INFO','WARN','ERROR','DEBUG')]
[string]$Level = 'INFO',
[string]$LogFile = $script:LogFilePath
)
$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
$caller = (Get-PSCallStack)[1].Command
$line = "$ts [$Level] [$caller] $Message"
if ($LogFile) { Add-Content -Path $LogFile -Value $line -Encoding UTF8 }
switch ($Level) {
'ERROR' { Write-Error $line }
'WARN' { Write-Warning $line }
'DEBUG' { Write-Verbose $line }
default { Write-Host $line -ForegroundColor Cyan }
}
}
Log Rotation by Size or Date
Log rotation prevents log files from growing indefinitely. Rotate by size (archive when file exceeds a threshold) or by date (start a new file each day). The Initialize-Log function sets up the log file path and handles rotation at script startup.
function Initialize-Log {
[CmdletBinding()]
param(
[string]$LogDirectory = 'C:\Logs',
[string]$LogName = 'automation',
[int]$MaxSizeMB = 10,
[int]$RetainDays = 30
)
# Date-based log file name
$script:LogFilePath = Join-Path $LogDirectory "$LogName-$(Get-Date -Format 'yyyyMMdd').log"
New-Item -Path $LogDirectory -ItemType Directory -Force | Out-Null
# Size-based rotation: archive if over MaxSizeMB
if (Test-Path $script:LogFilePath) {
$sizeMB = (Get-Item $script:LogFilePath).Length / 1MB
if ($sizeMB -gt $MaxSizeMB) {
$archive = $script:LogFilePath -replace '\.log$', "_$(Get-Date -Format 'HHmmss').bak"
Rename-Item $script:LogFilePath $archive
}
}
# Purge old log files
Get-ChildItem $LogDirectory -Filter "$LogName-*.log" |
Where-Object CreationTime -lt (Get-Date).AddDays(-$RetainDays) |
Remove-Item -Force
Write-Log "Logging initialized. File: $script:LogFilePath" -Level INFO
}
Include Caller Function Name
Get-PSCallStack provides the current call stack. Index [1] (one level up from Write-Log itself) gives the calling function’s name. This context is invaluable when diagnosing issues in large scripts — you see exactly which function generated each log line.
# Caller context is automatically captured in Write-Log
function Invoke-DatabaseBackup {
Write-Log 'Starting database backup' -Level INFO
try {
# ... backup logic ...
Write-Log 'Backup completed successfully' -Level INFO
} catch {
Write-Log "Backup failed: $($_.Exception.Message)" -Level ERROR
}
}
# Log output will show:
# 2024-03-15T14:32:01Z [INFO] [Invoke-DatabaseBackup] Starting database backup
# 2024-03-15T14:32:45Z [INFO] [Invoke-DatabaseBackup] Backup completed successfully
Structured JSON Logging
For log aggregation systems like Elastic or Splunk, JSON-formatted logs are easier to parse and index. A JSON-logging variant of Write-Log produces machine-readable output that integrates with modern log management platforms.
function Write-JsonLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message,
[string]$Level = 'INFO',
[hashtable]$Properties = @{}
)
$entry = @{
timestamp = (Get-Date).ToUniversalTime().ToString('o')
level = $Level
message = $Message
host = $env:COMPUTERNAME
caller = (Get-PSCallStack)[1].Command
} + $Properties
$json = $entry | ConvertTo-Json -Compress
Add-Content -Path $script:LogFilePath -Value $json -Encoding UTF8
}
# Usage with extra context properties
Write-JsonLog -Message 'User created' -Level INFO `
-Properties @{ user = 'jsmith'; department = 'IT'; action = 'create' }
Import Logging Framework as Module
Save the logging functions in a .psm1 file and import it at the top of every automation script. This gives all scripts consistent logging behavior without copying code.
# Save as: C:\Modules\Logging\Logging.psm1
# Then in every automation script:
Import-Module C:\Modules\Logging\Logging.psm1 -Force
Initialize-Log -LogDirectory 'C:\Logs\MyScript' -LogName 'deployment'
Write-Log 'Script started' -Level INFO
try {
# ... main script logic ...
Write-Log 'Script completed successfully' -Level INFO
} catch {
Write-Log "Unhandled error: $($_.Exception.Message)" -Level ERROR
exit 1
} finally {
Write-Log 'Cleanup complete' -Level INFO
}
Common Errors and Fixes
- File locking issues when multiple script instances log simultaneously.
Add-Contentretries on file lock conflicts, but concurrent script instances can still collide. Append a process ID or unique GUID to the log file name for each instance, or use a mutex to serialize writes if shared log files are required. - JSON logging encoding must match when reading back. Write JSON log files with
-Encoding UTF8inAdd-Content. When reading back withGet-Content | ConvertFrom-Json, ensure the encoding is set correctly. Mismatched encoding (UTF-16 vs UTF-8) produces garbage characters in log parsing.
Related Cmdlets / See Also
Wrapping Up
A proper logging framework is a one-time investment that makes every subsequent script more maintainable and debuggable. Build the Write-Log and Initialize-Log functions into a module, import it in all scripts, and use structured JSON logging if you work with a log aggregation platform. Timestamps, severity levels, and caller context transform raw output into actionable diagnostic records.


