Skip to content

Logging

Note

Learn how to implement effective logging in PowerShell scripts - from simple console output to production-ready log files.

Overview

Logging is critical for troubleshooting, auditing, and monitoring your PowerShell scripts. Good logging helps you understand what your script did, when it did it, and what went wrong.

Why logging matters:

  • Troubleshooting - Understand failures after they happen
  • Auditing - Track who did what and when
  • Monitoring - Detect issues in scheduled scripts
  • Debugging - Understand script flow during development
  • Compliance - Meet regulatory requirements

This guide covers everything from simple console messages to production-ready logging systems.

Understanding PowerShell Output Streams

PowerShell has six output streams, each for a different purpose:

Stream Cmdlet Purpose Captured by $? In Variables
Success (1) Write-Output Normal output ✅ Yes ✅ Yes
Error (2) Write-Error Error messages ❌ No ✅ Yes ($Error)
Warning (3) Write-Warning Warning messages ✅ Yes ❌ No
Verbose (4) Write-Verbose Detailed info ✅ Yes ❌ No
Debug (5) Write-Debug Debug info ✅ Yes ❌ No
Information (6) Write-Information Informational ✅ Yes ✅ Yes (PS 5.0+)
Host (N/A) Write-Host Display only ✅ Yes ❌ No

Quick Comparison

# Write-Output - Goes to pipeline (can be captured)
Write-Output "Processing data..."
$result = Get-Data  # Can capture output

# Write-Host - Display only (cannot be captured)
Write-Host "Processing data..." -ForegroundColor Green
$result = Get-Data  # Cannot capture Write-Host output

# Write-Verbose - Optional detailed messages
Write-Verbose "Looking up user in database..."  # Only shows with -Verbose

# Write-Warning - Warning messages
Write-Warning "File size exceeds 1GB"

# Write-Error - Error messages
Write-Error "Failed to connect to server"

# Write-Information - Informational messages (PS 5.0+)
Write-Information "Backup completed: 150 files" -InformationAction Continue

Write-Host vs Write-Output vs Others

Write-Host (Display Only)

Use for: Progress messages, colored output, user-facing text

# Good uses of Write-Host
Write-Host "=== Starting Backup Process ===" -ForegroundColor Cyan
Write-Host "Progress: 25%" -ForegroundColor Yellow
Write-Host "SUCCESS: Backup complete!" -ForegroundColor Green

# Why it's useful
Write-Host "Processing..." -NoNewline
Start-Sleep -Seconds 2
Write-Host " Done!" -ForegroundColor Green

⚠️ Important: Write-Host output cannot be captured, redirected, or piped!

# This DOESN'T work
function Get-Data {
    Write-Host "Here's your data"  # Goes to console only
}

$result = Get-Data  # $result is EMPTY!

# This DOES work
function Get-Data {
    Write-Output "Here's your data"  # Goes to pipeline
}

$result = Get-Data  # $result contains the string

Write-Output (Pipeline Output)

Use for: Data you want to return, output that can be piped or captured

function Get-SystemInfo {
    # Returns object - can be captured/piped
    [PSCustomObject]@{
        ComputerName = $env:COMPUTERNAME
        OS = (Get-CimInstance Win32_OperatingSystem).Caption
        Uptime = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
    }
}

# Can capture output
$info = Get-SystemInfo

# Can pipe output
Get-SystemInfo | Export-Csv system.csv

Best practice: Don't use Write-Output explicitly - just output the object:

# Unnecessary
function Get-Name {
    Write-Output "John"
}

# Better (implicit output)
function Get-Name {
    "John"
}

Write-Verbose (Optional Details)

Use for: Detailed information that's normally hidden

function Get-UserData {
    [CmdletBinding()]  # Required for -Verbose to work
    param([string]$Username)

    Write-Verbose "Connecting to Active Directory..."
    Write-Verbose "Looking up user: $Username"

    $user = Get-ADUser -Identity $Username

    Write-Verbose "Found user: $($user.Name)"
    Write-Verbose "Email: $($user.EmailAddress)"

    return $user
}

# Normal call (verbose messages hidden)
Get-UserData -Username "jdoe"

# With -Verbose (shows all messages)
Get-UserData -Username "jdoe" -Verbose

Write-Warning (Warnings)

Use for: Non-fatal issues that need attention

