A Mixpanel alternative for business metrics: events vs a custom metrics API
Event store, counter API, or SQL over your own database? What Infrai's /v1/analytics routes answer, what they refuse to, and when PostHog is the better call.
Pick the event store, not the counter. A dashboard that has to answer “how many invoices got paid last week, split by plan” needs one row per thing that happened, kept alongside the properties describing it — and Infrai’s analytics namespace is that store, reachable as plain REST from any backend. POST /v1/analytics/track writes an event, POST /v1/analytics/query/events reads a time window back, and the retention and path queries are free to run.
What Infrai doesn’t ship is a chart. No UI, no drag-and-drop explorer, no saved-report list — you get the store and the queries, and the dashboard itself is a page in your own admin app. That’s the trade-off the rest of this comparison turns on.
Three data shapes, and what each honestly answers
Mixpanel, Amplitude and PostHog are event stores with an analyst-facing UI bolted on. A custom metrics API is a counter: you decide at write time which question you’ll ask later, then read back an aggregate. Metabase and Redash are a third animal entirely — SQL clients pointed at a database you already run and already model.
The axis that decides it is whether the question exists before the data does.
| Backend shape | ”How many paid invoices last week, by plan?" | "Which June signups came back?” | New question needs a code change | Typical products |
|---|---|---|---|---|
| Event store | Yes, from raw events | Yes, retention query | No — properties are already stored | Mixpanel, Amplitude, PostHog, /v1/analytics/track |
| Custom metrics API | Only if you bucketed by plan at write time | No | Yes, a new metric name and a deploy | Prometheus, StatsD, /v1/metrics/report |
| SQL over your own DB | Yes | Yes, with a query you write | No, but you own the schema | Metabase, Redash |
A counter is cheaper and smaller, and it’s right for operational numbers whose shape never changes — queue depth, error rate, requests per second. Business questions mutate every quarter. Somebody will ask for the same number split by country, and with a counter the honest answer is “in three weeks, once we ship a new tag and accumulate data”.
What the analytics routes actually do
Five write routes and three read routes, no SDK involved. POST /v1/analytics/track takes one event and POST /v1/analytics/batch takes up to 1000 in a single call; POST /v1/analytics/identify merges profile traits onto a distinct_id, POST /v1/analytics/group attaches that id to a company for B2B rollups, and POST /v1/analytics/alias folds an anonymous id into a known one after signup.
Here’s the write, concrete enough to paste into a terminal:
curl -sS -X POST https://api.infrai.cc/v1/analytics/track \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
-H "content-type: application/json" \
-d '{
"event": "invoice_paid",
"distinct_id": "kb_demo_user_7",
"properties": {
"kind": "invoice_paid",
"plan": "pro",
"amount_usd": 49,
"tenant": "kb_demo_acme"
}
}'
The response carries an id you can log next to your own record:
{
"ok": true,
"data": { "accepted": true, "event_id": "evt_NNYd4Hrtqd4gRXtsdEMMUemh" },
"metadata": {
"request_id": "req_f85951d743e244858a3ada82",
"latency_ms": 98,
"vendor": "infrai",
"cost_usd": 0.00005
}
}
Event names are validated against ^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$, so "9 bad name!" comes back as a 400 with ANALYTICS_EVENT_NAME_INVALID — the behaviour you want, since a typo’d event name is silent data loss everywhere else. idempotency_key is honoured too: replay the same key and you get the same event_id back, billed once, which is what keeps a retrying webhook handler from double-counting revenue.
Reading back is a POST with an explicit 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-07-26T00:00:00Z",
"until": "2026-07-27T00:00:00Z",
"filter": {"tenant": "kb_demo_acme"},
"limit": 50
}'
{
"ok": true,
"data": {
"items": [
{
"event": "invoice_paid",
"distinct_id": "kb_demo_user_7",
"properties": {"kind": "invoice_paid", "plan": "pro", "amount_usd": 49, "tenant": "kb_demo_acme"},
"timestamp": "2026-07-26T05:49:17.568368Z"
}
],
"next_cursor": null,
"total_estimate": 1
}
}
Two details worth knowing before you design the property bag. filter is a property matcher, so every dimension you plan to slice by later — plan, tenant, country, and a kind key that mirrors the event name — belongs in properties at write time; that’s the one decision an event store still makes you get right up front. And total_estimate counts matching rows for the window, which makes a filter plus a limit of 1 a perfectly good counter tile: one free call, no scanning.
A dashboard tile, end to end
This is the whole pattern — page through a window, fold into whatever shape your admin page renders. Node 22, no dependencies:
// dashboard-tile.mjs — paid invoices in the last 7 days, grouped by plan
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");
async function queryEvents(payload) {
const res = await fetch(`${API}/v1/analytics/query/events`, {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok !== true) {
throw new Error(`query/events failed: ${json?.error?.code ?? res.status}`);
}
return json.data;
}
const since = new Date(Date.now() - 7 * 864e5).toISOString();
const until = new Date().toISOString();
const byPlan = new Map();
let cursor = null;
let scanned = 0;
do {
const page = await queryEvents({ since, until, filter: { kind: "invoice_paid" }, limit: 1000, cursor });
for (const item of page.items) {
scanned += 1;
const plan = item.properties?.plan ?? "unknown";
byPlan.set(plan, (byPlan.get(plan) ?? 0) + 1);
}
cursor = page.next_cursor;
} while (cursor);
console.log(`paid invoices, last 7 days: ${scanned}`);
for (const [plan, n] of [...byPlan].sort((a, b) => b[1] - a[1])) {
console.log(` ${plan.padEnd(12)} ${n}`);
}
Cohort questions don’t need any of that folding, because there’s a route for them:
curl -sS -X POST https://api.infrai.cc/v1/analytics/query/retention \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
-H "content-type: application/json" \
-d '{
"born_event": "signup",
"return_event": "invoice_paid",
"since": "2026-06-01T00:00:00Z",
"until": "2026-07-26T00:00:00Z",
"interval": "week"
}'
Each cohort comes back keyed by the day its born_event first fired, with a returning array of interval buckets. POST /v1/analytics/query/path is the sibling: give it a start_event and a max_depth and it returns a branching tree of what users did next, with a user count on every node.
What it costs, and how to check today’s number
Ingest is billed per call and reads are free. Verified 26 July 2026: analytics.track is $0.00005 per call, and the other four ingest routes — batch, identify, group, alias — are $0.0001 per call, so a 1000-event batch is one $0.0001 charge rather than a thousand. Queries are free but rate-limited, and new accounts start with $2 of free credit (roughly 39,999 track calls). Infrai’s rates drift downward and discount campaigns run, so treat those as a ceiling and read the live ones:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
| python3 -c "import json,sys; [print(c['method'], c['path'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('analytics.')]"
The structural point outlives any of those numbers: batching moves the unit from per-event to per-call, and reads never bill at all, so the cost of a dashboard is the cost of your writes.
Where a specialist beats this
Several places, and they’re not close.
If a product manager needs to build their own funnel without opening a terminal, stick with Mixpanel, Amplitude or PostHog — Infrai has no query builder, no session replay, and no funnel route at all (there’s an ANALYTICS_FUNNEL_STEP_INVALID error code, but nothing in the namespace emits it today), so a multi-step conversion funnel is something you fold together from query/events yourself. If your events already land in Postgres because they’re rows in your product schema, Metabase or Redash over a read replica is cheaper and more flexible than shipping the same facts twice. And a tile that pages through millions of rows on every render is a tile that gets slower every month — cache the fold, or keep a rollup table of your own, once a window stops fitting in a couple of pages.
What you get in exchange is one credential. The key that writes these events also runs the cron job that reads them at 08:00, stores the rendered CSV and emails it to finance — no second vendor, no second invoice, no second key rotation. For a small team whose analytics stack would otherwise be a fourth subscription with a seat price, that consolidation usually decides it, not the per-call rate.