Rollout KPI dashboards on a budget: metrics API vs Statsig and PostHog

Statsig, PostHog Insights and Grafana Cloud each solve a different half of feature-rollout KPI monitoring. Where a plain counters API is enough, and where it isn't.

Watching a feature rollout on a startup budget comes down to one question: do you need statistics, or do you need counters? Statsig ships an assignment engine and a significance calculator. PostHog Insights ships product analytics with a chart builder on top. Infrai ships two REST routes — POST /v1/metrics/report writes a named number with tags, GET /v1/metrics/query reads a windowed aggregate back — and expects you to own the display layer.

If your rollout KPI is “did the error rate for the on cohort go up over the last six hours”, counters are enough and the second option costs you a monthly seat price you don’t need. If the KPI is “is the 1.8% lift statistically real”, you want an experimentation engine, and Infrai won’t help.

The four things people mean by “metrics dashboard”

These products get compared as if they’re substitutes. They aren’t, and the comparison table readers actually need is about scope, not price.

ToolWhat it’s genuinely forHow the data gets inWhat you still build
StatsigVariant assignment plus a stats engine (sequential tests, guardrail metrics)client/server SDK, events keyed by experimentalmost nothing
PostHog InsightsProduct analytics — funnels, session replay, a chart builder alongside flagscapture SDK or ingest APIalmost nothing
Grafana CloudDashboards and alert rules over a Prometheus-style time series storescrape target or a push agent per servicedashboards, PromQL, retention policy
Infrai metricsNamed counters and gauges behind one API key, queried over a time windowone HTTP POST from your own server codethe rule, the chart, the alert routing

The row that surprises people is Grafana Cloud. It’s not a cheap Statsig — it’s an operational stack, and the operational stack is where the ongoing work lives (an agent per environment, a scrape config, series-cardinality budgets). For an EU or US startup with four services and no platform engineer, that’s usually the wrong shaped cost.

Infrai’s version is deliberately smaller. There’s no agent, no scrape endpoint, no exporter — your handler posts a number when something happens.

Emitting a KPI per variant

Tag the metric with the variant and you can compare cohorts later without a second data model. Here’s the raw call, which you can paste into a terminal right now:

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": "checkout.completed",
    "value": 1,
    "type": "counter",
    "tags": {"variant": "new_flow", "region": "eu", "release": "2026.07.4"}
  }'

The response confirms the write and hands back an id you can log next to your own request id:

{
  "ok": true,
  "data": { "accepted": true, "metric_id": "metric_SjIJH5ZPb9PlgkXkWbBSQQ1M" },
  "metadata": { "request_id": "req_ebcb753199bc4c7cb653eece", "latency_ms": 27 }
}

In application code you want one helper, not a call site full of fetch options. This is Node 22 ESM, no dependencies:

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

export async function kpi(name, value, tags) {
  const res = await fetch(`${API}/v1/metrics/report`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ name, value, type: "counter", tags }),
  });
  if (!res.ok) {
    const detail = await res.text();
    console.error(`metric ${name} rejected: HTTP ${res.status} ${detail}`);
    return null;
  }
  const { data } = await res.json();
  return data.metric_id;
}

export async function recordCheckout(variant, region) {
  await kpi("checkout.completed", 1, { variant, region, release: "2026.07.4" });
}

Wrap it in a try/catch at the call site or fire it without awaiting. Telemetry that can fail a checkout is worse than no telemetry.

Reading the cohorts back

The read side takes the metric name, an aggregation, a tag filter as tag.<key>, and a time range: since, until and a bucket window such as 1h or 1d. That last trio is what turns a single number into a line you can plot.

curl -sS "https://api.infrai.cc/v1/metrics/query?name=checkout.completed&agg=count&tag.variant=new_flow&since=2026-07-23T00:00:00Z&until=2026-07-26T00:00:00Z&window=1d" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "name": "checkout.completed",
    "agg": "count",
    "points": [
      { "ts": "2026-07-23T00:00:00Z", "value": 412.0 },
      { "ts": "2026-07-24T00:00:00Z", "value": 508.0 },
      { "ts": "2026-07-25T00:00:00Z", "value": 561.0 }
    ]
  }
}