function Backup-Files {
    param([string]$Path)

    $files = Get-ChildItem -Path $Path

    if ($files.Count -gt 10000) {
        Write-Warning "Large backup detected: $($files.Count) files. This may take a while."
    }

    # Continue with backup...
}

Write-Error (Errors)

Use for: Error messages (doesn't stop execution unless ErrorActionPreference is Stop)

function Get-Data {
    param([string]$Path)

    if (-not (Test-Path $Path)) {
        Write-Error "File not found: $Path"
        return
    }

    # Continue processing...
}

Write-Information (Informational)

Use for: Informational messages that can be captured (PowerShell 5.0+)

function Process-Data {
    [CmdletBinding()]
    param()

    Write-Information "Starting data processing..." -InformationAction Continue

    # Process data...

    Write-Information "Processed 150 records" -InformationAction Continue
}

# Can capture information stream
$info = Process-Data 6>&1  # Redirect information stream

Simple Logging Patterns

Basic File Logging

# Simple log entry
$logFile = "C:\Logs\script.log"
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $logFile -Value "[$timestamp] Script started"

# With error handling
try {
    # Your code here
    Add-Content -Path $logFile -Value "[$timestamp] Processing complete"
}
catch {
    Add-Content -Path $logFile -Value "[$timestamp] ERROR: $_"
}

Console + File Logging

$logFile = "C:\Logs\backup.log"

function Write-Log {
    param([string]$Message)

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logEntry = "[$timestamp] $Message"

    # Write to console
    Write-Host $logEntry

    # Write to file
    Add-Content -Path $logFile -Value $logEntry
}

# Usage
Write-Log "Starting backup process"
Write-Log "Backing up 150 files"
Write-Log "Backup complete"

Log Levels

$logFile = "C:\Logs\script.log"

function Write-Log {
    param(
        [string]$Message,
        [ValidateSet("INFO", "WARNING", "ERROR")]
        [string]$Level = "INFO"
    )

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logEntry = "[$timestamp] [$Level] $Message"

    # Color-coded console output
    $color = switch ($Level) {
        "INFO"    { "White" }
        "WARNING" { "Yellow" }
        "ERROR"   { "Red" }
    }

    Write-Host $logEntry -ForegroundColor $color
    Add-Content -Path $logFile -Value $logEntry
}

# Usage
Write-Log "Script started" -Level INFO
Write-Log "Low disk space detected" -Level WARNING
Write-Log "Failed to connect to database" -Level ERROR

Creating a Reusable Logging Function

Production-Ready Write-Log Function

function Write-Log {
    <#
    .SYNOPSIS
    Writes log messages to console and file with timestamps and levels

    .PARAMETER Message
    The message to log

    .PARAMETER Level
    Log level: INFO, WARNING, ERROR, DEBUG

    .PARAMETER LogFile
    Path to log file. If not specified, uses default location.

    .PARAMETER NoConsole
    Skip console output (file only)

    .EXAMPLE
    Write-Log "Process started"

    .EXAMPLE
    Write-Log "Disk space low" -Level WARNING

    .EXAMPLE
    Write-Log "Connection failed" -Level ERROR -LogFile "C:\Logs\custom.log"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [string]$Message,

        [ValidateSet("INFO", "WARNING", "ERROR", "DEBUG")]
        [string]$Level = "INFO",

        [string]$LogFile = "C:\Logs\PowerShell_$(Get-Date -Format 'yyyyMMdd').log",

        [switch]$NoConsole
    )

    process {
        # Create log directory if it doesn't exist
        $logDir = Split-Path $LogFile
        if (-not (Test-Path $logDir)) {
            New-Item -Path $logDir -ItemType Directory -Force | Out-Null
        }

        # Format log entry
        $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        $logEntry = "[$timestamp] [$Level] $Message"

        # Write to file
        try {
            Add-Content -Path $LogFile -Value $logEntry -ErrorAction Stop
        }
        catch {
            Write-Warning "Failed to write to log file: $_"
        }

        # Write to console (unless NoConsole specified)
        if (-not $NoConsole) {
            $color = switch ($Level) {
                "INFO"    { "White" }
                "WARNING" { "Yellow" }
                "ERROR"   { "Red" }
                "DEBUG"   { "Gray" }
            }

            Write-Host $logEntry -ForegroundColor $color
        }
    }
}

Using the Logging Function in Scripts

