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-eventlog gem: 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.

Windows event log monitoring workflow: Win32::EventLog produces EventRecords, RuleSet#match classifies them, Watcher collects matches, and AlertFormatter feeds a console digest and email via Mailer, with a test harness able to substitute a FakeEventSource

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 --rules path silently falls back to RuleSet::DEFAULT_RULES, which only catches error/critical level events, not custom patterns.
  • Email alert never arrives — verify the SMTP relay in --smtp-host accepts unauthenticated relay from this host, or extend Mailer#send! to pass credentials to Net::SMTP.start if your relay requires them.
  • Testing rule changes without waiting for a real event — copy the pattern in test_eventlog_watch.rb: build EventRecord instances by hand, wrap them in a tiny FakeEventSource, and run them through your real RuleSet and Watcher. 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-minutes window, 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,Application and merge results across sources before formatting the digest.
  • Deduplication / rate limiting — if a flapping service generates the same alert_name fifty times in an hour, collapse repeats into a single "occurred 50 times" line instead of fifty emails.
  • Remote collectionWin32::EventLog.open accepts 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 --json flag mirroring the other scripts in this series so matches can be shipped to a log aggregator instead of (or in addition to) email.

Leave a Reply

Your email address will not be published. Required fields are marked *