A daily cron that queries your metrics and emails a one-page summary

How metric query APIs get used for a daily digest: tag the bucket at write time, read one aggregate per tag, then let cron fire the endpoint that sends the page.

Three moving parts, all of them plain REST calls: a write path that tags every number with the day it belongs to, a read call that hands back one aggregate per tag, and a scheduler that hits an endpoint of yours each morning. On Infrai those are POST /v1/metrics/report (or /v1/metrics/batch), GET /v1/metrics/query and POST /v1/cron/create, with POST /v1/email/send doing the last mile into your inbox.

The part that trips people up is the read.

Most metric query APIs you’ve met take a start, an end and a step, then return a series you can chart — Prometheus does it with query_range, CloudWatch with GetMetricStatistics. Infrai’s read call takes a metric name, an aggregation and tag filters, and returns a single aggregate over the whole retained series. That’s a narrower API than you’d expect, and it decides where the “daily” part of a daily digest actually lives.

One aggregate, no time range

There is no from, no to, no step. Ask for agg=avg on app.checkout.latency_ms and you get the mean of every sample the account still holds for that name, returned as one point whose ts is the most recent sample. Ask for agg=count and you get how many samples exist, full stop.

So the range has to be encoded in the data. You put the bucket in a tag when you write the sample, and you filter on that tag when you read it back. A day tag of 2026-07-26 turns “yesterday’s error count” into an exact-match filter rather than a range scan — and because the filter is exact, a day with no writes returns points: [] rather than a zero, which is a distinction your digest template needs to handle.

Aggregations available today are avg, sum, count, p50 and p99.

One caveat that cost us time in testing: an aggregation the server doesn’t recognise is not rejected. Send agg=median and you get HTTP 200 with the arithmetic mean and "agg": null in the body — no error, no warning. Check that data.agg came back equal to what you asked for before you paste the number into an email that says “p99”.

Tag the bucket when you write

Single samples go through the report route. Anything that fires more than a few times a minute should go through the batch route instead, for a reason that shows up on the invoice later.

curl -sS -X POST https://api.infrai.cc/v1/metrics/report \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "app.orders.completed",
    "value": 1,
    "type": "counter",
    "tags": { "day": "2026-07-26", "env": "production" }
  }'
{
  "ok": true,
  "data": { "accepted": true, "metric_id": "metric_FjxgHD3z0AKJZpw3QJRK7d0I" },
  "metadata": { "request_id": "req_3d8280674d8c4c8bb12eb423", "latency_ms": 45 }
}

The batch route takes the same point shape under a points array and answers with a count of what it took:

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": "app.orders.completed", "value": 1, "type": "counter", "tags": { "day": "2026-07-26" } },
      { "name": "app.checkout.latency_ms", "value": 812, "type": "timing", "tags": { "day": "2026-07-26" } },
      { "name": "app.api.5xx", "value": 1, "type": "counter", "tags": { "day": "2026-07-26" } }
    ]
  }'

Worth flagging before you build on it: a point missing name is dropped silently. Send four points where one has no name and the response is {"accepted": 3} — no error, no indication of which one went missing. If your emitter builds point objects from a map lookup that can return undefined, you’ll lose samples and only notice when a tile in the digest reads zero. We validate names client-side before the request for exactly that reason.

Reading yesterday back

curl -sS -H "Authorization: Bearer $INFRAI_API_KEY" \
  "https://api.infrai.cc/v1/metrics/query?name=app.orders.completed&agg=count&tag.day=2026-07-26"
{
  "ok": true,
  "data": {
    "name": "app.orders.completed",
    "agg": "count",
    "points": [{ "ts": "2026-07-26T01:26:50.967076Z", "value": 3.0 }]
  }
}

Reads are free and unbilled, so a digest that pulls twelve tiles is twelve free calls. That matters for the design: you’re not paying per panel, so a one-page summary can afford to be genuinely informative rather than three numbers chosen to keep the bill down.

The endpoint the cron calls

Infrai’s scheduler fires an HTTP request at a URL you own. The digest logic — read the tiles, render the HTML, send it — lives in your service, which is the right place for it, because rendering a table of your own KPIs is not something you want to express in a scheduler’s config language.

import { createServer } from "node:http";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const SECRET = process.env.DIGEST_SECRET;
const TO = process.env.DIGEST_TO;
if (!KEY || !SECRET || !TO) throw new Error("set INFRAI_API_KEY, DIGEST_SECRET and DIGEST_TO");

const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

async function tile(name, agg, day) {
  const url = `${API}/v1/metrics/query?name=${encodeURIComponent(name)}&agg=${agg}&tag.day=${day}`;
  const res = await fetch(url, { headers });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`${name}: ${json.error?.code ?? res.status}`);
  if (json.data.agg !== agg) throw new Error(`${name}: server ignored agg=${agg}`);
  const points = json.data.points ?? [];
  return points.length ? points[0].value : null;
}

function yesterday() {
  const d = new Date(Date.now() - 86400000);
  return d.toISOString().slice(0, 10);
}

