Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting
· Go Komura · PowerShell, Windows, Automation, Log Investigation, Operational Improvement, Legacy Asset Reuse
1. What to Grasp First
Last time, we covered the basic PowerShell commands, the pipeline, CSV output, JSON, .ps1 scripts, and safety checks with -WhatIf. As the follow-up, this time we will build a somewhat larger script that is easy 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 does not mean “removing human verification.” It means fixing where the verification happens and ensuring 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, preview, and move).
powershell-script-log-maintenance-automation - komurasoft-blog-samples (GitHub)
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, etc. |
| 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 we will not delete anything. In this first applied installment, we stop at “moving,” which is safer than deleting.
3. Folder Layout
As an example, we 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 body and the configuration file separate makes per-environment swapping easy. You can run an operation where only the paths change, such as C:\Test\Logs in development and 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"
}
The meanings are as follows.
| 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/patterns to search for |
ArchiveDays |
How old a log must be to become an archive target |
ArchivePath |
The archive destination folder |
Using JSON lets you change the conditions without editing the script body.
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 to JSON strings; when dealing with deep nesting, specifying -Depth becomes important.
5. Build the Read-Only Part First
Do not write the move processing straight away — at first, just find the logs and turn 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
Up to this point everything is read-only. Even when trying this against the 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, we still do not move anything. Look at archive-targets.csv and confirm there are not too many targets and that the folder is not wrong.
7. Split Change Processing into Functions
Separate change processing, like moving files, from the 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, 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() is placed immediately before Move-Item. The decision of whether to change is placed not outside the function but immediately 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 executing the archive.
.\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, run without -Preview.
.\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 run, a timestamped folder like C:\App\Reports\20260602-030000 is created under OutputPath.
The following files are output 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 it ran, under what conditions, and what came out.”
11. Run It on a Schedule with Task Scheduler
If manual runs check out, you can run it on a schedule with Task Scheduler.
Here is an example that initially 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.
In production operation, 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 - It conforms to your script 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 |
|---|---|---|
| Logs aren’t found | LogPath is wrong |
Check with Test-Path and Get-ChildItem |
| The CSV is empty | No logs fall within the target period | Widen Days and check |
| Japanese text is garbled | The log’s character encoding differs from the assumption | Check the input/output encodings |
| Doesn’t run as a task | Different run-as user or working folder | Check WorkingDirectory and permissions |
| Too many archive targets | ArchiveDays is too short |
Look at archive-targets.csv and adjust |
| Destination differs from expectation | Misunderstanding the relative-path preservation rule | Check Destination with -Preview |
| Fails only in production | Differences in permissions, policy, or locked files | Check error.txt and transcript.txt |
In particular, when running via Task Scheduler, “the user running it yourself” and “the task’s run-as user” can differ. 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 gradually adapted to your environment rather than used as is. Here are common customizations.
| What you want | Where to modify |
|---|---|
Also include .txt |
Change Get-ChildItem -Filter *.log |
See a few lines around each ERROR |
Use 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 based on summary.json |
| Separate configuration per application | Prepare multiple JSON files and split the tasks |
| Automate all the way to deletion | First run a move-based operation for a while, then consider it |
That said, it is safer not to add everything from the start. For operational scripts, being traceable when something fails matters more than having many features.
14. An Operational Checklist for the Field
Before running a PowerShell script on a schedule, check the following:
- First ran only the read-only processing with
-SkipArchive - Then checked the move plan with
-Preview - The targets in
archive-targets.csvwere reasonable - The
Destinationvalues inarchive-result.csvmatched expectations - A timestamped audit trail remained in the output folder
- Confirmed that
error.txtis produced on errors - Checked the permissions of the task’s run-as user
- Checked the execution policy, signing, and internal rules
- Operate with moves first, not deletion right away
- Decided where files would be restored to if recovery is needed
15. Conclusion
“Applied PowerShell” does not mean using lots of difficult syntax. What pays off in real work is having established this pattern:
- 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 too:
- File organization
- Report generation
- CSV aggregation
- Batch file replacement
- Inventorying old assets
- Daily and monthly operational checks
PowerShell is handy even as one-liners, 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 becomes not just a time-saving tool but a small business application that stabilizes your operations.
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.
How to Run PowerShell from C# (CSharp) and Receive the Results as Objects
How to launch PowerShell from C# and receive results as PSObject rather than strings — a practical walkthrough of the PowerShell SDK, Add...
Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break
A practical walkthrough of testing PowerShell scripts with Pester v5 — safely covering date handling, file operations, deletion logic, mo...
Practical PowerShell Command Recipes — Growing the Small Tools You Use Every Day
A practical roundup of PowerShell commands for everyday work, covering where to use Measure-Object, Group-Object, Select-String, Compare-...
PowerShell Command Basics — The Operations to Learn First and How to Use Them Safely
So that PowerShell beginners never get lost in real work, this article covers how to find cmdlets, the pipeline, file operations, CSV pro...
Preparing for VBScript Deprecation: An Audit Guide for VBA and Internal Tools
Preparing for the phased deprecation of VBScript: inventorying VBA, Excel macros, and internal tools, static detection, execution logging...
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.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links