agg accepts count, sum, avg, p50 and p99. Hand it something else — median, say — and you get a 400 rather than a series of zeros that quietly reads like a flat week.

Two queries and a division give you the comparison a rollout dashboard exists to show:

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 since = new Date(Date.now() - 7 * 864e5).toISOString();

async function series(name, variant) {
  const url = new URL(`${API}/v1/metrics/query`);
  url.searchParams.set("name", name);
  url.searchParams.set("agg", "count");
  url.searchParams.set("tag.variant", variant);
  url.searchParams.set("since", since);
  url.searchParams.set("window", "1d");
  const res = await fetch(url, { headers: { authorization: `Bearer ${KEY}` } });
  if (!res.ok) throw new Error(`query failed: HTTP ${res.status}`);
  const { data } = await res.json();
  return data.points ?? [];
}

const total = (points) => points.reduce((acc, p) => acc + p.value, 0);

for (const variant of ["new_flow", "control"]) {
  const done = await series("checkout.completed", variant);
  const started = await series("checkout.started", variant);
  const rate = total(started) ? (total(done) / total(started)) * 100 : 0;
  console.log(`${variant.padEnd(9)} ${total(done)}/${total(started)} = ${rate.toFixed(2)}%`);
  console.log(done.map((p) => `${p.ts.slice(0, 10)} ${p.value}`).join("  "));
}

That’s the whole dashboard backend: a conversion rate per cohort and the daily buckets behind it. Point it at a table in your existing admin app and you’re done — no new front end, no new login for the team.

What it costs, and how to read today’s rate

Writes are billable and reads are not. POST /v1/metrics/report and POST /v1/metrics/batch are $0.001 per data point, verified 2026-07-26; GET /v1/metrics/query is free but rate-limited. New accounts start with $2 of credit, which is roughly 2,000 points before you pay anything.

Rates move, and they mostly move down, so read the current number rather than trusting this paragraph:

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

And to see what you’ve actually spent across every capability on the account, not just metrics:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '{total_cost, total_calls, breakdown: .breakdown[0:5]}'

The structural point matters more than the figure. Billing is per data point, not per series and not per monthly active user, so a rollout that doubles your traffic doesn’t move you into a new plan tier — it just doubles a number you can already see. POST /v1/metrics/batch is priced the same way, per point, so batching saves you round trips rather than money: in our testing a 200-point batch came back in about 2.3 seconds, where 200 separate reports would have cost you 200 connections.

Where the specialists win

Be clear about the boundary. Infrai has no experiment assignment, no sequential testing, no CUPED variance reduction and no automatic significance verdict — if you’re running real A/B tests and need someone to tell you when to stop, stick with Statsig or PostHog. That’s a genuinely different product, not a missing feature we’re being coy about.

The read-path limitation is narrower than it used to sound. GET /v1/metrics/query gives you one named series, one aggregation and one bucket size; it doesn’t join two metrics for you, and it has no funnel or cohort operator. A three-step funnel means three queries and your own arithmetic. For dense charts over years of history, with recording rules and alert expressions on top, Prometheus with Grafana is still the better tool and it’s worth saying so plainly.

What it isn’t is a reason to leave for event-shaped questions. Infrai’s analytics namespace is a real event store on the same key: POST /v1/analytics/query/events reads a windowed event count, POST /v1/analytics/query/retention returns a cohort matrix and POST /v1/analytics/query/path walks the sequences users actually took — all three free and rate-limited, with only the ingest side billable and priced per event. So “did the new flow retain better in week two” doesn’t require a second vendor. PostHog wins on the console you’d hand a PM, not on whether the data exists.

The other half of the argument is the same key already reaching error capture, queues, cron and object storage. When the rollout dashboard turns into “email me the daily variant summary”, that’s the same account and the same bill — not a second vendor, a second SDK and a second invoice to reconcile. For a team of three, that consolidation tends to be worth more than a nicer chart.

References

Browse more metrics developer guides