Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting
· Updated: · Go Komura · PowerShell, Windows, Automation, Log Investigation, Operational Improvement, Legacy Asset Reuse
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614643)
- First published
Cite this article(DOI: 10.5281/zenodo.21614642)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614642 https://comcomponent.com/en/blog/2026/06/02/000-powershell-script-log-maintenance-automation/
- DOI (latest version)
- 10.5281/zenodo.21614642
- DOI (this version)
- 10.5281/zenodo.22220496
1. What to Get Right First
Last time we covered the basic PowerShell commands, the pipeline, CSV output, JSON, .ps1 scripts, and safety checks with -WhatIf. This article continues from there and builds a somewhat larger script that is comfortable to use in real work. The subject matter is operational work like this:
- Investigate logs
- Compile error lines into a CSV
- List old logs as archive candidates
- Move old logs if needed
- Keep the run results as an audit trail
What matters in applied PowerShell is not making commands longer, but establishing the following flow:
- Separate out the configuration
- Build the read-only processing
- Keep the output
- Split the change processing into functions
- Rehearse with
-WhatIf - Consider automated execution last
Automation is not about skipping human verification. It is about fixing where the verification happens so that the same audit trail is produced every time.
The code in this article is published on GitHub as a ready-to-run sample set: the finished script, the configuration file, a script that creates a dummy log environment, and Pester tests that verify the investigation, the preview run, and the move.
powershell-script-log-maintenance-automation - komurasoft-blog-samples (GitHub)
Where This Article Fits
Three PowerShell articles form a continuous set. It is not a numbered series, but reading them in this order keeps the thread intact.
| Order | Article | Scope |
|---|---|---|
| 1 | PowerShell Command Basics — The Operations to Learn First and How to Use Them Safely | Finding commands, the pipeline, CSV / JSON, .ps1, -WhatIf |
| 2 | Practical PowerShell Command Recipes — Growing the Small Tools You Use Every Day | Building blocks for aggregation, comparison, extraction, and audit-trail output |
| 3 | This article | Combining those building blocks into a single operational script, with configuration, audit trails, preview runs, and scheduled execution |
The only prerequisite is the content of article 1. You can follow this article without having read article 2. The reference to “last time” at the top means article 1.
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (16 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. What We Will Build
This time we will build a script called Invoke-LogMaintenance.ps1.
Its main features are as follows.
| Feature | Description |
|---|---|
| Log search | Search for .log files under the specified folder |
| Time window | Investigate only logs modified within the last N days |
| Error extraction | Extract lines containing ERROR, WARN, FATAL, and so on |
| CSV output | Save the search results to log-hits.csv |
| Old log listing | Save logs older than N days to archive-targets.csv |
| Archiving | Move old logs to a separate folder |
| Preview run | With -Preview, show the plan without moving anything |
| Run records | Save a transcript, a summary JSON, and the result CSVs |
Note that nothing gets deleted. In this first applied installment we stop at moving files, which is safer than deleting them.
3. Folder Layout
As an example, we will use the following layout.
C:\Ops
Invoke-LogMaintenance.ps1
log-maintenance.json
C:\App\Logs
app.log
batch.log
old
app-202401.log
C:\App\Reports
20260602-030000
log-hits.csv
archive-targets.csv
archive-result.csv
summary.json
transcript.txt
C:\App\Archive
20260602-030000
old
app-202401.log
Keeping the script and the configuration file separate makes it easy to swap settings per environment. You can run the same script with only the paths changed: C:\Test\Logs in development, D:\App\Logs in production.
4. Create the Configuration File
First, create log-maintenance.json.
{
"LogPath": "C:\\App\\Logs",
"OutputPath": "C:\\App\\Reports",
"Days": 7,
"Patterns": [
"ERROR",
"WARN",
"FATAL"
],
"ArchiveDays": 90,
"ArchivePath": "C:\\App\\Archive"
}
Here is what each value means.
| Item | Meaning |
|---|---|
LogPath |
The log folder to investigate |
OutputPath |
Where reports are written |
Days |
How many recent days of logs to investigate |
Patterns |
The strings and patterns to search for |
ArchiveDays |
How old a log must be to become an archive target |
ArchivePath |
The archive destination folder |
Keeping this in JSON lets you change the conditions without editing the script itself.
In PowerShell, ConvertFrom-Json lets you handle JSON as objects. Conversely, to record processing results as JSON, use ConvertTo-Json. ConvertTo-Json is the cmdlet that converts objects into JSON strings, and when the data nests deeply, specifying -Depth becomes important.
Patterns Is Treated as a Regular Expression
One caveat before we go further. The values in Patterns are ultimately passed to Select-String -Pattern, and by default Select-String interprets them as regular expressions.
That means ERROR. reads as “ERROR followed by any single character” and matches ERRORS and ERROR: as well, while a line containing only ERROR does not match. A string that contains backslashes, such as C:\App, is likewise read as regular-expression metacharacters if you write it verbatim.
- You want a regular expression: write it as is. Forms such as
"ERROR|FATAL"and"\[ERROR\]"work - You want to match the literal string: put the result of
[regex]::Escape("ERROR.")into the JSON, or add-SimpleMatchto theSelect-Stringcall in the script
Select-String is also case-insensitive by default: it picks up error lines and ERROR lines alike. Add -CaseSensitive when you need the distinction.
5. Build the Read-Only Part First
Do not jump straight to the move processing. Start with nothing more than finding the logs and turning them into a CSV.
$config = Get-Content .\log-maintenance.json -Raw -Encoding UTF8 | ConvertFrom-Json
$since = (Get-Date).AddDays(-[int]$config.Days)
$files = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
Where-Object { $_.LastWriteTime -ge $since }
$files |
Select-Object FullName, Length, LastWriteTime
Next, search the contents of the logs.
$patterns = [string[]]$config.Patterns
Select-String -LiteralPath ($files | Select-Object -ExpandProperty FullName) -Pattern $patterns |
Select-Object Path, LineNumber, Pattern, Line |
Export-Csv .\log-hits.csv -NoTypeInformation -Encoding UTF8
Everything so far is read-only. Even when you try this against a production folder, stop at this stage first.
6. List the Old Logs
Next, list the archive targets.
$limit = (Get-Date).AddDays(-[int]$config.ArchiveDays)
$targets = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
Where-Object { $_.LastWriteTime -lt $limit } |
Sort-Object LastWriteTime
$targets |
Select-Object FullName, Length, LastWriteTime |
Export-Csv .\archive-targets.csv -NoTypeInformation -Encoding UTF8
Even at this stage, nothing is moved yet. Open archive-targets.csv and check that the target list is not unexpectedly large and that the folder is the one you meant.
7. Split Change Processing into a Function
Keep change processing such as moving files separate from read-only processing.
In PowerShell, adding SupportsShouldProcess to a function makes it able to handle -WhatIf and -Confirm. -WhatIf shows what would be changed without executing anything, and -Confirm is the mechanism for prompting before execution. You can read the details in about_Functions_CmdletBindingAttribute on Microsoft Learn.
function Move-OldLogFile {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo[]]$File,
[Parameter(Mandatory)]
[string]$SourceRoot,
[Parameter(Mandatory)]
[string]$ArchiveRoot
)
foreach ($item in $File) {
$relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
$destination = Join-Path $ArchiveRoot $relativePath
$destinationDirectory = Split-Path -Path $destination -Parent
if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
if (-not [System.IO.Directory]::Exists($destinationDirectory)) {
[System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
}
Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Moved"
Message = ""
}
}
else {
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Preview"
Message = ""
}
}
}
}
The key point is that $PSCmdlet.ShouldProcess() sits immediately before Move-Item. The decision of whether to change is placed not outside the function but right before the change.
8. The Finished Script
Here is the finished version that brings everything together. The file name is Invoke-LogMaintenance.ps1.
# Invoke-LogMaintenance.ps1
#Requires -Version 7.0
[CmdletBinding()]
param(
[ValidateNotNullOrEmpty()]
[string]$ConfigPath = ".\log-maintenance.json",
[switch]$Preview,
[switch]$SkipArchive,
[switch]$SkipTranscript
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Ensure-Directory {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
if (-not [System.IO.Directory]::Exists($Path)) {
[System.IO.Directory]::CreateDirectory($Path) | Out-Null
}
}
function Import-LogMaintenanceConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Config file not found: $Path"
}
$config = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($name in @("LogPath", "OutputPath", "Days", "Patterns", "ArchiveDays", "ArchivePath")) {
if (-not ($config.PSObject.Properties.Name -contains $name)) {
throw "Config value missing: $name"
}
}
if ([string]::IsNullOrWhiteSpace([string]$config.LogPath)) {
throw "LogPath is empty."
}
if (-not (Test-Path -LiteralPath $config.LogPath)) {
throw "LogPath not found: $($config.LogPath)"
}
if ([string]::IsNullOrWhiteSpace([string]$config.OutputPath)) {
throw "OutputPath is empty."
}
if ([string]::IsNullOrWhiteSpace([string]$config.ArchivePath)) {
throw "ArchivePath is empty."
}
if (@($config.Patterns).Count -eq 0) {
throw "Patterns is empty."
}
if ([int]$config.Days -lt 1) {
throw "Days must be 1 or greater."
}
if ([int]$config.ArchiveDays -lt 1) {
throw "ArchiveDays must be 1 or greater."
}
return $config
}
function Export-CsvWithHeader {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]]$InputObject,
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[string[]]$Header
)
if ($InputObject.Count -gt 0) {
$InputObject |
Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
}
else {
($Header -join ",") |
Set-Content -LiteralPath $Path -Encoding UTF8
}
}
function Get-LogHit {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$LogPath,
[Parameter(Mandatory)]
[ValidateRange(1, 3650)]
[int]$Days,
[Parameter(Mandatory)]
[string[]]$Pattern
)
$since = (Get-Date).AddDays(-$Days)
$files = @(
Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
Where-Object { $_.LastWriteTime -ge $since }
)
Write-Verbose "Recent log files: $($files.Count)"
if ($files.Count -eq 0) {
return @()
}
$paths = $files | Select-Object -ExpandProperty FullName
Select-String -LiteralPath $paths -Pattern $Pattern -ErrorAction Stop |
ForEach-Object {
[pscustomobject]@{
Path = $_.Path
LineNumber = $_.LineNumber
Pattern = $_.Pattern
Line = $_.Line.Trim()
}
}
}
function Get-OldLogFile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$LogPath,
[Parameter(Mandatory)]
[ValidateRange(1, 3650)]
[int]$ArchiveDays
)
$limit = (Get-Date).AddDays(-$ArchiveDays)
Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
Where-Object { $_.LastWriteTime -lt $limit } |
Sort-Object LastWriteTime
}
function Move-OldLogFile {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo[]]$File,
[Parameter(Mandatory)]
[string]$SourceRoot,
[Parameter(Mandatory)]
[string]$ArchiveRoot
)
foreach ($item in $File) {
$relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
$destination = Join-Path $ArchiveRoot $relativePath
$destinationDirectory = Split-Path -Path $destination -Parent
if (Test-Path -LiteralPath $destination) {
$name = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
$ext = $item.Extension
$destination = Join-Path $destinationDirectory ("{0}_{1:yyyyMMddHHmmss}{2}" -f $name, $item.LastWriteTime, $ext)
}
if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
Ensure-Directory -Path $destinationDirectory
Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Moved"
Message = ""
}
}
else {
[pscustomobject]@{
Source = $item.FullName
Destination = $destination
Status = "Preview"
Message = ""
}
}
}
}
$config = Import-LogMaintenanceConfig -Path $ConfigPath
$runStamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportDir = Join-Path ([string]$config.OutputPath) $runStamp
Ensure-Directory -Path $reportDir
$transcriptStarted = $false
$transcriptPath = Join-Path $reportDir "transcript.txt"
try {
if (-not $SkipTranscript) {
Start-Transcript -Path $transcriptPath -Force | Out-Null
$transcriptStarted = $true
}
Write-Host "Report directory: $reportDir"
$hits = @(
Get-LogHit `
-LogPath ([string]$config.LogPath) `
-Days ([int]$config.Days) `
-Pattern ([string[]]$config.Patterns)
)
$hitCsv = Join-Path $reportDir "log-hits.csv"
Export-CsvWithHeader `
-InputObject $hits `
-Path $hitCsv `
-Header @("Path", "LineNumber", "Pattern", "Line")
$oldFiles = @(
Get-OldLogFile `
-LogPath ([string]$config.LogPath) `
-ArchiveDays ([int]$config.ArchiveDays)
)
$archiveTargets = @(
$oldFiles |
Select-Object FullName, Length, LastWriteTime
)
$archiveTargetCsv = Join-Path $reportDir "archive-targets.csv"
Export-CsvWithHeader `
-InputObject $archiveTargets `
-Path $archiveTargetCsv `
-Header @("FullName", "Length", "LastWriteTime")
$moveResults = @()
if ($SkipArchive) {
Write-Host "Archive skipped."
}
elseif ($oldFiles.Count -eq 0) {
Write-Host "No archive targets."
}
else {
$archiveRunRoot = Join-Path ([string]$config.ArchivePath) $runStamp
$moveResults = @(
Move-OldLogFile `
-File $oldFiles `
-SourceRoot ([string]$config.LogPath) `
-ArchiveRoot $archiveRunRoot `
-WhatIf:$Preview
)
}
$archiveResultCsv = Join-Path $reportDir "archive-result.csv"
Export-CsvWithHeader `
-InputObject $moveResults `
-Path $archiveResultCsv `
-Header @("Source", "Destination", "Status", "Message")
$summary = [pscustomobject]@{
CheckedAt = (Get-Date).ToString("s")
ComputerName = $env:COMPUTERNAME
LogPath = [string]$config.LogPath
ReportDirectory = $reportDir
HitCount = $hits.Count
ArchiveTargetCount = $oldFiles.Count
ArchiveResultCount = $moveResults.Count
Preview = [bool]$Preview
SkipArchive = [bool]$SkipArchive
}
$summaryPath = Join-Path $reportDir "summary.json"
$summary |
ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath $summaryPath -Encoding UTF8
Write-Host "Finished."
Write-Host "Hits: $($hits.Count)"
Write-Host "Archive targets: $($oldFiles.Count)"
}
catch {
$errorPath = Join-Path $reportDir "error.txt"
$_ | Out-String | Set-Content -LiteralPath $errorPath -Encoding UTF8
Write-Error "Failed: $($_.Exception.Message)"
exit 1
}
finally {
if ($transcriptStarted) {
Stop-Transcript | Out-Null
}
}
9. Example Runs
First, run only the log investigation, without archiving.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -SkipArchive
Next, check the archive plan.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview
With -Preview, the move processing for old logs is treated as -WhatIf.
The three files to review at this point are:
log-hits.csvarchive-targets.csvarchive-result.csv
If everything looks right, drop -Preview and run it for real.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json
To see the details, add -Verbose.
.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview -Verbose
10. The Output Files
When the script runs, it creates a timestamped folder such as C:\App\Reports\20260602-030000 under OutputPath.
The following files are written inside it.
| File | Contents |
|---|---|
log-hits.csv |
Lines detected as errors or warnings |
archive-targets.csv |
Old logs that became archive targets |
archive-result.csv |
Move results, or Preview results |
summary.json |
A summary of counts and run conditions |
transcript.txt |
A record of the PowerShell session |
error.txt |
Details when an error occurred |
Start-Transcript is the cmdlet for recording a PowerShell session’s commands and console output to a text file. In operational scripts, it makes it much easier to check later when the script ran, under what conditions, and what came out of it.
What the Output Should Look Like
On the first run, what matters is not whether files appeared but whether their contents match what you expect. The log content below is dummy data, but the columns and the order of the fields are exactly what the script in this article produces.
log-hits.csv has four columns: Path, LineNumber, Pattern, and Line. By default, Export-Csv wraps every field in ".
"Path","LineNumber","Pattern","Line"
"C:\App\Logs\app.log","128","ERROR","2026-06-02 02:14:51 [ERROR] OrderService: timeout while calling /api/stock"
"C:\App\Logs\app.log","301","WARN","2026-06-02 02:41:03 [WARN] OrderService: retry 1/3"
"C:\App\Logs\batch.log","57","FATAL","2026-06-02 03:00:12 [FATAL] nightly batch aborted"
The Pattern column holds the pattern string that matched, verbatim. It corresponds to the values you wrote in Patterns in the configuration file, so you can use it to count how many hits each keyword produced.
archive-targets.csv has three columns: FullName, Length, and LastWriteTime.
"FullName","Length","LastWriteTime"
"C:\App\Logs\old\app-202401.log","10485760","2024/01/31 23:59:58"
archive-result.csv has four columns: Source, Destination, Status, and Message. With -Preview, Status is Preview; on a real run it is Moved.
"Source","Destination","Status","Message"
"C:\App\Logs\old\app-202401.log","C:\App\Archive\20260602-030000\old\app-202401.log","Moved",""
The date and time format varies from machine to machine, because Export-Csv converts values to strings according to the OS locale settings. If the CSV feeds a downstream system, confirm this point before you rely on it.
summary.json is a summary of the counts and the run conditions. Paths are escaped as \\, exactly as the JSON specification requires.
{
"CheckedAt": "2026-06-02T03:00:07",
"ComputerName": "OPS-01",
"LogPath": "C:\\App\\Logs",
"ReportDirectory": "C:\\App\\Reports\\20260602-030000",
"HitCount": 3,
"ArchiveTargetCount": 1,
"ArchiveResultCount": 1,
"Preview": false,
"SkipArchive": false
}
For monitoring and daily checks, it pays to arrange things so that summary.json alone is enough. You can then open the CSVs only on the days when HitCount spikes or ArchiveTargetCount suddenly grows.
Note that the CSVs are created even when there are zero targets, because Export-CsvWithHeader writes a file containing just the header row. That design keeps “the file is missing” from being mistaken for “there were zero matches.”
11. Run It on a Schedule with Task Scheduler
Once manual runs check out, you can run the script on a schedule with Task Scheduler.
Here is an initial example that runs every day at 3:00 a.m.
$scriptPath = "C:\Ops\Invoke-LogMaintenance.ps1"
$configPath = "C:\Ops\log-maintenance.json"
$action = New-ScheduledTaskAction `
-Execute "pwsh.exe" `
-Argument "-NoProfile -File `"$scriptPath`" -ConfigPath `"$configPath`"" `
-WorkingDirectory "C:\Ops"
$trigger = New-ScheduledTaskTrigger -Daily -At 3:00
Register-ScheduledTask `
-TaskName "AppLogMaintenance" `
-Action $action `
-Trigger $trigger `
-Description "Collect app log errors and archive old logs"
New-ScheduledTaskAction creates an object representing the command the task will run, and New-ScheduledTaskTrigger creates the launch conditions: daily, weekly, at logon, and so on. Finally, Register-ScheduledTask registers the task on the local computer.
Confirm That the Task Registered
A registration command that returns without error does not yet mean the task works. Before waiting for the scheduled time, inspect what was registered.
In the GUI, open Task Scheduler (taskschd.msc) and select Task Scheduler Library in the left pane; the AppLogMaintenance task you just registered appears in the list. These are the columns to look at.
| Column | What to look for |
|---|---|
| Status | Ready means it is enabled. Disabled means it will not run even when the trigger fires |
| Triggers | Check that the condition is the one you intended, such as At 3:00 AM every day |
| Next Run Time | The next scheduled time. If it is blank, suspect the trigger or the enabled state |
| Last Run Result | Filled in after a run. 0x0 is success, 0x1 is a general error, 0x41301 means currently running |
If those columns are not shown in the list, right-click the column headers and add them.
You can check the same things from PowerShell. On machines where you cannot open the GUI, this is faster.
# View what was registered
Get-ScheduledTask -TaskName "AppLogMaintenance" |
Select-Object TaskName, TaskPath, State
# View the run result
Get-ScheduledTaskInfo -TaskName "AppLogMaintenance" |
Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime
If LastTaskResult is anything other than 0, start with error.txt and transcript.txt in the report folder.
If the task never launched at all, however, not one line of the script ran, so the report folder does not even exist. In that case, look at the History tab that appears at the bottom of the window when you select the task in the list, not at the Properties dialog you get by right-clicking the task. Task history is sometimes disabled by default, so turn it on with Enable All Tasks History in the right pane, then right-click the task and choose Run to start it manually and see what happens.
In production, also check the following:
- The run-as user has read access to the log folder
- There is write access to the archive destination
- The path to
pwsh.exeresolves - The script conforms to your execution policy and signing rules
- Manual runs and task runs produce the same results
- On failure,
error.txtand the task history can be reviewed
12. Common Stumbling Blocks
| Symptom | Cause | Remedy |
|---|---|---|
| The logs are not found | LogPath is wrong |
Check with Test-Path and Get-ChildItem |
| The CSV comes out empty | No logs fall within the target period | Widen Days and check again |
| Japanese text is garbled | The log’s character encoding differs from what you assumed | Check the input and output encodings |
| It does not run as a task | The run-as user or the working folder differs | Check WorkingDirectory and the permissions |
| Too many archive targets | ArchiveDays is too short |
Look at archive-targets.csv and adjust |
| The destination is not what you expected | The relative-path preservation rule is misunderstood | Check Destination with -Preview |
| It fails only in production | Differences in permissions, policy, or locked files | Check error.txt and transcript.txt |
Task Scheduler in particular can run the script as a different account from the one you used yourself. If it works manually but fails as a task, suspect permissions and the working folder first.
13. How to Think About Customizing It
This script is meant to be adapted to your own environment little by little rather than used as is. Here are the customizations that come up most often.
| What you want to do | What to change |
|---|---|
Also cover .txt |
Change Get-ChildItem -Filter *.log |
See a few lines around each ERROR |
Take the Select-String results and fetch the surrounding lines with Get-Content |
| Compress before archiving | Add Compress-Archive before Move-OldLogFile |
| Send email notifications | Add notification processing driven by summary.json |
| Keep separate configuration per application | Prepare multiple JSON files and split the tasks |
| Automate all the way to deletion | Run the move-based process for a while first, then consider it |
That said, it is safer not to add everything from the start. For an operational script, being able to trace what happened when it fails matters more than having many features.
Decide the Email Notification Method Up Front
“Send email notifications” is a frequent request, but this is the one item where you have to settle on a method before implementing anything.
PowerShell’s Send-MailMessage is explicitly marked obsolete on Microsoft Learn, which advises against using it because it does not guarantee a secure connection to the SMTP server. There is no direct successor cmdlet inside PowerShell; the alternatives listed are libraries such as MailKit, or, in an Exchange Online environment, Send-MgUserMail from the Microsoft Graph PowerShell SDK.
The right method depends on the environment.
| Environment | Notification method |
|---|---|
| An internal SMTP relay is available and neither authentication nor TLS is required | Send-MailMessage still works, but confine it to a single function so you are ready for its eventual removal |
| Authenticated SMTP or TLS is required | Use a library such as MailKit |
| Microsoft 365 environment | Go through Microsoft Graph (Send-MgUserMail) |
| It does not have to be email | Use a webhook such as Teams, or hand a file off to your monitoring platform |
Whichever method you pick, keep the notification step to nothing more than reading summary.json and sending it, and keep it separate from the main processing. In practice, it pays to arrange things so that a failed notification still leaves the log investigation and the archiving complete.
14. An Operational Checklist for Real-World Use
Before running a PowerShell script on a schedule, check the following:
- You first ran only the read-only processing with
-SkipArchive - You then checked the move plan with
-Preview - The targets in
archive-targets.csvwere reasonable - The
Destinationvalues inarchive-result.csvmatched expectations - A timestamped audit trail was left in the output folder
- You confirmed that
error.txtis produced when an error occurs - You checked the permissions of the task’s run-as user
- You checked the execution policy, signing, and your internal rules
- You operate with moves first rather than deleting right away
- You have decided where files will be restored to if recovery is needed
15. Conclusion
Applied PowerShell does not mean using a lot of difficult syntax. What pays off in real work is having this pattern in place:
- Separate the configuration into JSON
- Build the read-only processing first
- Keep audit trails in CSV and JSON
- Split change processing into functions
- Provide a
-WhatIf-equivalent rehearsal - Make it traceable with the transcript and
error.txt - Verify with manual runs before scheduling
This script uses log investigation and archiving as its subject, but the approach applies to other work as well:
- Organizing files
- Generating reports
- Aggregating CSVs
- Replacing batch files
- Taking stock of legacy assets
- Daily and monthly operational checks
PowerShell is useful even as a one-line command, but for business use it is safer to keep to this order:
Look → Record → Rehearse → Execute → Keep the trail
Shape things this way and PowerShell stops being merely a time-saving tool: it becomes a small business application that keeps your operations stable.
Reference Links
- The complete sample code for this article (finished script, configuration file, Pester tests) - komurasoft-blog-samples (GitHub)
- PowerShell Documentation - Microsoft Learn
- ConvertTo-Json - Microsoft Learn
- about_Functions_CmdletBindingAttribute - Microsoft Learn
- Start-Transcript - Microsoft Learn
- New-ScheduledTaskAction - Microsoft Learn
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Parameter Design and Modularization for PowerShell Scripts — From a Script That Works to a Script You Can Hand Over
A step-by-step procedure for raising a PowerShell script to a quality you can hand to someone else. Covers the param block and [CmdletBin...
The Differences Between Windows PowerShell 5.1 and PowerShell 7 — A Practical Guide to Migrating In-House Scripts
The relationship between Windows PowerShell 5.1 and PowerShell 7 (side-by-side coexistence and pwsh.exe), Microsoft's official position t...
Automating PC Provisioning With winget + PowerShell — Making the Runbook Executable
How to make new-hire PC setup reproducible. Covers installing applications with winget and export/import, declarative configuration with ...
Integrating With REST APIs From PowerShell — Invoke-RestMethod in Practice
A practical guide to calling in-house and SaaS REST APIs from PowerShell: passing authentication headers, avoiding mojibake in non-ASCII ...
Where to Look When a PowerShell Script Is Slow — Arrays, Pipelines and Matching
The classic causes of slow PowerShell scripts, laid out. Why += on an array is O(n^2), the difference between the pipeline and foreach, t...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- When automating log investigation with PowerShell, what should I build first?
- Do not start by writing change processing such as moves or deletions. Build only the read-only processing first: finish the part that finds the target logs with Get-ChildItem, extracts error lines with Select-String, and exports them to CSV. Even when you try this against a production folder, stop at this read-only stage and review the results. From there, the safe path is to proceed step by step to listing archive targets and then to wrapping the move processing in a function.
- How do I build -WhatIf into a PowerShell script?
- Adding SupportsShouldProcess to a function's CmdletBinding attribute makes -WhatIf and -Confirm available on that function. The key is to place $PSCmdlet.ShouldProcess() immediately before the code that actually makes the change (Move-Item, for example), so the decision of whether to change sits right next to the change itself. The script in this article passes its -Preview switch through as -WhatIf:$Preview, which lets you review the plan without moving anything.
- Why does my PowerShell script work manually but fail in Task Scheduler?
- The typical cause is that the task's run-as user differs from the account you ran it under yourself. Check that the run-as user has read access to the log folder and write access to the archive destination, that the working folder (WorkingDirectory) is correct, that the path to pwsh.exe resolves, and that the script conforms to your execution policy and signing rules. It is important to set things up so that failures can be traced through error.txt, transcript.txt, and the task history.
- Is it fine to automate the deletion of old logs as well?
- Automating deletion from the start is not recommended. The script in this article also stops at the safer option of moving files rather than deleting them. Run the move-based process for a while first, review the audit trail in archive-targets.csv and archive-result.csv, and consider automating deletion only once you can see that nothing is going wrong. It also matters to decide in advance where files will be restored to if you need to recover them.