Structured Logging and Transcripts for PowerShell Automation

When an unattended PowerShell job fails at 03:00, Write-Host output is gone. Nobody was watching the console, nothing was captured, and you are left reconstructing the failure from a truncated error email and a hunch.

Scheduled scripts are where PowerShell earns its keep and where its default output story falls apart. Interactive scripts get away with printing to the console because a human is there to read it. A script triggered by Task Scheduler or an Azure Automation hybrid worker has no console anyone will ever see. If you have not deliberately written the run to disk, the run did not happen as far as anyone investigating it later is concerned. This post gives a scheduled script a real logging spine: a transcript for the full session, a Write-Log function that emits structured, leveled, rotating lines you can query, and a path to the Windows Event Log for jobs your operations team monitors centrally. Everything here targets PowerShell 7.4 on Windows.

Start-Transcript: the cheap, complete record

Before writing any bespoke logging, turn on the transcript. Start-Transcript captures everything that goes to the host — commands, output, errors, warnings — into a plain text file. It is the single highest-value line you can add to a scheduled script, because it costs nothing and it records the things you forgot to log explicitly. Put it at the very top, inside a try/finally so the transcript is always stopped even when the script throws.

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

#requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# --- Configuration --------------------------------------------------------
$LogDir      = 'C:ProgramDataAcmeJobLogs'
$LogFile     = Join-Path $LogDir 'nightly-sync.log'
$MaxLogBytes = 5MB          # rotate once the active file passes this size
$MaxLogFiles = 7            # keep this many rotated files, then discard
$EventSource = 'Acme Nightly Sync'

New-Item -ItemType Directory -Path $LogDir -Force | Out-Null

# A dated transcript per run: the full, uncurated session record.
$stamp      = Get-Date -Format 'yyyyMMdd_HHmmss'
$transcript = Join-Path $LogDir "transcript_$stamp.log"
Start-Transcript -Path $transcript -IncludeInvocationHeader | Out-Null

try {
    # ... the actual work of the script goes here ...
}
finally {
    Stop-Transcript | Out-Null
}

The -IncludeInvocationHeader switch prepends a header recording the command that started the session and the time, which matters when you are correlating a transcript against a scheduler log months later. The transcript is deliberately verbose — it is your fallback, not your primary signal. For a signal you can actually search and alert on, you want structure.

A Write-Log function you can query later

Free-text log lines are fine to read and miserable to query. Six months from now you will want to answer questions like “show me every ERROR from the last week” or “how long did the export step take on each run”, and grepping prose does not scale to that. The fix is to write one structured object per line — newline-delimited JSON — so the log is both human-readable and machine-parseable. Each line carries a sortable ISO 8601 timestamp, a level, the message, the process ID, and any structured fields the caller wants to attach. Rotation is built into the same function so a chatty job cannot quietly fill the disk.

function Write-Log {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Message,
        [ValidateSet('DEBUG','INFO','WARN','ERROR')][string]$Level = 'INFO',
        [hashtable]$Data
    )

    # Rotate BEFORE writing if the active file has grown past the limit.
    if ((Test-Path $LogFile) -and (Get-Item $LogFile).Length -ge $MaxLogBytes) {
        $rotated = "$LogFile.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
        Rename-Item -Path $LogFile -NewName $rotated
        # Keep the newest $MaxLogFiles rotated files; discard the rest.
        Get-ChildItem "$LogFile.*" |
            Sort-Object LastWriteTime -Descending |
            Select-Object -Skip $MaxLogFiles |
            Remove-Item -Force
    }

    $entry = [ordered]@{
        timestamp = (Get-Date).ToString('o')   # ISO 8601, sortable
        level     = $Level
        message   = $Message
        pid       = $PID
    }
    if ($Data) { foreach ($k in $Data.Keys) { $entry[$k] = $Data[$k] } }

    ($entry | ConvertTo-Json -Compress -Depth 5) |
        Out-File -FilePath $LogFile -Append -Encoding utf8

    # Mirror to the host so the transcript captures it too.
    Write-Host "[$($entry.level)] $Message"
}

# Usage
Write-Log -Level INFO  -Message 'Sync started'
Write-Log -Level INFO  -Message 'Export complete' -Data @{ rows = 18432; seconds = 42 }
Write-Log -Level ERROR -Message 'Upload failed'   -Data @{ endpoint = 'sftp://acme' }

