Logging is one of those parts of a PowerShell script that feels optional until the first production failure happens at 2 AM. When a script is run manually, you can watch the console and fix problems as they appear. When the same script runs from Task Scheduler, a deployment tool, a service account, or a remote session, the console is gone. The log becomes the only witness.
For small one-off scripts, Write-Host might be enough. For production scripts, I want a log that answers a few basic questions quickly:
- When did the script start and finish?
- Which computer and account ran it?
- What input did it receive?
- Which step failed?
- Was the failure expected, recoverable, or fatal?
- What should I check next?
This post shows the logging pattern I normally use for Windows admin automation. It is not a framework. It is a practical baseline that works well for scheduled jobs, maintenance scripts, user provisioning, file cleanup, service checks, and other scripts that need to explain themselves later.
Why console output is not enough
PowerShell gives you several output streams: success, error, warning, verbose, debug, information, and progress. That is useful when you are sitting in front of the shell, but it is easy to lose those streams when a script runs unattended.
Task Scheduler can capture output if you redirect it, but many scheduled tasks are created without redirection. Some tools show only the final exit code. Some remote runners keep stdout and stderr but drop verbose messages. Progress output is especially poor for logs because it is designed for an interactive screen, not a file.
Because of that, I prefer scripts to write their own log file. The console can still show useful messages, but the file is the record I trust.
The simplest version is a function that writes one line with a timestamp, level, and message:
function Write-Log {
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR')]
[string]$Level = 'INFO',
[switch]$ToScreen
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$line = "$timestamp [$Level] $Message"
Add-Content -Path $script:LogPath -Value $line -Encoding utf8
if ($ToScreen) {
Write-Host $line
}
}This writes to the log file by default. If I also want to see the message in the console, I call it with -ToScreen:
Write-Log "Starting cleanup job" -ToScreen
Write-Log "Deleted temporary file: $Path"This already gives you more value than plain Write-Host. Every message has a time and severity. If the script takes 40 minutes, you can see where time was spent. If it fails before the final step, you can see the last successful action.
Set the log path at startup
Do not let each function invent its own log file. Set the log path once near the start of the script, then reuse it everywhere.
I usually create one log file per run. That avoids mixing two runs together and makes troubleshooting easier when a scheduled task retries.
$ScriptName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$RunId = Get-Date -Format 'yyyyMMdd-HHmmss'
$LogRoot = 'C:\Logs\PowerShell'
$script:LogPath = Join-Path $LogRoot "$ScriptName-$RunId.log"
if (-not (Test-Path $LogRoot)) {
New-Item -Path $LogRoot -ItemType Directory -Force | Out-Null
}
Write-Log "Starting $ScriptName"
Write-Log "Computer: $env:COMPUTERNAME"
Write-Log "User: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Log "PowerShell: $($PSVersionTable.PSVersion)"Those first lines matter. When a script runs under a service account, I want the log to prove which identity was used. When a script behaves differently on Windows PowerShell 5.1 and PowerShell 7, I want the version in the file. When the same script runs on several machines, I want the computer name at the top.
If the script takes parameters, log the safe ones too. Do not log passwords, tokens, connection strings, or personally sensitive values. For example, logging a target OU or server name is usually fine. Logging a credential object is not.
Use levels with clear meaning
Logging levels only help if they mean something. I keep three levels in many admin scripts:
INFOmeans normal progress.WARNmeans something unexpected happened, but the script can continue.ERRORmeans the current operation failed or the script must stop.
Do not mark everything as error. A missing optional folder might be a warning. A missing required input file is an error. A disabled user account might be normal if your script is checking account state, but it might be an error if your script is preparing that account for access.
Here is a common example from cleanup jobs:
if (-not (Test-Path $ArchivePath)) {
Write-Log "Archive path does not exist. Creating: $ArchivePath" 'WARN'
New-Item -Path $ArchivePath -ItemType Directory -Force | Out-Null
}
if (-not (Test-Path $SourcePath)) {
Write-Log "Source path does not exist: $SourcePath" 'ERROR'
throw "Required source path missing"
}The archive folder can be created, so it is a warning. The source folder is required input, so it is an error.
Wrap the main script in try and catch
Production scripts should have one top-level try and catch around the main work. That does not replace local error handling, but it makes sure unexpected failures are logged before the process exits.
$ErrorActionPreference = 'Stop'
try {
Write-Log "Loading input file: $InputPath"
$items = Import-Csv -Path $InputPath
foreach ($item in $items) {
Write-Log "Processing item: $($item.Name)"
# Do work here
}
Write-Log "Completed successfully"
exit 0
}
catch {
Write-Log "Fatal error: $($_.Exception.Message)" 'ERROR'
Write-Log "Error line: $($_.InvocationInfo.ScriptLineNumber)" 'ERROR'
Write-Log "Command: $($_.InvocationInfo.Line.Trim())" 'ERROR'
exit 1
}$ErrorActionPreference = 'Stop' is important. Many PowerShell commands write non-terminating errors by default. Without changing the preference, the script may continue after a failed command and create a confusing log. For production automation, I usually want failures to stop unless I have handled them deliberately.
Exit codes matter too. Task Scheduler, deployment tools, monitoring systems, and CI jobs can all read the process exit code. A script that logs an error but exits with 0 is harder to monitor. If the script failed, return a non-zero exit code.
Log objects as data when needed
Plain text is easy to read, but sometimes you need structured data. If a script processes many records, I often write a normal run log plus a CSV result file. The log explains what happened. The CSV gives a clean list for later review.
For example, a user audit script might write this:
$Results = foreach ($user in $Users) {
try {
Write-Log "Checking user: $($user.SamAccountName)"
[pscustomobject]@{
SamAccountName = $user.SamAccountName
Enabled = $user.Enabled
LastLogonDate = $user.LastLogonDate
Status = 'Checked'
Error = $null
}
}
catch {
Write-Log "Failed to check $($user.SamAccountName): $($_.Exception.Message)" 'ERROR'
[pscustomobject]@{
SamAccountName = $user.SamAccountName
Enabled = $null
LastLogonDate = $null
Status = 'Failed'
Error = $_.Exception.Message
}
}
}
$Results | Export-Csv -Path $ResultPath -NoTypeInformation -Encoding utf8
Write-Log "Result file written: $ResultPath"This pattern is easier to use than trying to parse a text log later. If I need to send the result to another admin, import it into Excel, or compare two runs, the CSV is ready.
For larger systems, JSON Lines can also work well because each line is one JSON object. For typical Windows admin scripts, a readable text log plus CSV results is usually enough.
Use transcript logging carefully
PowerShell has built-in transcript logging with Start-Transcript. It captures console input and output, which can be useful when you want a full record of an interactive session or a script run.
$TranscriptPath = Join-Path $LogRoot "$ScriptName-$RunId-transcript.txt"
Start-Transcript -Path $TranscriptPath -Force
try {
Write-Log "Transcript started: $TranscriptPath"
# Script work here
}
finally {
Stop-Transcript
}I do not use transcripts as my only logging method. Transcript files can be noisy, and they are not always shaped the way I want for troubleshooting. I use them when I need extra detail, such as during testing, migration work, or a risky maintenance task.
Be careful with secrets. A transcript can capture commands and output that should not be kept in plain text. If a script handles credentials, tokens, or sensitive data, review what the transcript will record before enabling it in production.
Rotate old logs
A production script should clean up after itself. Logs are useful, but an unattended script that runs every hour can fill a folder quickly.
For most scheduled scripts, I keep 30 to 90 days of logs, depending on how often the task runs and how important the history is.
$RetentionDays = 60
Get-ChildItem -Path $LogRoot -Filter "$ScriptName-*.log" -File |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$RetentionDays) } |
Remove-Item -Force
Write-Log "Removed logs older than $RetentionDays days"If the script writes CSV results or transcripts, rotate those too. In stricter environments, check retention requirements before deleting anything. Some logs are operational notes. Other logs may become audit records.
Keep noisy output out of the main log
One mistake I see in production scripts is logging every object in full. That feels helpful while testing, but it makes real logs hard to read. A log with 30,000 lines is not automatically better than a log with 300 useful lines.
I usually log counts and identifiers:
Write-Log "Found $($Users.Count) users to process"
Write-Log "Disabled account: $SamAccountName"
Write-Log "Skipped account because it is already disabled: $SamAccountName" 'WARN'If I need the full object, I put it in a result file or a separate debug file. The main log should tell the story of the run without forcing me to search through every property returned by Active Directory, Microsoft Graph, or a REST API.
A reusable starter pattern
Here is the compact version I use as a starting point:
param(
[Parameter(Mandatory)]
[string]$InputPath
)
$ErrorActionPreference = 'Stop'
$ScriptName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$RunId = Get-Date -Format 'yyyyMMdd-HHmmss'
$LogRoot = 'C:\Logs\PowerShell'
$script:LogPath = Join-Path $LogRoot "$ScriptName-$RunId.log"
if (-not (Test-Path $LogRoot)) {
New-Item -Path $LogRoot -ItemType Directory -Force | Out-Null
}
function Write-Log {
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR')]
[string]$Level = 'INFO',
[switch]$ToScreen
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$line = "$timestamp [$Level] $Message"
Add-Content -Path $script:LogPath -Value $line -Encoding utf8
if ($ToScreen) {
Write-Host $line
}
}
try {
Write-Log "Starting $ScriptName" -ToScreen
Write-Log "Computer: $env:COMPUTERNAME"
Write-Log "User: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Log "Input path: $InputPath"
if (-not (Test-Path $InputPath)) {
throw "Input path not found: $InputPath"
}
# Main script work goes here.
Write-Log "Completed successfully"
exit 0
}
catch {
Write-Log "Fatal error: $($_.Exception.Message)" 'ERROR'
Write-Log "Line: $($_.InvocationInfo.ScriptLineNumber)" 'ERROR'
exit 1
}This is enough for many production scripts. From there, add CSV results, transcript logging, or JSON output only when the job needs it.
Final checks before using the pattern
Before I put a scheduled script into production, I check the logging path from the same account that will run the task. A path that works for my admin account might fail for a service account. I also test one successful run and one failure run. The failure test matters because a logging pattern that only works when everything is healthy is not very useful.
For Task Scheduler, I also check three places: the script log file, the task history, and the last run result. Those three together usually tell me whether the script started, whether PowerShell exited cleanly, and whether my own code reached the expected end.
Good logging does not make a script perfect, but it makes failure easier to diagnose. In production automation, that is often the difference between a quick fix and guessing from an empty screen.
💬 Comments