Which API for centralized log ingest and search in a startup dashboard
Pick between a log ingest API and an error capture API for your admin dashboard: what each one stores, what survives the write, and what you must redact first.
For a startup dashboard you almost certainly want two APIs, not one. Infrai splits the job: POST /v1/logs/ingest and GET /v1/logs/search store and retrieve individual lines, while POST /v1/errors/message and GET /v1/errors/groups deduplicate repeated failures into counted groups. A “recent activity” panel is a log query. A “top 5 problems this week” panel is an error-grouping query, and no amount of log search will produce it.
Choosing wrong is expensive in a specific way: log lines never collapse, so a panel built on GET /v1/logs/search shows the same broken cron job forty times and hides everything else. Infrai’s error routes fingerprint on write and hand you count, first_seen_at and last_seen_at per group, which is exactly the shape a dashboard tile needs.
What each side actually stores
logs.* | errors.* | |
|---|---|---|
| Unit written | a batch of entries under entries | one event, text for errors.message |
| Deduplication | none — every line is a row | fingerprinted into an error_group_id |
| What a read returns | items[] with next_cursor and total | groups with count, first_seen_at, last_seen_at, is_resolved |
| Free reads | GET /v1/logs/search | GET /v1/errors/groups, GET /v1/errors/search, GET /v1/errors/list |
| Billed writes | logs.ingest, per call | errors.capture, per call; errors.message is free |
| Good dashboard panel | live feed, per-service filter | top problems, new-since-yesterday, resolve button |
Both sit behind the same key, which is the practical reason to use both rather than shoehorn everything into one.
The write that loses your stack trace
POST /v1/errors/capture accepts an exception object with type, value and stacktrace. We sent one on 26 July 2026 with a TypeError and a frame list, then fetched the stored event back with GET /v1/errors/get/{event_id}. The value had become the event title, type had been rewritten to "Message", and stacktrace came back as an empty array.
curl -s -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"exception":{"type":"TypeError","value":"Cannot read properties of null (reading total)","stacktrace":[{"filename":"billing.mjs","lineno":42}]},"level":"error","tags":{"env":"production"}}'
{
"ok": true,
"data": {
"event_id": "evt_err_OqaB8ud7Lwk7ajwKPEmtchHn",
"fingerprint": "f93e1af7dcbea7fcc11f1da380417a1d3ef28fe39a07c1dc96626da065ef3b37",
"error_group_id": "errgrp_2RevNHQFfAWqistQTLtdDtjE",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_OqaB8ud7Lwk7ajwKPEmtchHn"
}
}
So the grouping is real and the frames are not. If your dashboard needs a stack to be useful, put the stack where it survives — attributes on a log entry comes back byte-for-byte, including nested objects, arrays and nulls.
curl -s -X POST "https://api.infrai.cc/v1/logs/ingest" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"entries":[{"message":"TypeError: Cannot read properties of null (reading total)","level":"error","service":"billing","environment":"production","attributes":{"error_group_id":"errgrp_2RevNHQFfAWqistQTLtdDtjE","stack":["at total (billing.mjs:42:9)","at invoice (billing.mjs:88:3)"],"tenant":"acme"}}]}'
Writing the error_group_id into the log entry is the trick that makes the two APIs one product: the group tile gives you counts, and one substring search pulls the full frames for any event in that group.
The read side, and one thing to check before you ship
curl -s "https://api.infrai.cc/v1/errors/groups?limit=5" \
-H "Authorization: Bearer $INFRAI_API_KEY"
{
"ok": true,
"data": {
"groups": [
{
"error_group_id": "errgrp_17E05u607XMVSasuMpsrMX7w",
"title": "worker loop error",
"first_seen_at": "2026-07-06T08:57:58.564303Z",
"last_seen_at": "2026-07-06T09:00:23.307062Z",
"count": 19,
"level": "error",
"is_resolved": false,
"environments": ["prod"],
"releases": []
}
],
"next_cursor": null,
"total": 42
}
}
Free reads are the reason a dashboard can poll this every thirty seconds without a budget conversation. errors.message is a free write as well — it needs a non-empty text field and returns 400 INVALID_ARGUMENT with param: "text" if you send anything else — so a startup can run error grouping at zero marginal cost and pay only for the log lines.
curl -s -X POST "https://api.infrai.cc/v1/errors/message" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"cron nightly-billing exited 1"}'
Now the dashboard endpoint itself. It runs both queries in parallel and returns one payload your admin page can render.
// dashboard.mjs — GET /admin/overview for a small startup console. Node 22 ESM.
import { createServer } from "node:http";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) { console.error("INFRAI_API_KEY is not set"); process.exit(1); }
const auth = { authorization: `Bearer ${KEY}` };
async function readJson(url) {
const res = await fetch(url, { headers: auth });
const payload = await res.json();
if (!res.ok || payload.ok !== true) {
throw new Error(`${url} -> ${res.status} ${payload.error?.code ?? "unknown"}`);
}
return payload.data;
}
async function overview() {
const [groups, lines] = await Promise.all([
readJson("https://api.infrai.cc/v1/errors/groups?limit=5"),
readJson("https://api.infrai.cc/v1/logs/search?level=error&limit=25"),
]);
return {
top_problems: groups.groups.map((g) => ({
id: g.error_group_id,
title: g.title,
count: g.count,
last_seen: g.last_seen_at,
resolved: g.is_resolved,
})),
recent_error_lines: lines.items.map((row) => ({
at: row.timestamp,
service: row.service,
message: row.message,
stack: row.attributes?.stack ?? null,
})),
total_error_lines: lines.total,
};
}
createServer(async (req, res) => {
if (req.url !== "/admin/overview") { res.writeHead(404).end(); return; }
try {
const data = await overview();
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(data));
} catch (err) {
console.error("overview failed:", err.message);
res.writeHead(502, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "upstream observability call failed" }));
}
}).listen(3000, () => console.log("dashboard on :3000"));
Redact before you send, because nothing redacts afterwards
This is where a logging decision becomes a security decision, and it’s worth ten minutes before your first deploy.
Everything in attributes is stored verbatim and returned verbatim. There’s no field masking, no PII scrubber, no per-service scoping on the read side — a key that can write logs can also search every other service’s logs on that account, and there’s no delete route for a log entry once it lands. Spraying req.headers or a whole request body into an entry therefore permanently parks an Authorization header or a customer email in a store you can’t selectively clean. Strip it at the call site with an allowlist of fields rather than a denylist of secrets, and keep tokens, card data and full request bodies out entirely. The same applies to errors.message: whatever you put in text becomes a group title that shows up on a dashboard.
Rate-limited free reads and per-call billed writes make polling cheap and firehosing expensive — which, as trade-offs go, points you at the right design anyway.
When something else is the better buy
Better Stack is the honest recommendation if the dashboard you’re describing is really a log product: live tail, saved queries and alerts on patterns are things this API doesn’t support at all. Datadog earns its price when an incident needs logs, traces and host metrics correlated in one timeline. And if you’ve standardised on OpenTelemetry collectors, note that there’s no OTLP endpoint here — ingest is a plain JSON POST, so you’d be writing an exporter or running a collector with an HTTP sink.
What you get by staying is narrower and duller: two small APIs that already share a key with cron, queues, storage and email, one usage view that attributes cost per capability, and a service string as your only real dimension. For a startup admin page, that’s usually enough — right up until it isn’t, and then the swap is one HTTP client, not a re-platform.