PowerShell Logging Framework: Structured Script Logging

PowerShell Logging Framework: Structured Script Logging

PowerShell Tips Editor 3 min read
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-Content retries 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 UTF8 in Add-Content. When reading back with Get-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.

Send-Item -To