Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.
The Problem
Windows writes an enormous amount of operational truth into the Application, System, and Security event logs — service crashes, disk errors, failed logons, driver failures — but most of it is only ever looked at reactively, after someone already noticed something was wrong. Event Viewer isn’t built for continuous monitoring, and standing up a full log-shipping pipeline (Winlogbeat, a SIEM, etc.) is a lot of infrastructure if what you need on a given box is “email me when the Print Spooler dies again.”
Ruby’s win32-eventlog gem gives you direct access to the native Windows Event Log API from a plain script. Combined with a small rules engine, you can poll for new events, classify them against patterns you actually care about, and fire off an alert — all from one file, no agent install required.
Prerequisites
- Ruby 3.x on Windows (this script is Windows-only by nature — it wraps the Win32 Event Log API)
- The
win32-eventloggem:gem install win32-eventlog - Read access to the target event log (Application and System are readable by any authenticated user by default; Security requires elevated/administrative rights)
- An SMTP relay reachable from the host if you want email alerts (the script uses Ruby’s stdlib
net/smtp, no extra gem needed)
How It Works
The script is deliberately split into a Windows-specific event source and an OS-agnostic pipeline. Only one class, WindowsEventSource, touches the actual Win32 API — everything downstream of it (rule matching, digest formatting, mailing) works with a plain Ruby struct, which is what makes the matching logic testable on any machine, including the Linux box this article was written and verified on.

