Flag stats, custom metrics or events: measuring a rollout from the backend
Infrai's flags routes report no statistics by design. Which of the metrics counter and the analytics event store answers your rollout question, with Node 22 code for both.
Infrai’s feature flags namespace reports no statistics at all — no impressions route, no per-variant conversion, no evaluation count — and that’s a design decision rather than a gap. Flag evaluation happens in your process or behind get_value, and the API deliberately doesn’t retain who saw what. Measuring a rollout is therefore a separate write, and you have two places to put it: POST /v1/metrics/report for a counter, or POST /v1/analytics/track for an event.
Pick by the question, not by the tool. A counter answers “what is the number right now, split by the tags I chose at write time”. An event store answers questions you haven’t thought of yet, because it keeps one row per thing that happened with all its properties attached.
Which store answers which question
| Question about your rollout | flags routes | metrics counter | analytics events |
|---|---|---|---|
| ”How many users are in the treatment arm?“ | no — nothing is recorded | yes, agg=count on a tagged metric | yes, count events by property |
| ”What’s the conversion rate per arm?“ | no | yes, mean of 0/1 values | yes, and per user |
| ”Did conversion move after Tuesday’s ramp?“ | no | yes, one query per window | yes, windowed query |
| ”Did the treatment cohort come back next week?“ | no | no — a counter has no user identity | yes, query/retention |
| ”What did users do after the new wizard?“ | no | no | yes, query/path |
The third row used to be the hard one. GET /v1/metrics/query takes an explicit time window, so a before-and-after comparison is two calls with different from/to values rather than a shrug.
The event write
Both of the routes below are one HTTP call with no SDK. Start with the event, because it’s the one that survives the question changing.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/analytics/track" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"event": "signup_wizard_completed",
"distinct_id": "kb_demo_user_31",
"properties": {"variant": "treatment", "flag": "kb_signup_wizard_v3", "plan": "free"},
"idempotency_key": "wizard-kb_demo_user_31-2026-07-26"
}'
{
"ok": true,
"data": { "accepted": true, "event_id": "evt_NNYd4Hrtqd4gRXtsdEMMUemh" },
"metadata": { "request_id": "req_f85951d743e244858a3ada82", "latency_ms": 98, "cost_usd": 0.00005 }
}
idempotency_key earns its keep in a retrying worker: send the same key twice and you get the same event_id back, billed once, so a redelivered queue message doesn’t inflate the treatment arm. Event names are validated against ^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$, so a typo’d name is a 400 with ANALYTICS_EVENT_NAME_INVALID rather than silent data loss in a column nobody reads.
Reading a per-variant rate out of events
POST /v1/analytics/query/events takes a window and pages with a cursor. Fold the pages into whatever your admin page renders — this is the whole dashboard pattern, and it’s free to run.
// rollout-tile.mjs — exposure and conversion per variant, last 7 days. Node 22 ESM.
const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
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: ${json?.error?.code ?? res.status}`);
return json.data;
}
const since = new Date(Date.now() - 7 * 864e5).toISOString();
const until = new Date().toISOString();
const arms = new Map();
for (const stage of ["exposed", "completed"]) {
let cursor = null;
do {
const page = await queryEvents({
since,
until,
filter: { flag: "kb_signup_wizard_v3", stage },
limit: 1000,
cursor,
});
for (const item of page.items) {
const variant = item.properties?.variant ?? "unknown";
const row = arms.get(variant) ?? { exposed: 0, completed: 0 };
row[stage] += 1;
arms.set(variant, row);
}
cursor = page.next_cursor;
} while (cursor);
}
for (const [variant, { exposed, completed }] of arms) {
const rate = exposed === 0 ? "n/a" : `${((completed / exposed) * 100).toFixed(1)}%`;
console.log(`${variant.padEnd(10)} exposed=${exposed} completed=${completed} rate=${rate}`);
}
filter matches against the event’s properties, which is why stage and flag are properties rather than baked into the event name — one write shape, and every later split (by plan, by country, by anything you remembered to attach) is a query change instead of a deploy.
When a counter is the right answer
If the number is operational — request rate, queue depth, a sanity check that both arms are receiving traffic — a metric point is smaller, and the read is a single aggregate you can put straight on a tile. Conversion works as a mean of zeros and ones: report 1 on completion and 0 otherwise, tag both with the variant, and ask for agg=avg.
curl -sS -X POST "https://api.infrai.cc/v1/metrics/batch" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"points": [
{"name": "kb_signup_wizard_completed", "value": 1, "tags": {"variant": "treatment"}},
{"name": "kb_signup_wizard_completed", "value": 0, "tags": {"variant": "control"}}
]}'
curl -sS -G -X GET "https://api.infrai.cc/v1/metrics/query" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
--data-urlencode "name=kb_signup_wizard_completed" \
--data-urlencode "agg=avg" \
--data-urlencode "tag.variant=treatment" \
--data-urlencode "from=2026-07-19T00:00:00Z" \
--data-urlencode "to=2026-07-26T00:00:00Z"
agg accepts avg, sum, count, p50 and p99; anything else is rejected with a 400, so a typo in an aggregation name is an error rather than a plausible wrong number on a chart. Run the same query with the window either side of your ramp and you have the before-and-after without storing a single user id.
The trade-off is a real one. A counter gives you no confidence interval, no sample size unless you query count separately, and no notion of which users those numbers came from — the identity is gone the moment the point lands. For a ramp check that’s fine. If you need to decide whether to ship, query the events instead.
What it costs
Verified 2026-07-26: analytics.track is $0.00005 per call and the batch, identify, group and alias routes are $0.0001 per call, while metrics.report and metrics.batch are $0.001 per point — batching metric points saves round trips, not money. All the read routes on both namespaces are free and rate-limited, and a new account starts with $2 of credit. That price ratio is the actual guidance: at 1 million product events a month, events cost about $50 and the same volume as metric points costs about $1,000, so operational counters stay in metrics and per-user product events go to analytics.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.namespace=="analytics" or .namespace=="metrics") | {id, price: .billing.price_usd, unit: .billing.unit, free: .billing.free}]'
Rates here move over time and generally downward, and discount campaigns run, so read today’s figure rather than trusting this paragraph.
Where a specialist still wins
Infrai gives you the store and the queries; it doesn’t give you a chart. There’s no drag-and-drop explorer, no saved-report list, no session replay, and no funnel route — the ANALYTICS_FUNNEL_STEP_INVALID error code exists but nothing in the namespace emits it today, so a multi-step funnel is something you assemble from query/events yourself. If a product manager needs to build a funnel without opening a terminal, PostHog, Mixpanel or Amplitude are the better buy and you should take it; PostHog in particular puts the flag and the funnel it’s meant to move in one product. LaunchDarkly sells the governance layer instead — approval workflows and an audit trail on flag changes — which is worth its seat price when someone has to sign off, and isn’t when flags ship through code review.
The consolidation argument is the durable one. The key that evaluates the flag also writes the event, runs the cron job that emails the weekly rollup, and captures the exception when the new wizard throws — one credential, one invoice, no fourth subscription.