Automating log checks helps answer two routine questions: “What failed?” and “Are failures increasing?” These PowerShell and Bash scripts search logs, count errors, and send alerts on Linux, macOS, and Windows Server. Each also writes a CSV summary you can collect centrally.
The examples use a five-minute window and alert at three errors. That makes them easy to test. Set the threshold to match each host’s normal error rate before you schedule the checks.
What Are PowerShell and Bash Log Analysis Tools?
Bash combines text tools such as grep, awk, sed, and tail. PowerShell reads Windows events as objects with Get-WinEvent and searches files with Select-String. Both can run unattended and send an HTTP webhook when the error count reaches a threshold.
For a few servers with known problems to watch, these scripts are usually quicker to deploy than Elasticsearch, Logstash, and Kibana (ELK) or Splunk. They have limits: no durable central ingestion, access controls, long-term retention, or cross-host correlation. They also lack the investigation tools of a security information and event management (SIEM) system. If you need those across many hosts, or must prove local logs haven’t been changed, use a logging platform.
Prerequisites
- A Linux server running Ubuntu 24.04 LTS or Debian 12, a current macOS release, or Windows 11/Windows Server 2022 or later.
- A terminal session on each host. Use an account that can read the logs you need; some Windows event channels and Linux journal entries require elevated access.
- Bash,
grep,awk,sed, andtailon Linux or macOS. The Bash script expects UTC timestamps in the exact formatYYYY-MM-DDTHH:MM:SSZ. jqfor the JSON examples and Bash webhook payload. The Bash alert still writes CSV summaries without a webhook.- PowerShell 7 or Windows PowerShell 5.1 on Windows. The Windows commands use cmdlets available in both.
curl7.82 or later only if you want the Bash script to send a webhook withcurl --json.- About 20 MB of free disk space for scripts and test logs. Production space depends on log volume and retention.
The commands target Ubuntu 24.04 LTS, macOS with its bundled Bash, and Windows Server 2022 with PowerShell 7. Check versions on your hosts before you schedule jobs. A UPS helps a server keep collecting logs through short power cuts. A NAS can hold the small CSV summaries.
Step-by-Step Guide
Step 1: Install the tools and confirm your shell
Linux: Install jq and curl, then check their versions. Debian 12 uses the same commands.
sudo apt update
sudo apt install -y jq curl
bash --version
jq --version
curl --version
Expect version lines for Bash, jq, and curl. Only webhook delivery needs the newer curl.
macOS: Open Terminal from Applications > Utilities. macOS includes Bash and the text tools. If you use Homebrew, install jq and check curl:
brew install jq
bash --version
jq --version
curl --version
If brew is unavailable, follow Homebrew’s instructions or skip the jq examples. Check that curl is at least 7.82 before you enable the Bash webhook.
Windows: Open Windows Terminal or PowerShell and check the engine:
$PSVersionTable.PSVersion
$PSVersionTable.PSEdition
Windows PowerShell 5.1 can run the Windows script. For PowerShell 7, follow Microsoft’s installation instructions. Then open pwsh.exe and repeat the checks. PowerShell 7 installs alongside Windows PowerShell 5.1.
Step 2: Create a sample log you can search
On Linux or macOS, create a working directory and a log with three recent errors. The script will count them later.
mkdir -p "$HOME/logwatch"
now_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
printf '%s INFO service started\n%s ERROR database timeout\n%s ERROR request failed\n%s FATAL worker stopped\n' \
"$now_utc" "$now_utc" "$now_utc" "$now_utc" \
> "$HOME/logwatch/app.log"
cat "$HOME/logwatch/app.log"
Expected output: four lines with the current UTC timestamp. One is INFO, two are ERROR, and one is FATAL.
On Windows, create the script directory. The alert script reads Windows Event Logs, so it needs no sample file.
New-Item -ItemType Directory -Path 'C:\LogWatch' -Force | Out-Null
Get-Item 'C:\LogWatch'
Expected result: a directory entry for C:\LogWatch.
Step 3: Tail and filter plain-text logs with Bash
Use tail for recent lines, grep for matches, and awk for selected fields. Use sed to change displayed text when needed.
tail -n 20 "$HOME/logwatch/app.log"
grep -nE 'ERROR|FATAL' "$HOME/logwatch/app.log"
awk '$2 == "ERROR" || $2 == "FATAL" { print $1, $2, $3 }' "$HOME/logwatch/app.log"
sed 's/ database timeout/ database timeout (check upstream)/' "$HOME/logwatch/app.log"
grep -nE shows line numbers and lets | mean “or.” The awk command prints the timestamp, severity, and first message word. The sed command changes the display; it doesn’t edit the log file.
Expected result: grep finds three lines, and awk prints three short entries.
For a live file, use:
tail -f "$HOME/logwatch/app.log"
Press Ctrl+C to stop. On GNU tail, common on Linux, tail -F follows a filename across rotation. macOS tail options vary by release; check man tail before you depend on that behavior.
Step 4: Parse JSON and CSV logs in Bash
For newline-delimited JSON, give jq one JSON object per line. Create a test file:
cat > "$HOME/logwatch/app.jsonl" <<'JSON'
{"level":"info","service":"api","status":200}
{"level":"error","service":"api","status":500}
{"level":"error","service":"worker","status":503}
JSON
jq -r 'select(.level == "error") | [.service, .status] | @tsv' "$HOME/logwatch/app.jsonl"
Expected output:
api 500
worker 503
-r writes plain text instead of quoted JSON strings. For CSV fields that may contain commas or quotes, use a CSV parser. jq parses JSON. The awk -F, shortcut below works only for fixed, unquoted CSV fields.
cat > "$HOME/logwatch/status.csv" <<'CSV'
timestamp,service,status
2026-09-27T12:00:00Z,api,200
2026-09-27T12:01:00Z,api,500
CSV
awk -F, 'NR == 1 || $3 + 0 >= 500' "$HOME/logwatch/status.csv"
Expected result: the header and the 500 row. For more structured filters, see the official jq manual.
Step 5: Watch new events in real time
On a systemd-based Linux server, follow the journal for one service:
journalctl -u ssh.service -n 20 -f
-u selects the unit, -n 20 shows its last 20 entries, and -f waits for new ones. Service names vary by distribution. Find yours with systemctl list-units --type=service. Press Ctrl+C to stop.
systemctl list-units --type=service
macOS doesn’t use journalctl. Use tail -f for a file log, or Apple’s log stream for the unified log:
log stream --style compact --level error
log stream may include many processes. Once you know the process name, narrow the output with a predicate:
log stream --style compact --level error --predicate 'process == "ssh"'
Expected result: new matching messages appear when they’re recorded. An idle service may show no new lines.
Step 6: Create the cron-ready Bash error-count script
This script reads a file whose first field is a UTC ISO 8601 timestamp, such as 2026-09-27T12:00:00Z. It counts ERROR and FATAL lines from the last five minutes and appends a CSV row. It can also post a JSON alert. The alert sends counts, not raw log lines.
Create the script on Linux or macOS:
cat > "$HOME/logwatch/check-errors.sh" <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
log_file="${1:-$HOME/logwatch/app.log}"
output_dir="$HOME/logwatch"
threshold="${ERROR_THRESHOLD:-3}"
webhook_file="$output_dir/webhook.url"
if [[ ! -r "$log_file" ]]; then
printf 'Cannot read log: %s\n' "$log_file" >&2
exit 1
fi
case "$threshold" in
''|*[!0-9]*) printf 'ERROR_THRESHOLD must be a nonnegative integer\n' >&2; exit 1 ;;
esac
# GNU date is used on Linux; BSD date is used on macOS.
if [[ "$(uname -s)" == "Darwin" ]]; then
since_utc=$(date -u -v-5M '+%Y-%m-%dT%H:%M:%SZ')
else
since_utc=$(date -u -d '5 minutes ago' '+%Y-%m-%dT%H:%M:%SZ')
fi
now_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
host_name=$(hostname)
# ISO 8601 UTC timestamps sort in time order as strings.
count=$(awk -v start="$since_utc" -v end="$now_utc" \
'$1 >= start && $1 <= end && ($2 == "ERROR" || $2 == "FATAL") { n++ }
END { print n+0 }' "$log_file")
mkdir -p "$output_dir"
summary="$output_dir/summary.csv"
if [[ ! -e "$summary" ]]; then
printf 'timestamp_utc,host,source,error_count\n' > "$summary"
fi
printf '%s,%s,%s,%s\n' "$now_utc" "$host_name" "app.log" "$count" >> "$summary"
printf '%s: %s errors in the last five minutes\n' "$now_utc" "$count"
if (( count >= threshold )) && [[ -s "$webhook_file" ]]; then
webhook_url=$(< "$webhook_file")
if [[ "$webhook_url" == http://* || "$webhook_url" == https://* ]]; then
message="$host_name: $count log errors in the last five minutes"
payload=$(jq -nc --arg text "$message" \
--arg host "$host_name" --argjson count "$count" \
'{text:$text,host:$host,error_count:$count}')
curl --fail --silent --show-error --json "$payload" "$webhook_url"
printf '\n'
else
printf 'Webhook URL must start with http:// or https://\n' >&2
exit 1
fi
fi
BASH
chmod 700 "$HOME/logwatch/check-errors.sh"
"$HOME/logwatch/check-errors.sh"
cat "$HOME/logwatch/summary.csv"
Expected result: 3 errors in the last five minutes and a CSV row ending in ,3. If setup took several minutes, rerun Step 2 to refresh the timestamps.
The input format matters. Traditional syslog lines, Apache access logs, and logs with local timestamps need different parsing rules. Set the right rule before you schedule the script. An untimed file can’t give you a reliable five-minute count.
Step 7: Find Windows errors with Get-WinEvent
On Windows, press Win+R, run eventvwr.msc, and open Windows Logs > Application. The next command queries that channel.
In PowerShell, query recent level-2 errors with an XPath filter:
Get-WinEvent -LogName Application `
-FilterXPath '*[System[(Level=2) and TimeCreated[timediff(@SystemTime) <= 300000]]]' `
-MaxEvents 20 |
Select-Object TimeCreated, Id, ProviderName, Message
Level=2 means error. 300000 is five minutes in milliseconds. -MaxEvents 20 caps the displayed results; raise or remove it for a fuller search. With no matching errors, PowerShell reports that no events were found. That’s a valid result.
Get-EventLog is absent from PowerShell 7. Use Get-WinEvent for Windows Event Log queries. Microsoft’s PowerShell compatibility notes help when you maintain older scripts.
Step 8: Search text files and parse IIS/W3C logs in PowerShell
To search text logs, create two small files and run Select-String:
Set-Content 'C:\LogWatch\api.log' 'INFO ready','ERROR request failed'
Set-Content 'C:\LogWatch\worker.log' 'INFO ready','ERROR job failed'
Get-ChildItem 'C:\LogWatch' -Filter '*.log' |
Select-String -Pattern 'ERROR|FATAL' |
Select-Object Path, LineNumber, Line
Expected result: two matches with file paths and line numbers. Select-String uses regular expressions by default.
IIS uses the W3C Extended Log File Format. Its #Fields: line names the columns, which can change with IIS logging settings. In PowerShell 7 (6.0 and later), Import-Csv reads that format natively: it skips # comment lines and takes the column names from the #Fields: header. Find the newest log and filter server errors:
$logPath = Get-ChildItem 'C:\inetpub\logs\LogFiles' -Recurse -Filter 'u_ex*.log' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 -ExpandProperty FullName
if (-not $logPath) { throw 'No IIS W3C log found under C:\inetpub\logs\LogFiles' }
Import-Csv -Path $logPath -Delimiter ' ' |
Where-Object { [int]$_.'sc-status' -ge 500 } |
Select-Object -First 10 date, time, 'cs-uri-stem', 'sc-status'
Expected result: up to ten requests with HTTP status 500 or higher. No such responses means no rows.
Windows PowerShell 5.1 lacks native W3C support, so there you read the #Fields: line yourself and pass it as the header:
$fieldsLine = Get-Content $logPath |
Where-Object { $_ -like '#Fields:*' } |
Select-Object -Last 1
if (-not $fieldsLine) { throw "No #Fields header found in $logPath" }
$fields = ($fieldsLine -replace '^#Fields:\s*', '') -split '\s+'
$rows = Get-Content $logPath |
Where-Object { $_ -and $_ -notlike '#*' } |
ConvertFrom-Csv -Delimiter ' ' -Header $fields
$rows |
Where-Object { [int]$_.'sc-status' -ge 500 } |
Select-Object -First 10 date, time, 'cs-uri-stem', 'sc-status'
The result matches the PowerShell 7 command. The 5.1 version loads the whole IIS log with Get-Content, so select a single date file for multi-gigabyte logs. Prefer the PowerShell 7 command wherever pwsh.exe is installed; it needs no header handling of its own.
Step 9: Create the Scheduled-Task-ready PowerShell alert script
This script counts Windows Application errors from the last five minutes. It writes the same CSV columns as the Bash script and can send a webhook. Run PowerShell as the account that will own the scheduled task.
@'
$ErrorActionPreference = 'Stop'
$directory = 'C:\LogWatch'
$summary = Join-Path $directory 'summary.csv'
$webhookFile = Join-Path $directory 'webhook.url'
$threshold = 3
$endTime = Get-Date
$startTime = $endTime.AddMinutes(-5)
$hostName = $env:COMPUTERNAME
New-Item -ItemType Directory -Path $directory -Force | Out-Null
try {
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'
Level = 2
StartTime = $startTime
EndTime = $endTime
} -ErrorAction Stop)
$count = $events.Count
}
catch {
if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') {
$count = 0
}
else {
throw
}
}
$row = [pscustomobject]@{
timestamp_utc = $endTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
host = $hostName
source = 'Application'
error_count = $count
}
if (Test-Path $summary) {
$row | Export-Csv -Path $summary -NoTypeInformation -Append
}
else {
$row | Export-Csv -Path $summary -NoTypeInformation
}
Write-Output "$($row.timestamp_utc): $count errors in the last five minutes"
if ($count -ge $threshold -and (Test-Path $webhookFile)) {
$webhookUrl = (Get-Content $webhookFile -Raw).Trim()
if ($webhookUrl -notmatch '^https?://') {
throw 'Webhook URL must start with http:// or https://'
}
$body = @{
text = "${hostName}: $count Application errors in the last five minutes"
host = $hostName
error_count = $count
} | ConvertTo-Json -Compress
Invoke-RestMethod -Uri $webhookUrl -Method Post `
-ContentType 'application/json' -Body $body | Out-Null
}
'@ | Set-Content -Path 'C:\LogWatch\Check-Errors.ps1' -Encoding UTF8
& 'C:\LogWatch\Check-Errors.ps1'
Import-Csv 'C:\LogWatch\summary.csv' | Select-Object -Last 1
Expected result: a line with the error count and a CSV row whose source is Application. A healthy machine may report zero.
Step 10: Configure and test a webhook in a browser
Web: For a test, open Webhook.site and copy the unique URL. Its request list shows the JSON each script sends. Use an endpoint you control for operational alerts. Send only test counts and hostnames to a public test endpoint.
On Linux or macOS, save the copied URL and restrict access. Replace YOUR_WEBHOOK_URL with that URL:
printf '%s\n' 'YOUR_WEBHOOK_URL' > "$HOME/logwatch/webhook.url"
chmod 600 "$HOME/logwatch/webhook.url"
"$HOME/logwatch/check-errors.sh"
On Windows, save the URL and run the script. The event count must reach the threshold before it sends a request.
Set-Content -Path 'C:\LogWatch\webhook.url' -Value 'YOUR_WEBHOOK_URL'
& 'C:\LogWatch\Check-Errors.ps1'
If Windows has fewer than three real errors, change $threshold = 3 to $threshold = 0 in C:\LogWatch\Check-Errors.ps1. Run it once, then restore 3. Don’t create fake error events on a production server just to test delivery.
Expected result: Webhook.site shows a new POST with text, host, and error_count. A webhook receiver can send email, but delivery depends on that receiver or an SMTP relay. Keep mail credentials out of these scripts; set up authentication in the alert service.
Step 11: Schedule the scripts unattended
Linux and macOS: Open your user’s crontab:
crontab -e
Add this line, replacing YOUR_USER with your account name:
*/5 * * * * /bin/bash /home/YOUR_USER/logwatch/check-errors.sh /home/YOUR_USER/logwatch/app.log >> /home/YOUR_USER/logwatch/cron.log 2>&1
On macOS, use /Users/YOUR_USER in all three paths. Cron has a small environment, so the entry uses full paths. Save the file, then check the installed crontab:
crontab -l
Expected result: the five-minute entry appears. On macOS, scheduled jobs may need permission to read protected folders. Keep the test log under your home directory until the job works.
Windows: Open Task Scheduler, choose Create Task, and name it LogWatch Application Errors. On General, select an account that can read the Application log and choose Run whether user is logged on or not. On Triggers, choose New, set a start time, select Repeat task every: 5 minutes, and set for a duration of: Indefinitely. On Actions, choose New:
- Program/script:
pwsh.exeif PowerShell 7 is installed, orpowershell.exefor Windows PowerShell 5.1. - Add arguments:
-NoProfile -File "C:\LogWatch\Check-Errors.ps1" - Start in:
C:\LogWatch
Save the task and enter the account password if prompted. Right-click the task and choose Run. Check Last Run Result for 0x0, then inspect the newest CSV row:
Import-Csv 'C:\LogWatch\summary.csv' | Select-Object -Last 1
If the task fails under pwsh.exe, use the full path returned by (Get-Command pwsh).Source in an interactive PowerShell 7 session.
Step 12: Verify the output and collect it centrally
Run the Bash script again and check its summary:
"$HOME/logwatch/check-errors.sh"
tail -n 3 "$HOME/logwatch/summary.csv"
On Windows, run the task or script again and inspect its summary:
& 'C:\LogWatch\Check-Errors.ps1'
Import-Csv 'C:\LogWatch\summary.csv' | Select-Object -Last 3
Both outputs use timestamp_utc, host, source, and error_count. You can copy each host’s CSV to a folder with controlled access and chart counts by host and time. Keep a separate filename per host so jobs don’t append to the same network file. A NAS or internal file server can handle this small setup. Back up the summaries and control access if they matter to your operations.
Configuration
| Setting | Bash script | PowerShell script | Why change it |
|---|---|---|---|
| Error threshold | ERROR_THRESHOLD, default 3 | $threshold, default 3 | Set above the host’s normal five-minute count. |
| Time window | Five minutes in the date commands | $endTime.AddMinutes(-5) | Match the schedule or accept overlapping checks. |
| Input | First-field UTC timestamp; second-field ERROR or FATAL | Application log, level 2 | Match your service’s source and severity. |
| Webhook | ~/logwatch/webhook.url | C:\LogWatch\webhook.url | Leave absent to write CSV without an alert. |
| Output | ~/logwatch/summary.csv | C:\LogWatch\summary.csv | Copy per-host files to a central folder for a simple dashboard. |
The Bash script opens its file on every run, so it doesn’t hold a stale file handle. It can still miss entries moved to a rotated file during the five-minute window. For production file logs, count both the current and recent rotated file. You can also query a source that keeps the whole window, such as the systemd journal.
Keep comparison timestamps in UTC. Windows Event Log displays local time, while the exported summary uses UTC. The Bash example requires Z timestamps for the same reason.
Tips and Troubleshooting
Get-WinEvent says no events were found
The Application log may have no errors in the five-minute window. Expand the query to an hour to check access:
Get-WinEvent -FilterHashtable @{
LogName = 'Application'
StartTime = (Get-Date).AddHours(-1)
} -MaxEvents 5 | Select-Object TimeCreated, Id, LevelDisplayName
If this returns entries, the original zero count is likely correct.
The scheduled task works manually but fails on its schedule
Check Task Scheduler Library > your task > History. Confirm the account, program path, script path, and Start in directory. The scheduled account needs read access to the event channel and write access to C:\LogWatch. Run the script as that account to reproduce permission errors.
The Bash count is unexpectedly zero
Check the log’s first two fields. The script expects a UTC timestamp followed by ERROR or FATAL:
head -n 3 "$HOME/logwatch/app.log"
date -u '+%Y-%m-%dT%H:%M:%SZ'
If your app uses local time, lowercase levels, JSON, or another field order, change the awk condition. Changing only the display format won’t fix the UTC comparison.
Alerts stop after log rotation
A long-running tail -f can stay attached to an old file, while the Bash script reads only the current path. On Linux, tail -F follows the filename for interactive viewing. For scheduled counts, include the recent rotated file or query journalctl for the full window. IIS creates new W3C files over time, so repeat file discovery for each analysis run.
Large logs make searches slow
Narrow the source before you format results. Filter with Get-WinEvent -FilterHashtable or -FilterXPath before Select-Object. Choose the relevant IIS date file, and run grep before heavier awk work. The Bash alert scans its whole input file each run. When that gets costly, query a retained journal or track a safe read offset with rotation handling. Don’t load multi-gigabyte logs into one PowerShell array.
Webhook requests fail
Check that the URL file has one complete http:// or https:// URL. Confirm outbound access and that the receiver accepts JSON. The Bash webhook needs jq and a curl version that supports --json. A failed webhook stops that run after writing the CSV row. Check cron.log or Task Scheduler history for the error.
FAQ
When is a full SIEM worth the effort?
Use one for reliable collection from many hosts, long retention, role-based access, cross-system correlation, or security investigations with an audit trail. A scheduled count and CSV file fit a handful of known operational checks.
Can I tail and filter logs without installing packages?
Yes. tail, grep, awk, and sed are normally present on Linux and macOS. jq is the extra package for structured JSON.
How do I read Windows Event Logs in PowerShell 7?
Use Get-WinEvent. The older Get-EventLog cmdlet isn’t available in PowerShell 7.
Will the alert fire only once per incident?
No. It fires on each scheduled run while the count meets the threshold. Add a cooldown or deduplication rule in your webhook receiver if repeated alerts get noisy.
Can these scripts send email directly?
They can, but a webhook receiver or existing mail relay is easier to maintain than SMTP credentials in scheduled scripts. Send only counts and host metadata unless your alert channel is approved for log contents.
Wrapping Up
You now have scheduled error counts and a common CSV format for Linux, macOS, and Windows. I’d use this for targeted checks on a small fleet. For dependable cross-host history or security investigations, move the same questions into a central logging platform.
| Step | Action | Applies To |
|---|---|---|
| 1–5 | Install tools and inspect live or structured logs | Linux, macOS, Windows |
| 6–9 | Create error-count scripts | Linux, macOS, Windows |
| 10–12 | Test alerts, schedule runs, and collect summaries | Linux, macOS, Windows, web |