SCRIPT LIBRARY · POWERSHELL
Collecting Windows Update Logs with an SCCM Package
On Windows 10 and 11 there's no Windows Update logging to switch on. The trick is collecting it, and this script does that in one zip per machine.
- What it does
- Decodes the Windows Update ETW traces with Get-WindowsUpdateLog, adds the ConfigMgr client's update logs (and optionally CBS.log and the raw .etl files), and saves everything as one zip named after the computer.
- Requires
- Windows PowerShell 5.1 (Get-WindowsUpdateLog ships with Windows 10/11 and Server 2016+)
- No extra modules
- Permissions
- Local administrator or SYSTEM. If you save to a share, the computer account needs write access to it.
- Runs on
- Windows 10/11, Windows Server 2016+
- Tested
- Parse-checked and dry-run against a fake log folder with Get-WindowsUpdateLog mocked, in PowerShell 7.4
Part 6 of the thread Windows Update, untangled
The old version of this post set a Trace\Level registry value to turn on "verbose" Windows Update logging. That was a Windows 7-era trick for the old text-based WindowsUpdate.log. On Windows 10 and 11 it doesn't do anything useful, because Windows Update doesn't write a text log anymore. It writes ETW traces to C:\Windows\Logs\WindowsUpdate, all the time, whether you asked or not.
So the logging is already on. The annoying part is getting it off the machine in a form a human can read. Get-WindowsUpdateLog decodes the traces into a proper WindowsUpdate.log, but it only does that on demand, on the device itself. And if ConfigMgr is doing your patching, half the story is in the client's own logs anyway.
This script grabs both halves, zips them up with the computer name and a timestamp, and drops the zip somewhere you can get at it. Deploy it to the three machines that won't patch, go get a coffee, and come back to a folder of zips.
<#
.SYNOPSIS
Collects Windows Update logs from a device into one zip file.
.DESCRIPTION
On Windows 10/11 and Server 2016+, Windows Update writes ETW traces instead of a text
log. This script runs Get-WindowsUpdateLog to turn them into a readable WindowsUpdate.log,
adds the ConfigMgr client's update logs if the client is installed, optionally adds
CBS.log and the raw .etl files, and zips the lot as COMPUTERNAME-WULogs-timestamp.zip.
One missing log doesn't stop the rest from being collected.
.PARAMETER DestinationPath
Folder (local or UNC) where the zip is saved.
.PARAMETER SkipConfigMgrLogs
Don't collect WUAHandler.log, UpdatesDeployment.log and friends.
.PARAMETER IncludeCbsLog
Also collect CBS.log, where servicing stack and install failures often show up.
.PARAMETER IncludeEtl
Also collect the raw .etl trace files, for when someone else wants to decode them.
.EXAMPLE
.\Export-WindowsUpdateLog.ps1
.EXAMPLE
.\Export-WindowsUpdateLog.ps1 -DestinationPath \\sccm01\UpdateLogs$ -IncludeCbsLog
#>
[CmdletBinding()]
param(
[ValidateNotNullOrEmpty()]
[string]$DestinationPath = (Join-Path $env:SystemRoot 'Temp\UpdateLogs'),
[switch]$SkipConfigMgrLogs,
[switch]$IncludeCbsLog,
[switch]$IncludeEtl
)
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$name = "$env:COMPUTERNAME-WULogs-$stamp"
$work = Join-Path ([System.IO.Path]::GetTempPath()) $name
$notes = [System.Collections.Generic.List[string]]::new()
$null = New-Item -Path $work -ItemType Directory -Force
function Copy-LogSet {
param([string]$Source, [string[]]$Filter, [string]$Label)
if (-not (Test-Path -Path $Source)) { $notes.Add("$Label folder not found"); return }
$target = Join-Path $work $Label
$null = New-Item -Path $target -ItemType Directory -Force
foreach ($pattern in $Filter) {
foreach ($file in @(Get-ChildItem -Path $Source -Filter $pattern -File -ErrorAction SilentlyContinue)) {
try { Copy-Item -Path $file.FullName -Destination $target -ErrorAction Stop }
catch { $notes.Add("Couldn't copy $($file.Name): $($_.Exception.Message)") }
}
}
}
# 1. The main event: decode the ETW traces into WindowsUpdate.log.
if (Get-Command -Name Get-WindowsUpdateLog -ErrorAction SilentlyContinue) {
try {
Write-Verbose 'Decoding Windows Update ETW traces. This can take a minute or two.'
Get-WindowsUpdateLog -LogPath (Join-Path $work 'WindowsUpdate.log') -ErrorAction Stop | Out-Null
}
catch { $notes.Add("Get-WindowsUpdateLog failed: $($_.Exception.Message)") }
}
else {
# Pre-Windows 10 machines still write a plain text log.
Copy-LogSet -Source $env:SystemRoot -Filter 'WindowsUpdate.log' -Label 'Legacy'
}
# 2. The ConfigMgr side of the story, if there is one.
if (-not $SkipConfigMgrLogs) {
$ccmLogs = Join-Path $env:SystemRoot 'CCM\Logs'
if (Test-Path -Path $ccmLogs) {
Copy-LogSet -Source $ccmLogs -Label 'ConfigMgr' -Filter 'WUAHandler*.log', 'UpdatesDeployment*.log', 'UpdatesHandler*.log', 'UpdatesStore*.log', 'ScanAgent*.log'
}
else { Write-Verbose 'No ConfigMgr client logs on this device.' }
}
# 3. Optional extras.
if ($IncludeCbsLog) { Copy-LogSet -Source (Join-Path $env:SystemRoot 'Logs\CBS') -Filter 'CBS.log' -Label 'CBS' }
if ($IncludeEtl) { Copy-LogSet -Source (Join-Path $env:SystemRoot 'Logs\WindowsUpdate') -Filter '*.etl' -Label 'ETL' }
if ($notes.Count) { Set-Content -Path (Join-Path $work 'collection-notes.txt') -Value $notes }
# 4. Zip it up and clean up after ourselves.
try {
if (-not (Test-Path -Path $DestinationPath)) { $null = New-Item -Path $DestinationPath -ItemType Directory -Force -ErrorAction Stop }
$zip = Join-Path $DestinationPath "$name.zip"
Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -ErrorAction Stop
$fileCount = @(Get-ChildItem -Path $work -File -Recurse).Count
}
catch {
Write-Error "Couldn't write the archive to ${DestinationPath}: $($_.Exception.Message)"
Remove-Item -Path $work -Recurse -Force -ErrorAction SilentlyContinue
exit 1
}
Remove-Item -Path $work -Recurse -Force -ErrorAction SilentlyContinue
foreach ($note in $notes) { Write-Warning $note }
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
ZipPath = $zip
Files = $fileCount
SizeMB = [math]::Round((Get-Item -Path $zip).Length / 1MB, 2)
Warnings = $notes.Count
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-DestinationPath | string | $env:SystemRoot\Temp\UpdateLogs | Folder where the zip is saved. Use a UNC path to collect from lots of machines into one place. |
-SkipConfigMgrLogs | switch | — | Leave out WUAHandler.log, UpdatesDeployment.log, UpdatesHandler.log, UpdatesStore.log and ScanAgent.log. |
-IncludeCbsLog | switch | — | Add CBS.log, which is where most install failures (as opposed to scan failures) actually explain themselves. |
-IncludeEtl | switch | — | Add the raw .etl trace files too, for when someone else wants to decode them their own way. |
Run it
Collect on this machine and keep the zip locally.
.\Export-WindowsUpdateLog.ps1 -VerboseThe full set, dropped on a collection share.
.\Export-WindowsUpdateLog.ps1 -DestinationPath \\sccm01\UpdateLogs$ -IncludeCbsLogAs a ConfigMgr program command line.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Export-WindowsUpdateLog.ps1 -DestinationPath \\sccm01\UpdateLogs$Just the Windows side, on a machine without the ConfigMgr client.
.\Export-WindowsUpdateLog.ps1 -SkipConfigMgrLogsWhat you'll see
WARNING: Couldn't copy CBS.log: The process cannot access the file because it is being used by another process.
ComputerName : PC-0142
ZipPath : \\sccm01\UpdateLogs$\PC-0142-WULogs-20260929-101544.zip
Files : 6
SizeMB : 4.87
Warnings : 1
How it works
- Make a scratch folder. Everything is staged in a temp folder named after the computer and timestamp, so two runs never step on each other.
- Decode the traces. If
Get-WindowsUpdateLogexists, it writes a readable WindowsUpdate.log into the scratch folder. On anything older, it copies the plain-text log from the Windows folder instead. - Add the ConfigMgr logs. If
C:\Windows\CCM\Logsis there, it copies the update-related logs, including rolled-over copies likeWUAHandler-20260901-101010.log.WUAHandler.logtells you whether the scan worked.UpdatesDeployment.logtells you whether the client even thinks it should install something. - Keep going on errors. A missing folder or a locked file gets written to
collection-notes.txtinside the zip and shown as a warning, instead of stopping the run. - Zip and clean up.
Compress-Archivebuilds the zip at the destination, the scratch folder is deleted, and you get an object back with the path, file count and size.
For a handful of machines, ConfigMgr's Run Scripts is the fastest way to fire this off. For a whole collection, make it a package with a program so it runs whether or not anyone is signed in, and point everything at one share.
Take it further
- Read the right log first. Scan problems live in WindowsUpdate.log and WUAHandler.log. Install problems usually live in CBS.log. Starting in the right place saves a lot of scrolling.
- Add the event logs.
wevtutil epl Systemand theMicrosoft-Windows-WindowsUpdateClient/Operationalchannel export cleanly and are small. - Tidy the share. A scheduled task that deletes zips older than 30 days keeps the collection folder from turning into an archive nobody asked for.
Things that'll trip you up
- The share needs the computer account's permission. Run as SYSTEM, the script writes to the share as DOMAIN\PC-0142$. Give Domain Computers write access to a drop folder (and no read, if you'd rather machines couldn't browse each other's logs).
- Older builds want symbols. On early Windows 10 releases and Server 2016, Get-WindowsUpdateLog downloads symbols from Microsoft to decode the traces. No internet, no readable log. Current builds don't need them.
- CBS.log gets big. It can run to hundreds of megabytes on a machine that's been failing for a while. That's why it's opt-in. Multiply by a collection of 500 before you point this at a share.
- It's a snapshot. The ETW traces roll over. If the failure happened a week ago on a busy machine, the evidence may already be gone, so collect soon after the problem shows up.