# At the start of your script
$logFile = "C:\Logs\MyScript_$(Get-Date -Format 'yyyyMMdd').log"

Write-Log "======================================" -LogFile $logFile
Write-Log "Script: MyScript.ps1" -LogFile $logFile
Write-Log "Started: $(Get-Date)" -LogFile $logFile
Write-Log "User: $env:USERNAME" -LogFile $logFile
Write-Log "Computer: $env:COMPUTERNAME" -LogFile $logFile
Write-Log "======================================" -LogFile $logFile

try {
    Write-Log "Processing data..." -LogFile $logFile

    # Your code here

    Write-Log "Processing complete" -LogFile $logFile
}
catch {
    Write-Log "Script failed: $_" -Level ERROR -LogFile $logFile
    Write-Log $_.ScriptStackTrace -Level DEBUG -LogFile $logFile
    exit 1
}
finally {
    Write-Log "Script ended" -LogFile $logFile
}

Advanced Logging Patterns

Structured Logging (JSON Format)

function Write-StructuredLog {
    param(
        [string]$Message,
        [string]$Level = "INFO",
        [hashtable]$Properties = @{}
    )

    $logEntry = [PSCustomObject]@{
        Timestamp = Get-Date -Format "o"  # ISO 8601 format
        Level = $Level
        Message = $Message
        Computer = $env:COMPUTERNAME
        User = $env:USERNAME
        ProcessId = $PID
        Properties = $Properties
    }

    $json = $logEntry | ConvertTo-Json -Compress
    Add-Content -Path "C:\Logs\structured.json" -Value $json
}

# Usage
Write-StructuredLog -Message "User login" -Properties @{
    Username = "jdoe"
    IPAddress = "192.168.1.100"
    Success = $true
}

Write-StructuredLog -Message "File processed" -Properties @{
    FileName = "data.csv"
    RecordCount = 1500
    Duration = "00:02:15"
}

Log Rotation

function Start-LogRotation {
    param(
        [string]$LogPath,
        [int]$MaxSizeMB = 10,
        [int]$KeepCount = 5
    )

    if (-not (Test-Path $LogPath)) {
        return
    }

    $logFile = Get-Item $LogPath
    $sizeMB = [math]::Round($logFile.Length / 1MB, 2)

    if ($sizeMB -ge $MaxSizeMB) {
        Write-Verbose "Log file is ${sizeMB}MB, rotating..."

        # Create archive name with timestamp
        $archiveName = "{0}_{1}{2}" -f
            [IO.Path]::GetFileNameWithoutExtension($LogPath),
            (Get-Date -Format "yyyyMMdd_HHmmss"),
            [IO.Path]::GetExtension($LogPath)

        $archivePath = Join-Path (Split-Path $LogPath) "Archive"

        if (-not (Test-Path $archivePath)) {
            New-Item -Path $archivePath -ItemType Directory | Out-Null
        }

        # Move current log to archive
        Move-Item -Path $LogPath -Destination (Join-Path $archivePath $archiveName)

        # Clean up old archives
        Get-ChildItem -Path $archivePath |
            Sort-Object CreationTime -Descending |
            Select-Object -Skip $KeepCount |
            Remove-Item -Force

        Write-Verbose "Log rotated successfully"
    }
}

# Use before logging
Start-LogRotation -LogPath "C:\Logs\script.log" -MaxSizeMB 10 -KeepCount 5
Write-Log "Starting script"

Transcript Logging

# Automatic transcript of entire session
$transcriptPath = "C:\Logs\Transcript_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"

try {
    Start-Transcript -Path $transcriptPath

    # Your script here
    Write-Host "This goes to transcript"
    Get-Process | Select-Object -First 5

    Stop-Transcript
}
catch {
    Stop-Transcript
    throw
}

Log File Management

Automatic Cleanup Function

function Remove-OldLogs {
    <#
    .SYNOPSIS
    Removes log files older than specified days

    .EXAMPLE
    Remove-OldLogs -LogPath "C:\Logs" -DaysToKeep 30
    #>

    param(
        [string]$LogPath = "C:\Logs",
        [int]$DaysToKeep = 30,
        [string]$Filter = "*.log"
    )

    $cutoffDate = (Get-Date).AddDays(-$DaysToKeep)

    Get-ChildItem -Path $LogPath -Filter $Filter -Recurse |
        Where-Object { $_.LastWriteTime -lt $cutoffDate } |
        ForEach-Object {
            Write-Verbose "Removing old log: $($_.Name)"
            Remove-Item -Path $_.FullName -Force
        }
}

