A failure-alert Lambda with no state store: window tags and an edge trigger
Poll Infrai's metrics API on a schedule, compare two window tags to fire only on the transition, and keep a small app's alerting under a dollar a month.
The hard part of a polling alerter isn’t the threshold. It’s memory: a poller that reads a running total has no idea how much of it is new, and a poller that alerts on every bad reading pages you every 60 seconds until someone mutes it. Both problems normally get solved with a state store, which is annoying inside a Lambda. Against Infrai’s metrics API you can skip it, because the read is GET /v1/metrics/query with tag filters and you get to choose what the tags mean.
Make one of them the time window. Then “what happened in the last five minutes” is a filter rather than a range, “is this new” is a comparison between two filters, and the function that runs every five minutes needs to remember precisely nothing.
Why a cumulative sum can’t be polled
GET /v1/metrics/query accepts name, an agg from avg, sum, count, p50 or p99, and tag.<key>=<value>. That’s the whole surface. There’s no from, no to, no step — send them and they’re ignored rather than rejected — and points comes back with exactly one element.
So a poll of api_5xx_total returns every 5xx you’ve ever recorded under that name. Useless on its own.
Two ways out. Keep the previous total in a database and subtract, which is a state store plus a cold-start problem plus a correctness bug the first time the Lambda is redeployed. Or make each write land in a bucket you can address later, which costs one tag.
Tag the window at write time
The bucket is the wall clock rounded down to your poll interval. Emit it with the point.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST https://api.infrai.cc/v1/metrics/batch \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"points": [
{"name": "api_requests_total", "value": 1042, "type": "counter",
"tags": {"window": "2026-07-26T05:50", "service": "api"}},
{"name": "api_5xx_total", "value": 61, "type": "counter",
"tags": {"window": "2026-07-26T05:50", "service": "api"}}
]
}'
{ "ok": true, "data": { "accepted": 2 } }
Worth flagging: a point with no name is skipped silently and accepted only tells you how many survived, so compare it against the length of the array you sent.
Here’s the emitter. It counts in memory and flushes when the window rolls over, which keeps the write volume at two points per five minutes per instance regardless of traffic.
// window-meter.mjs — Node 22
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export const windowOf = (d = new Date(), minutes = 5) => {
const ms = minutes * 60_000;
return new Date(Math.floor(d.getTime() / ms) * ms).toISOString().slice(0, 16);
};
let current = windowOf();
let requests = 0;
let failures = 0;
export function record(statusCode) {
const now = windowOf();
if (now !== current) { const done = { current, requests, failures }; current = now; requests = 0; failures = 0; flush(done).catch((e) => console.error("[meter]", e.message)); }
requests++;
if (statusCode >= 500) failures++;
}
async function flush({ current: w, requests: r, failures: f }) {
if (r === 0) return;
const res = await fetch(`${BASE}/v1/metrics/batch`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ points: [
{ name: "api_requests_total", value: r, type: "counter", tags: { window: w, service: "api" } },
{ name: "api_5xx_total", value: f, type: "counter", tags: { window: w, service: "api" } },
] }),
});
if (!res.ok) console.error("[meter] flush rejected", res.status);
}
Two reads, and the edge between them
Now the poll. Read the window that just closed, and the one before it.
curl -sS -H "Authorization: Bearer ${INFRAI_API_KEY}" \
"https://api.infrai.cc/v1/metrics/query?name=api_5xx_total&agg=sum&tag.window=2026-07-26T05:50"
curl -sS -H "Authorization: Bearer ${INFRAI_API_KEY}" \
"https://api.infrai.cc/v1/metrics/query?name=api_5xx_total&agg=sum&tag.window=2026-07-26T05:45"
{
"ok": true,
"data": {
"name": "api_5xx_total",
"agg": "sum",
"points": [{ "ts": "2026-07-26T05:52:48.899618Z", "value": 61.0 }]
}
}
61 failures against 1042 requests is 5.9%; the window before it was 4 against 1180, or 0.3%. Bad now, healthy before — that’s an edge, and an edge is what deserves a notification. Poll again five minutes later with the same code and, if the outage is ongoing, both windows are bad, no edge, no second page. The dedup that normally needs a last_alerted_at row falls out of the arithmetic for free.
The function
// alerter.mjs — runs on a 5-minute schedule (Lambda, Cloud Run job, cron container)
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const HOOK = process.env.ALERT_WEBHOOK_URL;
const BUDGET = Number(process.env.ERROR_BUDGET ?? 0.02);
const MIN_TRAFFIC = 100;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const bucket = (offset) => {
const ms = 5 * 60_000;
return new Date(Math.floor(Date.now() / ms) * ms - offset * ms).toISOString().slice(0, 16);
};
async function sum(name, window) {
const q = new URLSearchParams({ name, agg: "sum", "tag.window": window });
const res = await fetch(`${BASE}/v1/metrics/query?${q}`, {
headers: { authorization: `Bearer ${KEY}` },
});
const json = await res.json();
if (!json.ok) throw new Error(`${name} ${window}: ${json.error?.code ?? res.status}`);
return json.data.points[0]?.value ?? 0;
}
async function rate(window) {
const [total, bad] = await Promise.all([sum("api_requests_total", window), sum("api_5xx_total", window)]);
return { total, bad, rate: total ? bad / total : 0 };
}
export async function handler() {
const [now, before] = await Promise.all([rate(bucket(1)), rate(bucket(2))]);
const breached = (w) => w.total >= MIN_TRAFFIC && w.rate > BUDGET;
if (!breached(now) || breached(before)) {
return { alerted: false, rate: now.rate, sample: now.total };
}
const text = `api error rate ${(now.rate * 100).toFixed(1)}% (${now.bad}/${now.total}) in window ${bucket(1)}`;
if (HOOK) {
const res = await fetch(HOOK, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) console.error("[alert] delivery failed", res.status);
}
return { alerted: true, text };
}
breached(before) is doing all the work in that condition. Drop it and you have a pager that fires every five minutes for the duration of an incident.
Where the delivery goes
A Slack or Discord incoming webhook is the cheapest sink and the snippet above already speaks it. If you want the alert in a mailbox instead, POST /v1/email/send is on the same key and the same bill — no second vendor, no second secret in the Lambda’s environment. That’s the practical version of consolidation: the alerter needed an email sender and didn’t need an account to get one.
What this doesn’t cover
Gatus is a better answer if what you actually want is uptime checks and a public status page: it probes endpoints itself, so it notices a service that has stopped answering, whereas a metrics poller only sees what your code managed to report. A process that dies mid-window emits nothing and reads as silence, not failure. Alerting on absence needs a separate rule that treats a zero-traffic window as suspicious, and that rule is noisy on a small app with genuinely idle nights.
You also don’t get escalation. PagerDuty exists because someone has to be woken up, rotas have to hand over, and an unacknowledged page has to travel to the next person — a webhook can’t do any of that. CloudWatch alarms are the obvious in-cloud comparison if all your traffic is already on AWS, and Prometheus with Alertmanager is the right answer once you have enough services that alert routing itself becomes a config problem.
| This poller | Gatus | CloudWatch alarms | PagerDuty | |
|---|---|---|---|---|
| Sees business-level failures | yes | no, endpoint probes | via custom metrics | no, it’s a delivery layer |
| Notices a dead process | no | yes | yes | no |
| Escalation and on-call rota | no | no | no | yes |
| Infrastructure to run | one scheduled function | a server or container | none, if you’re on AWS | none |
What it costs
Verified 2026-07-26: reads are free and rate-limited, and writes are $0.001 per metric point — POST /v1/metrics/report, or per point inside POST /v1/metrics/batch, which is the same price either way. Two points every 5 minutes is 576 points a day, about $0.58; the polling itself adds nothing because GET /v1/metrics/query is free. New accounts get $2 in credit, which covers a couple of weeks of that loop before you’ve paid anything. Prices tend to move down, so read the current numbers rather than trusting this paragraph:
curl -sS -H "Authorization: Bearer ${INFRAI_API_KEY}" \
"https://api.infrai.cc/v1/account/usage"
The durable shape of the bill matters more than the digits: you pay for what you write and not for how often you look, so the design that wins is a coarse in-process aggregate polled aggressively — never a per-request metric polled politely.