Example rules file
# rules.yml
- source: "Service Control Manager"
level: ["error", "critical"]
message_regex: "terminated unexpectedly"
alert_name: "service_crash"
- event_id: 4625
alert_name: "failed_logon"
- level: ["error"]
alert_name: "generic_error"
The full script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# eventlog_watch.rb — Poll the Windows System/Application event logs for
# new Error/Critical entries and send an email alert when patterns you
# care about show up (service crashes, disk errors, failed logons, etc).
#
# Usage (on Windows, with the win32-eventlog gem installed):
# ruby eventlog_watch.rb --log System --since-minutes 15
# ruby eventlog_watch.rb --log Application --rules rules.yml --mail-to ops@example.com
#
# Requires: Ruby 3.x on Windows, `gem install win32-eventlog`.
# The classification/formatting logic below has no Windows dependency and
# is covered by the test suite in test_eventlog_watch.rb, which runs on
# any OS by injecting fake events instead of reading the real event log.
require 'yaml'
require 'time'
require 'ostruct'
require 'optparse'
# ---------------------------------------------------------------------------
# EventRecord — a small, OS-agnostic representation of one event log entry.
# On Windows this gets built from a Win32::EventLog record; in tests it's
# built by hand. Keeping this shape narrow is what lets the rest of the
# script stay portable.
# ---------------------------------------------------------------------------
EventRecord = Struct.new(:source, :category, :event_id, :level, :time_generated, :message, keyword_init: true)
# ---------------------------------------------------------------------------
# RuleSet — loads matching rules from YAML and decides whether an event is
# alert-worthy. A rule matches on source (substring) and/or event_id, and
# optionally a regex against the message body.
# ---------------------------------------------------------------------------
class RuleSet
DEFAULT_RULES = [
{ 'level' => %w[error critical], 'alert_name' => 'generic_error' }
].freeze
def self.load(path)
return new(DEFAULT_RULES) unless path && File.exist?(path)
new(YAML.safe_load_file(path))
end
def initialize(rules)
@rules = rules
end
# Returns the alert_name of the first matching rule, or nil if the event
# doesn't match anything we care about.
def match(event)
@rules.each do |rule|
next if rule['source'] && !event.source.to_s.include?(rule['source'])
next if rule['event_id'] && event.event_id != rule['event_id']
next if rule['level'] && !Array(rule['level']).include?(event.level.to_s.downcase)
next if rule['message_regex'] && !(event.message.to_s =~ Regexp.new(rule['message_regex']))
return rule['alert_name'] || 'match'
end
nil
end
end
# ---------------------------------------------------------------------------
# AlertFormatter — turns a batch of (event, alert_name) pairs into a
# human-readable digest, used for both the console summary and the email
# body.
# ---------------------------------------------------------------------------
module AlertFormatter
def self.digest(matches)
return "No matching events." if matches.empty?
lines = ["#{matches.size} event(s) matched alert rules:\n"]
matches.each do |event, alert_name|
lines << format(
"[%s] %-20s id=%-6s %-15s %s",
event.time_generated.strftime('%Y-%m-%d %H:%M:%S'),
event.source,
event.event_id,
"(#{alert_name})",
truncate(event.message, 100)
)
end
lines.join("\n")
end
def self.truncate(str, len)
s = str.to_s.gsub(/\s+/, ' ').strip
s.length > len ? "#{s[0, len]}..." : s
end
end
# ---------------------------------------------------------------------------
# WindowsEventSource — the only Windows-specific piece. It wraps
# Win32::EventLog and yields EventRecord objects. Everything else in this
# file works with plain EventRecord structs, so it can be exercised on any
# platform.
# ---------------------------------------------------------------------------
class WindowsEventSource
LEVELS = {
1 => 'error', # EVENTLOG_ERROR_TYPE
2 => 'warning', # EVENTLOG_WARNING_TYPE
4 => 'info', # EVENTLOG_INFORMATION_TYPE
16 => 'critical' # EVENTLOG_AUDIT_FAILURE (treated as high severity here)
}.freeze
def initialize(log_name)
require 'win32/eventlog'
@log_name = log_name
rescue LoadError
raise "win32-eventlog gem not found. Run: gem install win32-eventlog (Windows only)."
end
# Yields EventRecord for every entry newer than `since`.
def each_since(since)
Win32::EventLog.open(@log_name) do |log|
log.read(Win32::EventLog::FORWARDS_READ | Win32::EventLog::SEQUENTIAL_READ) do |entry|
generated = Time.at(entry.time_generated)
next if generated < since
yield EventRecord.new(
source: entry.source,
category: entry.category,
event_id: entry.event_id & 0xFFFF, # low word is the real event ID
level: LEVELS.fetch(entry.event_type, 'unknown'),
time_generated: generated,
message: entry.description
)
end
end
end
end
# ---------------------------------------------------------------------------
# Watcher — the orchestration layer. Takes any object that responds to
# `each_since(time) { |event| ... }`, which lets us swap in
# WindowsEventSource for real runs and a FakeEventSource for tests.
# ---------------------------------------------------------------------------
class Watcher
def initialize(source, rules)
@source = source
@rules = rules
end
def matches_since(since)
matches = []
@source.each_since(since) do |event|
alert_name = @rules.match(event)
matches << [event, alert_name] if alert_name
end
matches
end
end
# ---------------------------------------------------------------------------
# Mailer — thin wrapper so alerting doesn't require a heavyweight mail gem.
# Uses `net/smtp` from the stdlib against a relay you specify; swap in your
# own transport (e.g. a Slack webhook) by replacing `send!`.
# ---------------------------------------------------------------------------
class Mailer
def initialize(smtp_host:, from:)
@smtp_host = smtp_host
@from = from
end
def send!(to:, subject:, body:)
require 'net/smtp'
msg = <<~MSG
From: #{@from}
To: #{to}
Subject: #{subject}
#{body}
MSG
Net::SMTP.start(@smtp_host) { |smtp| smtp.send_message(msg, @from, [to]) }
end
end
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
options = { log: 'System', since_minutes: 15, rules: nil, mail_to: nil, smtp_host: 'localhost' }
OptionParser.new do |opts|
opts.on('--log NAME', 'Event log to read: System, Application, Security') { |v| options[:log] = v }
opts.on('--since-minutes N', Integer) { |v| options[:since_minutes] = v }
opts.on('--rules PATH', 'YAML rules file') { |v| options[:rules] = v }
opts.on('--mail-to ADDRESS') { |v| options[:mail_to] = v }
opts.on('--smtp-host HOST') { |v| options[:smtp_host] = v }
end.parse!(ARGV)
rules = RuleSet.load(options[:rules])
source = WindowsEventSource.new(options[:log])
watcher = Watcher.new(source, rules)
since = Time.now - (options[:since_minutes] * 60)
matches = watcher.matches_since(since)
digest = AlertFormatter.digest(matches)
puts digest
if options[:mail_to] && !matches.empty?
Mailer.new(smtp_host: options[:smtp_host], from: 'eventlog-watch@localhost')
.send!(to: options[:mail_to], subject: "[eventlog_watch] #{matches.size} alert(s) on #{options[:log]}", body: digest)
puts "\nAlert email sent to #{options[:mail_to]}."
end
end
Walkthrough
EventRecord is the seam the whole design pivots on. It's a plain Struct with exactly the fields you'd want from an event log entry — source, event ID, severity level, timestamp, message — and nothing that ties it to Windows. WindowsEventSource is the only class that knows how to produce one: it opens the log via Win32::EventLog.open, reads forward sequentially, and maps the raw event_type integer and packed event_id (Windows stores facility/severity bits in the high word) into the plain fields on EventRecord.
RuleSet#match is a small rules engine: each rule can constrain on source (substring match, since Windows source names are inconsistent about exact casing/suffixes), event_id (exact match), level, and a message_regex for free-text matching against the description. Rules are checked in order and the first match wins, which means you should put your most specific rules (like matching a particular service crash) before catch-all rules (like "anything at error level").
Watcher is where the portability payoff shows up: it depends only on its source responding to each_since(time) { |event| ... }. In production that's a WindowsEventSource; in the test suite it's a FakeEventSource that iterates over an in-memory array of hand-built EventRecords. Neither Watcher nor RuleSet nor AlertFormatter has any idea which one it's talking to — that's dependency injection doing exactly what it's for.
Mailer is intentionally minimal — a single method wrapping Net::SMTP from the standard library. If your organization alerts through Slack or PagerDuty instead of email, this is the one class to swap out; nothing else in the script needs to change.
Example Output
The classification and formatting logic was verified with a fake event source feeding five synthetic events (two stale, three matching) through the real RuleSet, Watcher, and AlertFormatter classes — no Windows machine required to confirm the logic is correct:
$ ruby test_eventlog_watch.rb
3 event(s) matched alert rules:
[2026-07-18 16:47:08] Service Control Manager id=7031 (service_crash) The Print Spooler service terminated unexpectedly.
[2026-07-18 16:46:08] disk id=7 (generic_error) The device, \Device\Harddisk0\DR0, has a bad block.
[2026-07-18 16:46:38] Microsoft-Windows-Security-Auditing id=4625 (failed_logon) An account failed to log on.
ALL EVENTLOG ASSERTIONS PASSED
DEFAULT RULESET ASSERTIONS PASSED
On an actual Windows host, running against the real System log looks the same, just sourced from Win32::EventLog instead of the fake:
C:\scripts> ruby eventlog_watch.rb --log System --since-minutes 30 --rules rules.yml --mail-to oncall@example.com
2 event(s) matched alert rules:
[2026-07-18 09:14:02] Service Control Manager id=7031 (service_crash) The Print Spooler service terminated unexpectedly.
[2026-07-18 09:12:41] disk id=7 (generic_error) The device, \Device\Harddisk2\DR2, has a bad block.
Alert email sent to oncall@example.com.
Troubleshooting
- "win32-eventlog gem not found" on Windows — run
gem install win32-eventlog. If you're on a 64-bit Ruby install and still see load errors, confirm you're using a Ruby build that isn't the WSL/Linux one by accident — this script must run under native Windows Ruby. - Reading the Security log fails with an access error — the Security log requires the process to run elevated (as Administrator) or under an account with the Manage auditing and security log privilege. Application and System don't have this restriction.
- No matches even though Event Viewer clearly shows errors — check
--since-minutes; the script only looks at events newer than that window. Also confirm your rules file path is correct — a missing/misspelled--rulespath silently falls back toRuleSet::DEFAULT_RULES, which only catches error/critical level events, not custom patterns. - Email alert never arrives — verify the SMTP relay in
--smtp-hostaccepts unauthenticated relay from this host, or extendMailer#send!to pass credentials toNet::SMTP.startif your relay requires them. - Testing rule changes without waiting for a real event — copy the pattern in
test_eventlog_watch.rb: buildEventRecordinstances by hand, wrap them in a tinyFakeEventSource, and run them through your realRuleSetandWatcher. This is faster than triggering a real service crash to test one regex.
Extending This Script
- State file for "since" — instead of a fixed
--since-minuteswindow, persist the timestamp of the last event processed to a small file so a scheduled task run every 5 minutes never double-alerts or misses a gap. - Multiple logs in one run — accept a comma-separated
--log System,Applicationand merge results across sources before formatting the digest. - Deduplication / rate limiting — if a flapping service generates the same
alert_namefifty times in an hour, collapse repeats into a single "occurred 50 times" line instead of fifty emails. - Remote collection —
Win32::EventLog.openaccepts a remote host argument, so the same script can poll a small fleet of Windows servers from one central box (pairs nicely with the SSH fleet automation pattern from this series, adapted for WinRM). - Structured export — add a
--jsonflag mirroring the other scripts in this series so matches can be shipped to a log aggregator instead of (or in addition to) email.