# Usage
Remove-OldLogs -LogPath "C:\Logs" -DaysToKeep 30 -Verbose

Compress Old Logs

function Compress-OldLogs {
    param(
        [string]$LogPath = "C:\Logs",
        [int]$DaysOld = 7
    )

    $cutoffDate = (Get-Date).AddDays(-$DaysOld)

    $oldLogs = Get-ChildItem -Path $LogPath -Filter "*.log" |
        Where-Object { $_.LastWriteTime -lt $cutoffDate }

    foreach ($log in $oldLogs) {
        $zipName = "$($log.BaseName)_$($log.LastWriteTime.ToString('yyyyMMdd')).zip"
        $zipPath = Join-Path $LogPath $zipName

        if (-not (Test-Path $zipPath)) {
            Compress-Archive -Path $log.FullName -DestinationPath $zipPath
            Remove-Item -Path $log.FullName
            Write-Verbose "Compressed: $($log.Name) -> $zipName"
        }
    }
}

Complete Logging Example

Here's a production-ready script with comprehensive logging:

<#
.SYNOPSIS
Production script with comprehensive logging
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)]
    [string]$SourcePath,

    [string]$LogPath = "C:\Logs"
)

# --- Configuration ---
$ErrorActionPreference = "Stop"
$scriptName = [IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name)
$logFile = Join-Path $LogPath "${scriptName}_$(Get-Date -Format 'yyyyMMdd').log"

# --- Logging Function ---
function Write-Log {
    param(
        [Parameter(Mandatory=$true)]
        [string]$Message,

        [ValidateSet("INFO", "WARNING", "ERROR", "DEBUG")]
        [string]$Level = "INFO"
    )

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $logEntry = "[$timestamp] [$Level] $Message"

    # Ensure log directory exists
    $logDir = Split-Path $logFile
    if (-not (Test-Path $logDir)) {
        New-Item -Path $logDir -ItemType Directory -Force | Out-Null
    }

    # Write to file
    Add-Content -Path $logFile -Value $logEntry

    # Write to console with color
    $color = switch ($Level) {
        "INFO"    { "White" }
        "WARNING" { "Yellow" }
        "ERROR"   { "Red" }
        "DEBUG"   { "Gray" }
    }

    Write-Host $logEntry -ForegroundColor $color
}

# --- Main Script ---
try {
    # Log startup
    Write-Log "========================================" -Level INFO
    Write-Log "Script Started: $scriptName" -Level INFO
    Write-Log "User: $env:USERNAME" -Level INFO
    Write-Log "Computer: $env:COMPUTERNAME" -Level INFO
    Write-Log "PowerShell: $($PSVersionTable.PSVersion)" -Level INFO
    Write-Log "Parameters:" -Level INFO
    Write-Log "  SourcePath: $SourcePath" -Level INFO
    Write-Log "  LogPath: $LogPath" -Level INFO
    Write-Log "========================================" -Level INFO

    # Validate prerequisites
    Write-Log "Validating prerequisites..." -Level INFO

    if (-not (Test-Path $SourcePath)) {
        throw "Source path not found: $SourcePath"
    }
    Write-Log "Source path validated" -Level INFO

    # Main processing
    Write-Log "Starting main processing..." -Level INFO

    $files = Get-ChildItem -Path $SourcePath -File
    Write-Log "Found $($files.Count) files to process" -Level INFO

    $processedCount = 0
    $errorCount = 0

    foreach ($file in $files) {
        try {
            Write-Log "Processing: $($file.Name)" -Level DEBUG

            # Your processing logic here
            # For example:
            # Process-File -Path $file.FullName

            $processedCount++
        }
        catch {
            $errorCount++
            Write-Log "Failed to process $($file.Name): $_" -Level ERROR
            Write-Log $_.ScriptStackTrace -Level DEBUG
        }
    }

    # Summary
    Write-Log "========================================" -Level INFO
    Write-Log "Processing Summary:" -Level INFO
    Write-Log "  Total Files: $($files.Count)" -Level INFO
    Write-Log "  Processed: $processedCount" -Level INFO
    Write-Log "  Errors: $errorCount" -Level INFO

    if ($errorCount -gt 0) {
        Write-Log "Script completed with errors" -Level WARNING
        exit 1
    }
    else {
        Write-Log "Script completed successfully" -Level INFO
        exit 0
    }
}
catch {
    Write-Log "FATAL ERROR: $_" -Level ERROR
    Write-Log $_.ScriptStackTrace -Level DEBUG
    Write-Log "Script terminated abnormally" -Level ERROR
    exit 1
}
finally {
    Write-Log "Script ended: $(Get-Date)" -Level INFO
    Write-Log "Log file: $logFile" -Level INFO
}

