Prometheus tells me the box is healthy. It does not tell me that the game server accepted a player, kicked them with a cryptic interaction error, and is now sitting there at 3% CPU, "up", serving nobody. Metrics see the machine. The log sees the game.

So there's a second thing running next to the metrics stack: a small Python process that tails the log, matches a handful of patterns, and posts to a Discord webhook. It's about a hundred lines. It has caught more real incidents than every dashboard I've built.

TL;DR — the whole idea:

for line in tail(LOG):
    for rule in RULES:
        if rule.pattern.search(line):
            post_to_discord(rule.format(line))

Everything below is the part that makes it survive contact with reality: not spamming, not dying, and not lying to you.

Why not just use the monitoring stack

Because the interesting failures aren't numeric. "Server is up but every join fails" has no metric. "World save threw an exception" has no metric. "Player X got kicked 40 times in a minute" has no metric. They all have a log line, and the log line usually names the problem in plain English.

You could ship the logs to Loki and alert on them there, and if you already run Loki, do that. If you don't, standing up a log-aggregation stack to notice a crash is a lot of moving parts for one job.

The watcher

#!/usr/bin/env python3
import re, time, json, urllib.request, os

WEBHOOK = os.environ["DISCORD_WEBHOOK"]
LOG     = "/opt/gameserver/logs/latest.log"
ROLE    = "<@&123456789012345678>"   # role to ping, or "" for none

RULES = [
    dict(name="crash",  level="fatal",
         pattern=re.compile(r"(FATAL|Exception in server tick|Watchdog)", re.I),
         ping=True),
    dict(name="freeze", level="fatal",
         pattern=re.compile(r"Can't keep up|Server thread stalled", re.I),
         ping=True),
    dict(name="kick",   level="warn",
         pattern=re.compile(r"kicked .* for (?!leaving)", re.I),
         ping=False),
]

def tail(path):
    while not os.path.exists(path):
        time.sleep(2)
    f = open(path, "r", errors="replace")
    f.seek(0, 2)                       # start at EOF — don't replay history
    inode = os.fstat(f.fileno()).st_ino
    while True:
        line = f.readline()
        if line:
            yield line.rstrip()
            continue
        time.sleep(0.5)
        try:                            # did logrotate swap the file under us?
            if os.stat(path).st_ino != inode:
                f.close()
                f = open(path, "r", errors="replace")
                inode = os.fstat(f.fileno()).st_ino
        except FileNotFoundError:
            pass

def post(rule, line):
    content = f"{ROLE} **{rule['name']}** on `ash`\n```{line[:1800]}```" \
              if rule["ping"] else f"**{rule['name']}**\n```{line[:1800]}```"
    req = urllib.request.Request(
        WEBHOOK,
        data=json.dumps({"content": content}).encode(),
        headers={"Content-Type": "application/json"},
    )
    try:
        urllib.request.urlopen(req, timeout=10)
    except Exception as e:
        print(f"webhook failed: {e}", flush=True)   # never crash on a failed post

last = {}
COOLDOWN = 300

for line in tail(LOG):
    for rule in RULES:
        if not rule["pattern"].search(line):
            continue
        now = time.time()
        if now - last.get(rule["name"], 0) < COOLDOWN:
            break                       # rate-limited, stay quiet
        last[rule["name"]] = now
        post(rule, line)
        break

That's it. Standard library only — no deps, no venv, no pip install breaking on a Debian upgrade.

The four things that make it usable

1. seek(0, 2) before the first read. Open a log and read from the top and you'll replay every crash from the last three months into Discord in about two seconds. Ask me how I know. Start at the end; you only care about what happens from now on.

2. Inode check for logrotate. Game servers rotate latest.log on every restart. Your file handle happily keeps pointing at the old, now-unlinked inode and you never see another line — the bot looks alive and tells you nothing, which is worse than crashing. Compare the inode, reopen when it changes. See /logrotate-linux-log-rotation/ if rotation itself is new to you.

3. Cooldown per rule. A crash loop writes the same FATAL a hundred times a minute. Without the 5-minute cooldown, Discord rate-limits you, the channel becomes unreadable, and people mute it — at which point the bot has negative value. One message per rule per five minutes is plenty; you don't need a hundred to know it's broken.

4. @-ping only on fatal. This is the rule I most want to hand to past-me. If everything pings, nothing pings. Kicks and warnings post silently; only crash and freeze ping the role. The moment you ping for something people can't or won't act on, they mute the channel and the fatal ping goes unseen too.

Run it under systemd

# /etc/systemd/system/hytale-watch.service
[Unit]
Description=Game server log watcher → Discord
After=network-online.target

[Service]
User=gamewatch
Environment=DISCORD_WEBHOOK=https://discord.com/api/webhooks/xxx/yyy
ExecStart=/usr/bin/python3 /opt/hytale-watch/watch.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
systemctl enable --now hytale-watch
journalctl -u hytale-watch -f

Restart=always because a bot that dies silently is worse than no bot — you'll believe the silence means "no incidents". If you want the full anatomy of a unit file, /systemd-service-unit-file/ covers it.

Keep the webhook URL out of the unit file if the repo is shared — anyone with that URL can post as your bot. EnvironmentFile=/etc/hytale-watch.env with chmod 600 is the better shape.

Tuning the rules is the actual work

The code is done in an hour. The rules take a week, because you have to watch what your server actually logs. My process:

# what does this thing scream about, ranked?
journalctl -u gameserver --since "7 days ago" \
  | grep -iE "error|warn|fatal|exception" \
  | sed 's/[0-9]//g' \
  | sort | uniq -c | sort -rn | head -20

Strip the numbers so timestamps and player IDs collapse together, then count. The top of that list is your noise — things that happen constantly and mean nothing. Do not alert on them. The bottom of the list, the things that happened once or twice, is where the real incidents hide.

I started with a rule on every ERROR. Two hundred messages the first day, most of them a harmless plugin whining about a missing optional config. Now I match FATAL and two specific strings, and when it fires, something is genuinely wrong.

Match Ping Why
FATAL, Watchdog, Exception in server tick yes Server is dying or dead
Can't keep up, stalled yes Freeze — players feel this before you do
kicked ... for <reason> no Useful signal, not an emergency
Generic ERROR never Noise. This is a trap.

Where it fits

Two layers, and they answer different questions.

Prometheus + Grafana answers "is the machine healthy?" — CPU, memory, network, is the unit active. See /monitor-gameserver-wireguard-prometheus/ for the setup I run.

The log bot answers "is the game working?" — which is the question your players are actually asking when they DM you. It doesn't replace metrics and it doesn't want to. It just reads the one file that already contains the answer, and tells you before someone else does.

That's a good hundred lines of code.


Related posts