Because each line is valid JSON, the log becomes queryable with the same shell that wrote it. No external tooling required:

# Every error from today's log, as objects
Get-Content $LogFile |
    ForEach-Object { $_ | ConvertFrom-Json } |
    Where-Object level -eq 'ERROR' |
    Format-Table timestamp, message, endpoint

That is the whole point of structured logging: the effort you spend writing a disciplined line pays back the moment you need to reconstruct a run without re-reading the entire file. It is the same instinct that makes instrumenting delivery metrics worth the trouble — you cannot measure or reconstruct what you did not record in a shape you can query.

The Windows Event Log, the PowerShell 7 way

If your operations team monitors the Windows Event Log centrally — through SCOM, a SIEM agent, or a scheduled-task failure alert — a file on disk is invisible to them. You need to emit events. Here is the trap that catches people: Write-EventLog and New-EventLog do not exist in PowerShell 7. The entire *-EventLog command family was part of Windows PowerShell 5.1 and was never ported to PowerShell 7. A script that calls them runs fine when you test it in the ISE on 5.1 and fails silently or loudly the moment it runs under pwsh.exe.

The supported path in PowerShell 7 is to call the underlying .NET type directly. It is not much more code, and it is stable.

function Write-JobEvent {
    param(
        [Parameter(Mandatory)][string]$Message,
        [ValidateSet('Information','Warning','Error')][string]$EntryType = 'Information',
        [int]$EventId = 1000
    )

    # Registering the source writes to the registry and needs administrator
    # rights. It is a one-off: do it at install time, not on every run.
    if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) {
        [System.Diagnostics.EventLog]::CreateEventSource($EventSource, 'Application')
    }

    $type = [System.Diagnostics.EventLogEntryType]::$EntryType
    [System.Diagnostics.EventLog]::WriteEntry($EventSource, $Message, $type, $EventId)
}

Write-JobEvent -EntryType Error -EventId 5001 -Message 'Nightly sync failed: upload step'

Do the CreateEventSource call once, during deployment, in an elevated session — not inside the scheduled job, which should not be running as an administrator. Once the source is registered, writing entries needs no special privilege. Keep event IDs stable and documented; a monitoring rule that alerts on event ID 5001 is only useful if 5001 always means the same thing.

Keeping secrets out of the log

Logging and transcripts capture more than you intend, and that is precisely the risk. A transcript records everything sent to the host, so a stray Write-Host $connectionString or an unhandled exception that includes a token in its message writes that secret to a plain text file that now sits on disk for however long your rotation keeps it. Treat the log as a place secrets must never reach, and enforce it rather than hoping.

  • Never log a credential object or raw connection string. Log the fact of a connection, the target host, and the outcome — not the material that authorised it.
  • Redact before you write. Pass values through a scrub step so that even an accidental inclusion is masked in the output.
  • Lock down the log directory. C:ProgramData... logs should be readable only by the accounts that need them; a world-readable transcript is a credential leak waiting to happen.
# A blunt but effective redactor for known-sensitive patterns.
function Protect-Secret {
    param([string]$Text)
    $patterns = @(
        'password=S+',
        '(?i)bearers+[A-Za-z0-9._-]+',
        'AKIA[0-9A-Z]{16}'          # AWS access key IDs
    )
    foreach ($p in $patterns) { $Text = $Text -replace $p, '***REDACTED***' }
    return $Text
}

Write-Log -Level INFO -Message (Protect-Secret $rawResponse)

The same discipline that keeps API keys from becoming a liability applies here: assume anything written down will eventually be read by the wrong person, and design the write path accordingly. And treat these logging conventions the way you would any other standard — something a pre-commit and CI baseline can check for, so that a Write-Host of a secret never reaches the branch, let alone production.

What you have when you are done

A scheduled script wired this way leaves three complementary records: a transcript that captures the whole session for forensic reconstruction, a structured log you can query and alert on, and Event Log entries your operations tooling already watches. When the 03:00 run fails, you open the log, filter to the errors, read the transcript around the timestamp, and you know exactly what happened — without having been awake to see it. That is the difference between a script you trust unattended and one you have to babysit. Write-Host gives you the second. Twenty lines of logging spine gives you the first.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.