Tips & Tricks

Use Log Levels Consistently

# INFO - Normal operations
Write-Log "Backup started" -Level INFO
Write-Log "Processed 150 files" -Level INFO

# WARNING - Issues that don't stop execution
Write-Log "Disk space low: 5GB remaining" -Level WARNING
Write-Log "File already exists, skipping" -Level WARNING

# ERROR - Errors that need attention
Write-Log "Failed to connect to database" -Level ERROR
Write-Log "Access denied to folder" -Level ERROR

# DEBUG - Detailed troubleshooting (only when needed)
Write-Log "SQL Query: SELECT * FROM..." -Level DEBUG
Write-Log "API Response: {json}" -Level DEBUG

Always Create Log Directory First

function Write-Log {
param([string]$Message, [string]$LogFile)

# Create directory if it doesn't exist
$logDir = Split-Path $LogFile
if (-not (Test-Path $logDir)) {
    New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}

Add-Content -Path $LogFile -Value $Message
}

# Now safe to call without worrying about path
Write-Log "Started" "C:\Logs\SubFolder\app.log"

Mask Sensitive Data

# GOOD - Mask passwords and keys
$username = "jdoe"
Write-Log "Connecting as user: $username"
Write-Log "Authentication: Using provided credentials"

# GOOD - Redact partial data
$apiKey = "sk_live_abcdef1234567890"
$maskedKey = $apiKey.Substring(0, 7) + "***"
Write-Log "Using API key: $maskedKey"

# GOOD - Log success/failure, not content
Write-Log "API call succeeded" -Level INFO
Write-Log "Database query returned 150 records" -Level INFO

Log What Matters

# DO log these:
Write-Log "Script started: $(Get-Date)" -Level INFO
Write-Log "Parameters: Source=$Source, Dest=$Dest" -Level INFO
Write-Log "Processed 1500 files in 45 seconds" -Level INFO
Write-Log "Script completed successfully" -Level INFO

# DON'T log every detail:
# for ($i = 0; $i -lt 10000; $i++) {
#     Write-Log "Loop iteration $i"  # TOO MUCH!
# }

# Instead, log summaries:
Write-Log "Processing 10,000 items..." -Level INFO
# ... process items ...
Write-Log "Completed processing 10,000 items" -Level INFO

Never Log Passwords or Secrets

# NEVER DO THIS!
$credential = Get-Credential
Write-Log "Password: $($credential.GetNetworkCredential().Password)"  # DANGEROUS!

# NEVER log these:
# - Passwords or credentials
# - API keys or tokens
# - Credit card numbers
# - Social security numbers
# - Any PII (personally identifiable information)

# DO THIS INSTEAD:
Write-Log "Authenticating as: $($credential.UserName)" -Level INFO
Write-Log "Using provided credentials" -Level INFO

Don't Use Write-Host for Data

# BAD - Cannot capture output
function Get-ServerList {
Write-Host "Server01"
Write-Host "Server02"
}

$servers = Get-ServerList  # $servers is EMPTY!

# GOOD - Use proper output
function Get-ServerList {
"Server01"
"Server02"
}

$servers = Get-ServerList  # Now $servers has the data

# Use Write-Host ONLY for display messages:
Write-Host "Processing..." -ForegroundColor Yellow

Batch Writes for Performance

# SLOW - Opens/closes file 10,000 times!
for ($i = 0; $i -lt 10000; $i++) {
Add-Content -Path "C:\Logs\app.log" -Value "Item $i"
}

# FAST - Collect in memory, write once
$logEntries = for ($i = 0; $i -lt 10000; $i++) {
"Item $i"
}
$logEntries | Out-File "C:\Logs\app.log" -Append

# Or use a buffer in your Write-Log function

Additional Resources