One metrics schema for cron runs, API failures and business events
A reference layout for a small backend dashboard: how to name and tag three kinds of signal, why every panel is its own call, and what Healthchecks.io still has to do.
Three feeds usually end up on a small backend dashboard: did the nightly jobs run and how long did they take, how many API calls failed, and how many orders/signups/exports happened. Teams reach for three tools, then spend a quarter wiring them together. On Infrai they’re one store — POST /v1/metrics/report in, GET /v1/metrics/query out — and the engineering that matters is the naming convention, not the plumbing.
That claim comes with a boundary, and it’s worth putting up front: this store records what happened, so it cannot tell you that a job didn’t run. A dead-man’s-switch service like Healthchecks.io is the complement, not the competitor, and the last section is about drawing that line.
Everything is a name, a value, a type and tags
The write body is small enough to memorise: name matching ^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$, a numeric value, a type of counter, gauge, timing or distribution, and optional string tags. Every one of the three feeds fits.
| Feed | Name | Type | Tags that earn their keep |
|---|---|---|---|
| Cron run outcome | cron.run.total | counter | job, outcome, day |
| Cron run duration | cron.run.duration_ms | timing | job, day |
| API failures | api.error.total | counter | route, status, day |
| Business events | business.order.created | counter | plan, day |
Two rules make the difference between a schema you keep and one you rename in six months. Put the dimension in a tag, never in the name — api.error.total with tag.route beats api_error_checkout_total, because the first can be totalled and the second can’t. And always carry a day tag, because the read side has no time range at all and the tag is the only thing that will let you ask about today rather than about all of history.
Recording a cron run
Wrap the job once and every job you ever write is instrumented.
// instrumented-job.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");
const today = () => new Date().toISOString().slice(0, 10);
export async function withRun(job, fn) {
const started = Date.now();
let outcome = "ok";
try {
return await fn();
} catch (err) {
outcome = "failed";
throw err;
} finally {
const tags = { job, outcome, day: today() };
const points = [
{ name: "cron.run.total", value: 1, type: "counter", tags },
{ name: "cron.run.duration_ms", value: Date.now() - started, type: "timing", tags: { job, day: today() } },
];
const res = await fetch(`${BASE}/v1/metrics/batch`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ points }),
}).catch((e) => ({ ok: false, status: e.message }));
if (!res.ok) console.error("[metrics] run not recorded", res.status);
}
}
await withRun("nightly_invoices", async () => {
console.log("generating invoices");
});
The same two points on the wire, if you’d rather instrument a shell job:
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": "cron.run.total", "value": 1, "type": "counter",
"tags": {"job": "nightly_invoices", "outcome": "ok", "day": "2026-07-26"}},
{"name": "cron.run.duration_ms", "value": 41180, "type": "timing",
"tags": {"job": "nightly_invoices", "day": "2026-07-26"}}
]
}'
{ "ok": true, "data": { "accepted": 2 } }
A business event is the same call with a different noun, which is the whole argument for one store:
curl -sS -X POST https://api.infrai.cc/v1/metrics/report \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "business.order.created",
"value": 1,
"type": "counter",
"tags": {"plan": "pro", "day": "2026-07-26"}
}'
Reading a panel
Each panel is one read, and reads are free.
curl -sS -H "Authorization: Bearer ${INFRAI_API_KEY}" \
"https://api.infrai.cc/v1/metrics/query?name=cron.run.duration_ms&agg=p99&tag.job=nightly_invoices"
{
"ok": true,
"data": {
"name": "cron.run.duration_ms",
"agg": "p99",
"points": [{ "ts": "2026-07-26T05:54:51.422556Z", "value": 41180.0 }]
}
}
agg takes avg, sum, count, p50 or p99. Pass anything else and you get HTTP 200 with "agg": null and a plain arithmetic mean — a typo degrades into a wrong number rather than an error, so validate the string before you build the URL from user input.
The fan-out, and the group-by that isn’t there
There is no group_by. A tag is a filter, so “failures per route” is not one query returning five rows — it’s five queries, one per route, and you assemble the row set yourself. That sounds worse than it is at dashboard scale (each read came back in roughly 30 ms in our testing, and they parallelise), but the catch is that panels times series equals HTTP calls: a board with 40 series is 40 calls per refresh, and you have to know your tag values in advance because nothing will enumerate them for you.
// dashboard.mjs — one endpoint, because the API sends no CORS headers
import express from "express";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const day = () => new Date().toISOString().slice(0, 10);
async function panel({ name, agg, tags }) {
const q = new URLSearchParams({ name, agg });
for (const [k, v] of Object.entries(tags)) q.set(`tag.${k}`, v);
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}: ${json.error?.code ?? res.status}`);
return json.data.points[0]?.value ?? 0;
}
const app = express();
app.get("/api/dashboard", async (_req, res) => {
try {
const d = day();
const [failedRuns, slowest, checkoutErrors, orders] = await Promise.all([
panel({ name: "cron.run.total", agg: "sum", tags: { outcome: "failed", day: d } }),
panel({ name: "cron.run.duration_ms", agg: "p99", tags: { job: "nightly_invoices", day: d } }),
panel({ name: "api.error.total", agg: "sum", tags: { route: "/checkout", day: d } }),
panel({ name: "business.order.created", agg: "sum", tags: { plan: "pro", day: d } }),
]);
res.json({ day: d, failedRuns, slowestRunMs: slowest, checkoutErrors, orders });
} catch (err) {
console.error("[dashboard]", err.message);
res.status(502).json({ error: "metrics_unavailable" });
}
});
app.listen(3000);
Keep the key server-side anyway. api.infrai.cc returns no CORS headers and answers preflight with 401, so a browser cannot call it directly — which is the correct outcome, since the alternative is shipping a credential to every visitor.
What Healthchecks.io still has to do
Absence is invisible here. If the invoice job’s container never starts, nothing is written, and cron.run.total filtered to today returns an empty points array that looks exactly like “the day just started”. You can build a watcher that treats an empty result after a deadline as a failure, and it works — but it’s a second scheduled thing that can itself fail silently, which is the problem you were trying to solve.
That is precisely the job a ping service does well. Healthchecks.io expects a request and pages you when it doesn’t arrive, with the expectation living outside your infrastructure. The clean division: ping for did it run, metrics for how did it go.
Grafana, similarly, is still the better answer once someone wants to slice a board interactively — this API gives you numbers, not a query builder, and a hand-rolled dashboard stops being cheap around the point you’d have built annotations and templating. If your team is already fluent in PromQL, Prometheus plus Grafana is not a downgrade and you shouldn’t switch for its own sake.
The bill at dashboard scale
Verified 2026-07-26: writes cost $0.001 per metric point — the same whether you send it to POST /v1/metrics/report or as one of many points in POST /v1/metrics/batch — and reads are free but rate-limited. So a dashboard’s cost is set entirely by emission: 40 cron runs a day at two points each is $0.08 a day, while a per-request counter on a service doing 2 million calls would be absurd, and that asymmetry should decide what you instrument. New accounts start with $2 of credit. Rates drift downward over time, so check the live figures before you plan against them:
curl -sS -H "Authorization: Bearer ${INFRAI_API_KEY}" \
"https://api.infrai.cc/v1/discovery"
curl -sS -H "Authorization: Bearer ${INFRAI_API_KEY}" \
"https://api.infrai.cc/v1/account/usage"
One more thing the single-store argument buys you. The same key that wrote these points also runs the schedule (POST /v1/cron/create), captures the exception the failed run threw (POST /v1/errors/capture) and emails the digest (POST /v1/email/send) — so the dashboard’s next feature is a call, not a procurement decision.