Admin analytics for a Node SaaS: metrics counter, log search, or an event query?

Verified on the live API: log search ignores your time window and a metrics query returns one point. Here's which Infrai read route an admin page should call.

Neither, if the question has a date in it. An admin page that reports “signups this week” or “tenants active in June” wants a time-bounded count, and the two obvious candidates on Infrai can’t produce one: GET /v1/logs/search accepts since and until and ignores them, while GET /v1/metrics/query folds an entire metric into a single point no matter what range you ask for. The route that honours a window is the event query.

We checked all three on the live API on 26 July 2026 rather than reading the reference, because this is precisely the kind of thing a reference paper over. What follows is what the responses actually contained.

What each read route will answer

Read routeTime windowAggregationSlice by dimensionRight job
GET /v1/logs/searchNo — since/until are accepted and ignoredNone, just totallevel, service, free-text qDebugging one request or one bad deploy
GET /v1/metrics/queryNo — from/to are accepted and ignoredavg, sum, count, p50, p99, over everything retainedtag.<key>=<value>One current number on a status tile
POST /v1/analytics/query/eventsYes — since and until are enforcedNone server-side; total_estimate is your countfilter on event propertiesAnything 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 wearing an analytics hat

The search route is free, fast (6-13ms in our testing) and genuinely good at what it does:

curl -sS "https://api.infrai.cc/v1/logs/search?q=error&level=error&limit=3" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "message": "TypeError: Cannot read properties of null (reading total)",
        "level": "error",
        "timestamp": "2026-07-26T05:45:43.411924Z",
        "service": "billing",
        "environment": "production",
        "attributes": {
          "tenant": "acme",
          "stack": ["at total (billing.mjs:42:9)", "at invoice (billing.mjs:88:3)"]
        }
      }
    ],
    "next_cursor": null,
    "total": 1
  }
}

q, level and service all narrow the result set — dropping our account’s total from 5718 to 6 and then to 1. since, until and environment did not: every one of those queries came back with the same 5718. Unknown query parameters are dropped in silence too, which means a dashboard built on ?since=… looks like it works and quietly reports all-time figures forever.

That’s the failure mode to design around. Log search is for “show me what broke”, not “count what happened last Tuesday”, and if you want time-bounded counts out of log lines you’d be better off with Grafana Loki, where the range is part of the query language.

A metrics query returns one number, not a series

Same shape of problem, different route. Report a few points and read them back:

curl -sS "https://api.infrai.cc/v1/metrics/query?name=kb.demo.signups&agg=sum" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "name": "kb.demo.signups",
    "agg": "sum",
    "points": [{ "ts": "2026-07-26T05:52:52.996608Z", "value": 12 }]
  }
}

Three datapoints of 3, 7 and 2 went in. sum returned 12, count returned 3, avg returned 4 and p99 returned 7 — each of them a points array holding exactly one element, timestamped at the newest datapoint. Adding from and to changed nothing. So there is no line chart hiding in here; what you have is a single aggregate over everything retained under that name.

Two consequences worth flagging. First, an unrecognised agg doesn’t 400 — agg=bogus returned HTTP 200 with "agg": null and the arithmetic mean, so a typo in your dashboard code produces a plausible wrong number instead of an error. Second, the only way to get a breakdown is to put the dimension in the tags at write time and query each value separately: tag.day=2026-07-25 returned 2 from the same series. Write-time bucketing is the whole design, and if you need a series you’d be reaching for Prometheus.

The event query is the one with a window

curl -sS -X POST https://api.infrai.cc/v1/analytics/query/events \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "content-type: application/json" \
  -d '{
    "since": "2026-06-09T00:00:00Z",
    "until": "2026-06-10T00:00:00Z",
    "limit": 3
  }'

That window returned total_estimate: 69 with timestamps all inside 9 June; moving it to 1 June returned zero. The enforcement is real, and total_estimate is the count of matching rows — so a limit of 1 plus the window you care about is a counter tile that costs one free call.

Events get in through POST /v1/analytics/track, which needs an event name and a distinct_id.

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}` };

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 since = new Date(Date.now() - 7 * 864e5).toISOString();
const until = new Date().toISOString();
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. Current gauge for a status tile: the metrics query.
const gauge = await json(`${API}/v1/metrics/query?name=kb.demo.signups&agg=sum`);

// 3. Recent failures for the incident panel: log search.
const failures = await json(`${API}/v1/logs/search?level=error&limit=5`);

console.log(JSON.stringify({
  signups_last_7_days: week.total_estimate,
  signups_all_time_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

Reads are free on all three routes — logs.search, metrics.query and analytics.query.events are free but rate-limited, and none of them consume the new-account trial. Writes are where the money is, and the spread is wide: verified 26 July 2026, logs.ingest is $0.00003 per call, analytics.track is $0.00005 per call, and metrics.report is $0.001 per call — roughly 20 times an event. Every one of them bills per call rather than per record, so batching is what actually moves your bill. New accounts get $2 of free credit. Rates here move down over time, so read today’s:

// 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 ?? "");
}

The structural fact outlasts the figures: a metrics counter is the most expensive way to record an event here and the least expressive to read back, which inverts the usual intuition that counters are the cheap option.

Where this isn’t the right answer

Two boundaries, stated plainly.

The API sends no CORS headers and an OPTIONS preflight comes back 401, so your admin page cannot call any of this from the browser — the fetches have to run in your Node process and reach the client through your own route. That’s better practice anyway (your key stays server-side), but it’s a real constraint if you were planning a static admin panel.

And this isn’t product analytics. There’s no funnel route, no session replay, no self-serve chart builder, and nobody outside engineering will be writing queries against it. If your actual question is “which onboarding step do people abandon”, PostHog gives you that in an afternoon; if it’s “how much traffic did the pricing page get”, Plausible or Google Analytics is the shorter path. What Infrai covers well is the case where the numbers your admin page needs are already flowing through your backend, and you’d rather add one route to a key you already hold than sign up for a fourth vendor to count them.

References

Browse more analytics developer guides