A cron job on a free server plus a free model can turn the last hour of error logs into a structured incident draft while you sleep. I built that pipeline so I would stop writing the same 3 AM report twice—once half-asleep, and again after sunrise.
Why Our Night Shift Started With Scrolling
We ran three small services on a shared VPS. No dedicated SRE, no log platform, just plain text files. When something broke at night, I woke up, ssh'd in, and scrolled hundreds of stack traces before I could name the service.
Scrolling was the expensive part. I can find a root cause in minutes, but only after twenty minutes of noise. I wanted a tool that did the reading first and handed me a coherent story: what broke, which service, how bad, and where to look next.
Budget beat every framework debate. This was a side project with no revenue, so every piece had to be free. Compared with buying an observability stack, the tradeoff was explicit: accept a draft, not a diagnosis, and forbid the machine from inventing facts.
Goal: A Draft on Free Servers, Not a Diagnosis
The goal was deliberately modest. The pipeline would not fix anything and would not page anyone. Every hour it would cover the previous sixty minutes of logs, write a structured file, and leave it where I could open it on my phone.
Two availability claims made the project feasible: MonkeyCode's free model access (ten million tokens) and its free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Both claims are current as of writing, but quotas and terms can change, so verify them before you build on top of them.
I used a free model for summarization. Free models are good enough for structured drafts when you feed unique errors, not forty copies of the same timeout. I deployed the reader on a free server. Free servers are for evaluation and light workloads, not high-traffic production.
Success criteria I actually used:
- Accurate enough that a half-asleep developer could trust the summary
- Cheap enough that an hourly run cost nothing
- Structured enough to read on a phone at 3 AM
How I Built the Three-Stage Pipeline
The pipeline has three stages: log loading, deduplication, and summarization.
- Read the last hour of each log file (keep at most 200 matching lines per file).
- Collapse near-identical lines into unique errors with a hash.
- Send the deduplicated batch to a free model with a strict JSON schema.
Deduplication is the real feature
Raw logs repeat. One database timeout can produce forty stack traces that differ only by timestamp. Sending all forty wastes tokens and dilutes the signal. I strip the timestamp, keep alphanumeric characters plus .:_/, hash the remainder with Python hashlib, and keep the first occurrence of each digest.
# incident_drafter.py
import hashlib
import json
import os
from datetime import datetime, timedelta, timezone
from openai import OpenAI
client = OpenAI(
base_url=os.environ["MONKEYCODE_BASE_URL"],
api_key=os.environ["MONKEYCODE_API_KEY"],
)
SYSTEM_PROMPT = """You are an on-call engineer writing an incident report draft.
Given a batch of raw error log lines, produce JSON with:
- "summary": one sentence describing what happened
- "affected_services": list of service names
- "likely_cause": best guess at root cause
- "suggested_next_steps": exactly 3 concrete debugging actions
- "severity": "low", "medium", "high", or "critical"
Base every claim on the log lines. Never invent facts."""
def load_logs(path: str, since: datetime) -> list[str]:
lines = []
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
try:
ts = datetime.fromisoformat(line.split(" ")[0])
if ts >= since:
lines.append(line.strip())
except (ValueError, IndexError):
continue
except FileNotFoundError:
pass
return lines[-200:]
def dedupe(lines: list[str]) -> list[str]:
seen, unique = set(), []
for line in lines:
body = " ".join(line.split(" ")[1:])
key = "".join(c for c in body if c.isalnum() or c in ".:_/")
digest = hashlib.md5(key.encode()).hexdigest()
if digest not in seen:
seen.add(digest)
unique.append(line)
return unique
def draft_report(lines: list[str]) -> dict:
if not lines:
return {"summary": "No errors in this window.", "severity": "low"}
batch = "\n".join(lines[-100:])
resp = client.chat.completions.create(
model=os.environ["MONKEYCODE_MODEL"],
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": batch},
],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
def main() -> None:
since = datetime.now(timezone.utc) - timedelta(hours=1)
raw = []
for path in os.environ["LOG_FILES"].split(","):
raw.extend(load_logs(path.strip(), since))
unique = dedupe(raw)
report = draft_report(unique)
report["window_start"] = since.isoformat()
report["unique_error_count"] = len(unique)
report["raw_line_count"] = len(raw)
os.makedirs("incidents", exist_ok=True)
out_path = f"incidents/{since.date().isoformat()}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
I pin temperature=0 and response_format={"type": "json_object"} so the free model returns fields I can render, not a paragraph I have to parse at 3 AM. The JSON contract is summary, affected_services, likely_cause, suggested_next_steps (exactly three), and severity.
Cron plus a tiny FastAPI reader
The crontab entry is one line. The report server is a small FastAPI app:
5 * * * * cd /home/user/incident-drafter && python incident_drafter.py
# server.py
import json
from pathlib import Path
from fastapi import FastAPI
app = FastAPI()
@app.get("/incidents")
def list_incidents():
files = sorted(Path("incidents").glob("*.json"), reverse=True)
return [json.loads(f.read_text(encoding="utf-8")) for f in files[:10]]
Deploying on the free server is two commands:
pip install fastapi uvicorn openai
uvicorn server:app --host 0.0.0.0 --port 8000
At 3 AM I open /incidents, read the summary, and decide whether to dig or sleep.
| Approach | Night cost | What I get |
|---|---|---|
| SSH and scroll | 20+ minutes of noise | Unstructured memory |
| This pipeline | $0 on a free tier | JSON draft with next steps |
What Worked, What Failed, and Who Should Skip This
The pipeline ran three weeks before I trusted it.
Week one was calibration. Severity ratings were too conservative: everything came back "medium", even a single failed cron job. I mapped error frequency to severity before the model saw the batch, and the ratings became useful.
Week two produced the best moment. A database connection pool exhausted at 2:47 AM. The summary said "connection pool exhaustion in the auth service," listed "check for connection leaks in the session handler" as the first next step, and rated it "high". I checked the session handler, found a missing close() on an error path, and deployed a fix before standup. The draft saved roughly thirty minutes of scrolling.
Week three revealed the boundary. A new deploy panicked before the service could write logs. The pipeline saw nothing, wrote "No errors in this window", and the outage waited for a customer. A log summarizer can only summarize what the logs contain.
Lessons I would not skip:
- Dedup first. Summarization quality matters less than the input batch. The hash step turns two hundred noisy lines into twenty meaningful ones.
- Structure the draft. Severity, services, and next steps make the file readable on a phone. Free-form prose would have been ignored.
- Add a heartbeat. Silence looks identical to a clean night. I page if cron has not written a file for two hours.
Who should not use this: teams with strict data-residency rules should check where the free server runs before sending logs. Teams under a compliance regime that forbids sending logs to external APIs should not use this design at all. Anyone who needs a latency budget or a formal SLA should build on a paid tier instead of a free one. The same caution applies if you spin up several free servers or swap free models: quotas change. If a process can die before writing a line, you will get a clean report. A heartbeat is a patch, not a solution.
If you can accept those limits, start tonight:
- Point
LOG_FILESat one real log path and runincident_drafter.pyby hand for a week. - Read every JSON file on your phone and mark invented claims.
- Add frequency-to-severity mapping and a two-hour heartbeat.
- Put the reader on a free server and let a free model take the first draft.
The only thing you have to lose is a few hours of sleep. Clone the script, set the environment variables, and let tomorrow morning's file prove whether the free tier is enough for your night shift.
Top comments (0)