Charts for a SaaS admin page: metrics API or log search?

Why a chart wants a counters API and not a log query, how to bucket at write time on Infrai, and what the EU privacy difference between the two stores really is.

Use a metrics API for the charts and keep logs for the investigation afterwards. A chart needs one number per bucket; a log line is a sentence somebody has to find, parse and count before it becomes a number. Infrai’s metrics namespace stores the number directly — POST /v1/metrics/report writes it, GET /v1/metrics/query reads an aggregate back, and the read side costs nothing.

That split also settles the privacy question a European team has to answer before the first chart ships. A log line inherits whatever the developer put in it — an email, a request body, an IP — and therefore inherits erasure and retention duties. A metric point is a name, a float and a handful of low-cardinality tags, which is a much smaller surface to defend. Infrai won’t make that distinction for you, but the shape of the data makes the good choice the easy one.

What each store is actually good at

Question the admin page asksMetrics APILog search
”How many exports finished yesterday?”One aggregate callScan + regex + count
”Is the Pro plan using the feature more than Free?”Tag filter on the same seriesField extraction, then group-by
”Why did export #4471 fail at 14:12?”Can’t answer — no per-event detailThis is the whole point of logs
Cost per rendered chartFree readsScanned bytes, usually metered
Personal data riskLow by constructionHigh by default

The row that surprises people is the last one. Nothing stops you writing tags: { user_email: ... } into a metric — the API accepts any string map — but you’d be paying for it twice, once in your data-protection register and once in cardinality. Keep identifiers out and the series stays cheap and boring.

The EU part of the question

Two practical points if you sell into Europe. Minimisation is far easier to prove for a counter than for a log line: kpi.export.completed tagged plan=pro says nothing about a person, so an erasure request doesn’t touch it, while the log store that recorded the export filename and the requester’s address certainly does.

Residency deserves a straight answer too. Discovery lists this capability’s regions as western and china, and there’s no EU-only storage flag you can point a data-processing agreement at. If your contracts require raw events to stay inside the EU, keep those rows in your own database and send only the aggregate counter — which is what you wanted the metrics store for anyway.

Bucket at write time, because there is no time range

Here’s the constraint that shapes everything else: GET /v1/metrics/query takes a name, an agg and tag filters. It doesn’t support from, to or step. One call returns one aggregate over the retained series, not a bucketed line.

So you decide the bucket when you write, by putting it in a tag:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/metrics/report" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "kpi.export.completed",
        "value": 1,
        "type": "counter",
        "tags": { "plan": "pro", "day": "2026-07-25" }
      }'
{
  "ok": true,
  "data": { "accepted": true, "metric_id": "metric_PDuQ06HFsWE7Gfq2wM0LL2PM" },
  "metadata": { "request_id": "req_16157dcca9bb41acaee2e000", "latency_ms": 85 }
}

Names have to match ^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$, and type is one of counter, gauge, timing, distribution. Pick the day string in UTC and stop thinking about it.

One chart, one call per bucket

Reading a bucket back is a tag filter, and the aggregate you want is named explicitly:

curl -sS "https://api.infrai.cc/v1/metrics/query?name=kpi.export.completed&agg=sum" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS "https://api.infrai.cc/v1/metrics/query?name=kpi.export.completed&agg=sum&tag.day=2026-07-25&tag.plan=pro" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "name": "kpi.export.completed",
    "agg": "sum",
    "points": [{ "ts": "2026-07-26T01:20:06.772059Z", "value": 14.0 }]
  }
}

One point, not a series — ts is the newest sample that fed the aggregate, not a bucket boundary. A tag combination nobody has written to comes back as "points": [] rather than a 404, which is the behaviour you want on a dashboard: no data is a legitimate answer for a feature nobody used on Sunday.

Worth flagging one quiet failure: agg accepts avg, sum, count, p50 and p99, and anything else — max, say — is ignored rather than rejected. You get the mean back with "agg": null in the response. Assert on that field in your client if a wrong number would be embarrassing.

The dashboard endpoint in Node 22

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 lastDays = (n) =>
  Array.from({ length: n }, (_, i) => {
    const d = new Date(Date.now() - (n - 1 - i) * 86_400_000);
    return d.toISOString().slice(0, 10);
  });

async function bucket(name, day, plan, agg = "sum") {
  const qs = new URLSearchParams({ name, agg, "tag.day": day, "tag.plan": plan });
  const res = await fetch(`${API}/v1/metrics/query?${qs}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!res.ok) throw new Error(`query ${name} ${day} -> HTTP ${res.status}`);
  const { data } = await res.json();
  if (data.agg !== agg) throw new Error(`agg fell back to ${data.agg}`);
  return data.points[0]?.value ?? 0;
}

export async function exportSeries({ days = 7, plans = ["free", "pro"] } = {}) {
  const labels = lastDays(days);
  const series = {};
  for (const plan of plans) {
    series[plan] = await Promise.all(
      labels.map((day) => bucket("kpi.export.completed", day, plan)),
    );
  }
  return { generated_at: new Date().toISOString(), labels, series };
}

console.log(JSON.stringify(await exportSeries({ days: 3 }), null, 2));

Fourteen calls for a two-line, seven-day chart. That sounds wasteful until you notice reads are free and each one is a single indexed lookup — cache the assembled object for a minute and the admin page is done. If you ever need thirty series, reach for the batch write side and fewer, wider tags rather than more read calls.

Cost, and how to check today’s number

Writes are billable at $0.001 per point and reads are free. The unit matters more than the figure: POST /v1/metrics/batch is charged per point too, not per request — in our testing a 100-point batch moved the account by exactly ten cents — so batching buys you round-trips and latency, not a discount. New accounts start with $2 of credit, which is roughly 2,000 points before anything is charged. Verified 2026-07-26; read your own numbers with:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Rates in this catalogue move downward over time and discount campaigns run, so what you read there may well be lower than what’s printed here. The durable part is the structure — metered writes, free reads, and the same key already covering the queue, cron, storage and email that the admin page probably also needs, on one invoice instead of four.

Where logs and the dashboard products win

If your question is “why”, logs win and it isn’t close. Nobody debugged a failed export from a counter.

If you want panels you didn’t build, alert schedules and ad-hoc exploration over dimensions you invent at query time, buy that. Grafana Cloud is the obvious pick when your data is already Prometheus-shaped and you’d rather configure than code. Datadog’s dashboards are the reference implementation once you’re paying for its agent, and CloudWatch is hard to argue with if everything already runs in one AWS account. Prometheus itself is excellent and free, with the caveat that scraping short-lived jobs is its known weak spot.

The case for a plain counters API is narrower: you already have an admin page, you want four KPI tiles rather than an observability practice, and adding a second vendor to a three-person team is a real ongoing cost in accounts, keys and invoices. That’s a trade-off, not a free win — you’re giving up ad-hoc analysis to keep the surface small.

References

Browse more metrics developer guides