Admin analytics for a Node SaaS: metrics counter, log search, or an event query?
All three Infrai read routes take a time window. What separates them is shape — a list, one aggregate, or a queryable event store. Verified against the live API.
The event query, if the question has a date and a noun in it. “Signups this week”, “tenants active in June”, “how many of March’s accounts came back” — those are event questions, and on Infrai they belong to POST /v1/analytics/query/events and its two siblings rather than to the log search or the metrics counter. All three read surfaces take a since/until window; what actually separates them is the shape of what comes back.
That’s the distinction worth internalising, because picking by shape is what keeps an admin page honest. A list is not a count. One aggregate is not a series. And an event store is neither, until you ask it a question with a window in it.
What each read route will answer
| Read route | Comes back as | Aggregation | Slice by | Right job |
|---|---|---|---|---|
GET /v1/logs/search | A list of lines plus total | None | level, service, free-text q | Debugging one request or one bad deploy |
GET /v1/metrics/query | One point for the window | avg, sum, count, p50, p99 | tag.<key>=<value> | A single number on a status tile |
POST /v1/analytics/query/events | Matching events plus total_estimate | None server-side; the estimate is your count | filter on event properties | Anything an admin page calls “analytics” |
Three routes, three jobs. The mistake is asking one of them to do another’s.
Log search is a debugging tool, and a good one
It’s free, it’s fast — 3-4ms per read in our testing — and the window is enforced, so you can narrow to the deploy that broke rather than scrolling.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/logs/search?level=error&limit=2&since=2026-07-26T00:00:00Z&until=2026-07-28T00:00:00Z" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"message": "TypeError: Cannot read properties of null (reading total)",
"level": "error",
"timestamp": "2026-07-27T11:54:38.937149Z",
"service": "billing",
"environment": "production",
"attributes": { "tenant": "acme" }
}
],
"next_cursor": null,
"total": 1
}
}
Move that window back to the first two days of June and the same query returns total: 0 — the bound is real, not decorative. What you don’t get is arithmetic: total counts matching lines, and if you want “errors per tenant per day” out of it you’re paginating and counting in your own process. That’s the reason a log route makes a poor analytics backend even when it answers quickly, and it’s the honest limitation of using it as one.
A metrics query returns one number, not a series
Same window support, different shape. Report a few points and read them back:
curl -sS "https://api.infrai.cc/v1/metrics/query?name=checkout_webhook_failures&agg=count&since=2026-07-27T00:00:00Z&until=2026-07-28T00:00:00Z" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"name": "checkout_webhook_failures",
"agg": "count",
"points": [{ "ts": "2026-07-27T11:51:08.377317Z", "value": 3.0 }]
}
}
points holds exactly one element: the aggregate over the window you asked for, timestamped at the newest datapoint inside it. Shift the window into last month and you get points: []. So there’s no line chart hiding in here — a chart means one call per bucket, in a loop you write.
Two things to know before you build on it. The agg list is closed, and sending anything outside avg, sum, count, p50 and p99 gets you a 400 that names the five, which is worth catching in a test rather than in production. And breakdowns come from tags at write time: tag.service=checkout filters, nothing else does. If a real time series with server-side bucketing is the requirement, that’s what Prometheus is built for and this isn’t.
The event query is the one that answers business questions
curl -sS -X POST "https://api.infrai.cc/v1/analytics/query/events" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "content-type: application/json" \
-d '{
"since": "2026-07-01T00:00:00Z",
"until": "2026-07-28T00:00:00Z",
"filter": {"plan": "pro"},
"limit": 2
}'
{
"ok": true,
"data": {
"items": [
{
"event": "invoice_paid",
"distinct_id": "kb_demo_user_7",
"properties": { "plan": "pro", "amount_usd": 49, "tenant": "kb_demo_acme" },
"timestamp": "2026-07-26T05:45:03.186691Z"
}
],
"next_cursor": "2",
"total_estimate": 9
}
}
total_estimate is the count of matching rows, so limit: 1 plus the window you care about is a counter tile that costs one free call. Events go in through POST /v1/analytics/track, which wants an event name and a distinct_id.
The two siblings are the reason this is an event store rather than a log with better filters. POST /v1/analytics/query/retention takes a born_event and a return_event and returns a cohorts array — the cohort matrix, computed server-side. POST /v1/analytics/query/path takes a start_event and returns a tree of what people did next, with a user count on every branch:
{
"root": "invoice_paid",
"children": [
{ "event": "invoice_paid", "users": 4, "children": [{ "event": "invoice_paid", "users": 3, "children": [] }] }
]
}
Both are free reads. Retention and path analysis are the two questions people assume they have to leave the platform for, and they don’t.
An admin endpoint that uses all three properly
// admin-summary.mjs — Node 22, no dependencies
const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY in the environment");
const auth = { authorization: `Bearer ${key}` };
const since = new Date(Date.now() - 7 * 864e5).toISOString();
const until = new Date().toISOString();
async function json(url, init = {}) {
const res = await fetch(url, { ...init, headers: { ...auth, ...(init.headers ?? {}) } });
const parsed = await res.json();
if (!res.ok || parsed.ok !== true) {
throw new Error(`${url} -> ${parsed?.error?.code ?? res.status}`);
}
return parsed.data;
}
// 1. Business count for a real window: the event query.
const week = await json(`${API}/v1/analytics/query/events`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ since, until, filter: { kind: "signup" }, limit: 1 }),
});
// 2. One number for a status tile: the metrics query.
const gauge = await json(
`${API}/v1/metrics/query?name=kb.demo.signups&agg=sum&since=${since}&until=${until}`,
);
// 3. Recent failures for the incident panel: log search.
const failures = await json(`${API}/v1/logs/search?level=error&limit=5&since=${since}&until=${until}`);
console.log(JSON.stringify({
signups_last_7_days: week.total_estimate,
signups_gauge: gauge.points[0]?.value ?? null,
recent_errors: failures.items.map((l) => `${l.service}: ${l.message}`),
}, null, 2));
Note what each call is trusted with. The window number comes from events; the gauge is read as a scalar and never plotted; the log lines are a list to eyeball, not a metric. Get those roles the wrong way round and the page lies without ever throwing.
What the three cost
Every read in this article is free and rate-limited, and none of them consume the new-account trial — which is why an admin page can refresh on every page load without anyone noticing. The writes are where the money is, and the spread is wider than people expect:
| Write | Rate |
|---|---|
POST /v1/logs/ingest | $0.00003 per call |
POST /v1/analytics/track | $0.00005 per call |
POST /v1/metrics/report | $0.001 per call |
GET /v1/logs/search, GET /v1/metrics/query, POST /v1/analytics/query/events | free |
The structural fact outlasts the digits: a metrics counter is the most expensive way to record something here and the least expressive to read back, which inverts the usual intuition that counters are the cheap option. New accounts start with $2 of free credit. Read today’s numbers rather than trusting a table someone wrote months ago:
// price-check.mjs — today's per-call rates for the routes in this article
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY in the environment");
const res = await fetch("https://api.infrai.cc/v1/discovery", {
headers: { authorization: `Bearer ${key}` },
});
if (!res.ok) throw new Error(`discovery lookup failed: HTTP ${res.status}`);
const { capabilities } = await res.json();
const want = new Set(["logs.ingest", "logs.search", "metrics.report", "metrics.query", "analytics.track", "analytics.query.events"]);
for (const cap of capabilities.filter((c) => want.has(c.id))) {
console.log(cap.id.padEnd(24), cap.billing?.price_usd ?? "free", cap.billing?.unit ?? "");
}
There’s a second reason that matters for an admin page specifically. Because the events, the logs, the counters and everything else your backend calls run on one account, GET /v1/account/usage breaks the spend down per capability — so “what does this tenant cost us” is a query you run, not four invoices you reconcile at the end of the month. Per-tenant cost attribution is usually the hardest number on an admin dashboard to produce, and here it’s one bill and one read.
Where this isn’t the right answer
Two boundaries, stated plainly.
There’s no client-side SDK, so the key stays server-side and your admin page reaches these routes through your own Node process rather than from the browser. Better practice regardless, but a real constraint if you were planning a purely static admin panel.
And there’s no self-serve chart builder. Engineers write these queries; a growth lead cannot drag a funnel together in a UI, and there’s no session replay. If the person asking the question doesn’t write code, PostHog earns its price on the interface alone — buy it when the audience for the numbers is outside engineering. What you’d be trading away is that the numbers your admin page needs are already flowing through your backend, on a key you already hold.