Applied PowerShell Scripting — Safely Automating Log Investigation, Archiving, and Reporting

· · 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:

  1. Investigate logs
  2. Compile error lines into a CSV
  3. List old logs as archive candidates
  4. Move old logs if needed
  5. Keep the run results as an audit trail

What matters in applied PowerShell is not making commands longer, but establishing the following flow:

  1. Separate out the configuration
  2. Build the read-only processing
  3. Keep the output
  4. Split the change processing into functions
  5. Rehearse with -WhatIf
  6. 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.csv
  • archive-targets.csv
  • archive-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.exe resolves
  • It conforms to your script execution policy and signing rules
  • Manual runs and task runs produce the same results
  • On failure, error.txt and 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.csv were reasonable
  • The Destination values in archive-result.csv matched expectations
  • A timestamped audit trail remained in the output folder
  • Confirmed that error.txt is 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.

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Back to the Blog