Errors, logs and metrics: the minimum failure-alert stack that works

A two-person SaaS doesn't need three observability signals. Which failures belong in an error store, which need real logs, and a cron that mails you the daily digest.

Three signals — errors, logs, metrics — is the standard advice, and for a team of two it’s usually one signal too many to start with. Almost every failure a small SaaS needs to hear about arrives as either a thrown exception or a rule that quietly didn’t hold, and both fit in an event store. Infrai’s errors namespace takes both: POST /v1/errors/capture for exceptions, and a free message route for the “nothing threw, but this is wrong” class that logs usually swallow.

Reads are free here, so the alerting half is a scheduled script rather than a subscription. What follows is the smallest arrangement we’d defend for a product with a handful of services in the US or EU: one store, one severity ladder, one digest that lands in your inbox each morning.

Which signal catches which failure

Failure you care aboutRight signalWhere it goes
Unhandled exception in a request or workerError eventPOST /v1/errors/capture — billed per event
Job ran, produced nothing, threw nothingMessage event at warningPOST /v1/errors/message — free
”What exactly did request 9f2c1 do?”Structured logsA log platform: Better Stack, Datadog
”Is p95 checkout latency drifting?”Numeric time seriesA metrics product

The bottom two rows are honest about the boundary. An error store isn’t a log store — there’s no request-log ingest, no retention policy to configure, no field-level index — and it can’t compute a percentile over a time window. If either of those questions is the one keeping you up, add the tool that answers it and keep the error store for what it’s good at.

Start with the top two rows. They cover the failures that lose you customers.

The free half of the ladder

The message route exists for events that deserve a group and a timestamp but aren’t exceptions. We checked the live route while writing this: it needs a non-empty text, and it accepts the same severity, environment and release context the capture route does — confirm the current field list in the errors API reference before you standardise on it.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/errors/message" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "nightly reconcile finished with 0 rows",
    "level": "warning",
    "environment": "production",
    "release": "2026.07.6"
  }'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_xJAus2BOwPd0T4jS0jGBnvAx",
    "fingerprint": "e1af875ae7df397283bf8c608539f8b014d00af72917daa6969eb2a2e52d3ef2",
    "error_group_id": "errgrp_jIxIrC7qVpO1LYMuBB8yFYv6",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_xJAus2BOwPd0T4jS0jGBnvAx"
  }
}

Same envelope as a captured exception, same grouping behaviour, no charge. That asymmetry is worth designing around, because the events teams skip are almost always the cheap ones: a payment webhook that arrived twice, a reconcile job that matched nothing, a feature flag evaluated for a tenant that no longer exists. None of those throw. All of them precede an incident by hours, and a warning-level group whose count suddenly triples is often the earliest honest signal that a release went wrong — earlier than the 500s, and much earlier than the support email that starts “is something down?”.

Record them. They’re free.

Reading the day back

Two queries produce a digest. Aggregated groups tell you what’s open; the event list, filtered by severity, tells you how loud it got.

curl -sS "https://api.infrai.cc/v1/errors/groups?status=unresolved&limit=50" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/errors/list?level=error&limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Severity filtering works on the event list and not on the group list, which took us a live call to establish — the group route only understands status, and any other filter you add there is ignored without complaint.

The digest

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const DAY_MS = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - DAY_MS).toISOString();

async function read(path) {
  const res = await fetch(`${API}${path}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${KEY}` },
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`${path} -> HTTP ${res.status}`);
  const { data } = await res.json();
  return data;
}

function section(title, rows) {
  return rows.length ? `## ${title}\n${rows.join("\n")}\n` : "";
}

try {
  const groups = (await read("/v1/errors/groups?status=unresolved&limit=50")).groups ?? [];
  const active = groups.filter((g) => g.last_seen_at >= since);
  const regressions = active.filter((g) => g.first_seen_at >= since);
  const byRelease = new Map();
  for (const g of active) {
    for (const rel of g.releases.length ? g.releases : ["unreleased"]) {
      byRelease.set(rel, (byRelease.get(rel) ?? 0) + g.count);
    }
  }

  const lines = [`# Failures in the last 24h — ${new Date().toISOString().slice(0, 10)}`, ""];
  lines.push(section("New groups", regressions.map((g) => `- ${g.title.split("\n")[0]} (${g.level})`)));
  lines.push(section("Still open", active.map((g) => `- ${g.count}x ${g.title.split("\n")[0]}`)));
  lines.push(section("Events by release", [...byRelease].map(([rel, n]) => `- ${rel}: ${n}`)));
  console.log(lines.filter(Boolean).join("\n"));
} catch (err) {
  console.error(`digest failed: ${err.message}`);
  process.exitCode = 1;
}

Nothing here sends mail, and that’s deliberate. Cron mails whatever a job writes to stdout, so a MAILTO line does the delivery you were about to build:

MAILTO=oncall@example.com
0 8 * * * cd /srv/ops && /usr/bin/node daily-digest.mjs

If you’d rather have HTML in the inbox, or a message posted into a channel, the same API key already reaches Infrai’s email and queue capabilities — the delivery step doesn’t need a new account, an SDK or a second invoice. That’s the part of this that a competitor’s price cut can’t match: not the rate, but the fact that the next thing you need is already on the key you have.

Running cost

At $0.00005 per captured exception, verified 2026-07-26, a service throwing 20,000 real exceptions a month spends about $1. Message events and every read are free, and new accounts start with $2 of free credit. Confirm both classes yourself:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.namespace == "errors") | {id, billing: .billing.is_billable}'

Rates here tend to fall rather than rise, and campaigns run, so treat the figure as an upper bound. The structural facts are steadier: writes metered, reads free but rate-limited, no per-seat charge, and a RATE_LIMIT_ACCOUNT response if a poller gets greedy — which is a good reason to schedule the digest daily instead of hammering the read routes every ten seconds.

Where this stack runs out

There’s no alert-rule engine, no on-call rotation, no dashboard, and no support for streaming your application logs into the same place. If you need log search across six services, Better Stack or Datadog are the right purchase and this isn’t a substitute. If you need trace-linked errors with automatic release health, Sentry does that properly.

What you get instead is a stack a single person can hold in their head: one write per failure, two free reads, one cron line, and a digest that arrives whether or not anything broke — which is how you find out the watcher itself is still alive.

References

Browse more errors developer guides