async function buildDigest(day) {
  const rows = await Promise.all([
    ["Orders completed", "app.orders.completed", "count"],
    ["Checkout p99 (ms)", "app.checkout.latency_ms", "p99"],
    ["5xx responses", "app.api.5xx", "count"],
  ].map(async ([label, name, agg]) => [label, await tile(name, agg, day)]));
  const cells = rows
    .map(([label, value]) => `<tr><td>${label}</td><td>${value ?? "no data"}</td></tr>`)
    .join("");
  return `<h2>Daily metrics for ${day}</h2><table>${cells}</table>`;
}

async function sendDigest(day, html) {
  const res = await fetch(`${API}/v1/email/send`, {
    method: "POST",
    headers,
    body: JSON.stringify({ to: TO, subject: `Daily metrics — ${day}`, html }),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`email.send: ${json.error?.code ?? res.status}`);
  return json.data.message_id;
}

createServer(async (req, res) => {
  if (req.headers["x-digest-secret"] !== SECRET) {
    res.writeHead(403).end("forbidden");
    return;
  }
  try {
    const day = yesterday();
    const messageId = await sendDigest(day, await buildDigest(day));
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ day, message_id: messageId }));
  } catch (err) {
    res.writeHead(500, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: String(err.message ?? err) }));
  }
}).listen(8080);

The send route answers with message_id, from_used and accepted_recipients; suppressed addresses come back separately in suppressed_recipients and are not delivered, so a digest that silently stops arriving is usually a suppression-list entry rather than a broken cron.

Scheduling it

curl -sS -X POST https://api.infrai.cc/v1/cron/create \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "daily-metrics-digest",
    "cron_expr": "15 7 * * *",
    "task": "https://api.example.com/jobs/daily-digest",
    "timezone": "Europe/Berlin",
    "timeout_seconds": 120,
    "overlap_policy": "skip",
    "headers": { "x-digest-secret": "rotate-me" }
  }'

The field is task, holding the URL to fire. Send task_url instead and you get a 400 reading cron.create needs 'task' (str) — the response echoes it back as task_url alongside task_type: "http_url", which is where the confusion starts. timezone is an IANA name, timeout_seconds caps at 900, retry defaults to 3, and overlap_policy: "skip" stops a slow digest run from stacking on top of the next one.

{
  "ok": true,
  "data": {
    "job_id": "cron_RwMQT4qavsuNNnTMvThJxUKF",
    "cron_expr": "15 7 * * *",
    "task_type": "http_url",
    "task_url": "https://api.example.com/jobs/daily-digest",
    "timezone": "Europe/Berlin",
    "enabled": true,
    "status": "active",
    "next_run_at": null
  }
}

Note next_run_at — in our testing it came back null immediately after create and stayed null on a follow-up read, so don’t gate a deploy check on it. Confirm the job exists and is enabled instead:

curl -sS -H "Authorization: Bearer $INFRAI_API_KEY" "https://api.infrai.cc/v1/cron/list"

What a daily digest costs

Writes are billable per call at $0.001, reads are free but rate-limited, and an email is $0.000115 per recipient — all verified 2026-07-26, and all readable live so you never have to trust a number in an article:

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

Rates on this platform move downward and discount campaigns run, so what you read today may well be lower than what’s printed here. The structural facts are the durable ones: reads free, writes metered per call, new accounts carrying $2 of free credit that covers roughly 1,999 billable calls.

The number that shapes your architecture is that metrics.batch bills per call, not per point. Fifty points in one batch cost the same $0.001 as a single report. Flush a rollup every 60 seconds and you’re at 1,440 calls a day; flush every five minutes and it’s 288, under $9 a month. Emit one report per HTTP request and you’ll be unhappy.

That’s the honest boundary: this is a metering surface for aggregate counters and business events, not a high-cardinality time-series database. If you need per-request histograms at thousands of samples a second, you’d be better off with Prometheus scraping your own process and a rollup job pushing the daily summary here.

When you should buy a dashboard instead

OptionWhat it takes to stand upWhere it winsWhere it hurts
Infrai metrics + cron + emailThree REST calls, one API key, no agentThe digest, the schedule and the send share one account and one billNo range queries, no charting UI, no anomaly detection
Grafana CloudData source, dashboard, scheduled reportReal charts and shareable PDF reportsAnother account, another key, reporting is a paid-tier feature
DatadogInstall the agent, define monitorsCorrelated traces, logs and metrics out of the boxPriced per host and per custom metric; heavy for a two-service app
cron + bash + psqlAn hour and a shell scriptFree, and the data never leaves your databaseEvery new metric is a new SQL query nobody else can read

If your app already has a Postgres you trust, a shell script and a query are genuinely the cheapest path to a daily email, and the DEV Community write-up linked below shows exactly that shape. The argument for doing it over an API isn’t that it’s cheaper — it’s that the same credential that stores the counter also fires the schedule and sends the mail, so there’s one place to look when the digest stops arriving and one invoice at the end of the month.

Start with three tiles. Add the fourth when someone asks for it.

References

Browse more metrics developer guides