Error tracking vs logging in a Node SaaS: which one to call, when

A practical split between exception capture and structured logs for Node.js: grouping, correlation ids, what Infrai's two routes each store, and where each one stops.

The split is about grouping, not severity. An exception you want counted, deduplicated and marked resolved goes to error tracking; a line you want to read in sequence next to fifty other lines goes to logs. On Infrai those are two different routes with two different billing classes — POST /v1/errors/capture groups by fingerprint, POST /v1/logs/ingest appends to a searchable stream — and picking the wrong one for a signal costs you either noise or the ability to answer “how many users hit this?”

A correlation id is what stops the two surfaces from being separate universes. Generate one per request, put it in the error event’s tags and in the log entry’s message, and a triage session becomes: find the group, take an event’s tag, grep the logs for the id. That’s the whole pattern, and it works the same whether the error tracker is Infrai, Sentry or Datadog.

Decide with three questions

Does the thing that happened have a stack and a count? Then it’s an error event. Does it only make sense in order, alongside its neighbours? Then it’s a log line. Does anyone need to mark it “handled”? Only error trackers have that concept.

QuestionError tracking (errors.capture)Logging (logs.ingest)
Unit of workAn issue, deduplicated by fingerprintA line, appended in time order
Volume you’d sendHundreds/dayMillions/day
Read pathGroup list, count, first/last seenSubstring search over message
LifecycleResolve, ignore, reopenNone; expires on retention TTL
Billing on Infrai$0.00005 per call$0.00003 per call
Free companion routePOST /v1/errors/messageGET /v1/logs/search

Those two rates were read on 26 July 2026. Errors cost roughly 1.7x a log call, which is the right shape — you send far fewer of them — and both figures move down over time as platform rates get cut, so check the live number before you build a budget on it:

curl -s "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import sys,json; caps=json.load(sys.stdin)['capabilities']; print([(c['id'], c['billing'].get('price_usd','free')) for c in caps if c['id'] in ('errors.capture','logs.ingest','errors.message')])"

What capture actually stores — and the part it throws away

This is the boundary you need before choosing, and it’s not in any marketing page. Infrai’s capture route accepts an exception object with a stacktrace array, and it does not keep the frames. We posted a full TypeError with a three-key frame and read the event straight back:

curl -s -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "checkout.charge threw",
    "level": "error",
    "exception": {
      "type": "TypeError",
      "value": "gateway.charge is not a function",
      "stacktrace": [{"filename": "/app/checkout.js", "lineno": 42, "function": "charge"}]
    },
    "tags": {"service": "checkout", "request_id": "req_8c41"}
  }'

The stored event comes back with the exception rewritten:

{
  "ok": true,
  "data": {
    "event_id": "evt_err_JWXZJGZJMK9VpaWaSphyLviY",
    "level": "error",
    "title": "checkout.charge threw",
    "exception": { "type": "Message", "value": "checkout.charge threw", "stacktrace": [] },
    "fingerprint_source": "default",
    "group_count": 1,
    "tags": { "service": "checkout", "request_id": "req_8c41" }
  }
}

type became Message, the value became your message string, and the frames are gone. tags survived intact, which is why the correlation id belongs there.

So: Infrai gives you grouping, counting, resolve/ignore and per-group history, but it does not do frame-level stack traces and there’s no support for source maps or minified-bundle symbolication. If a minified front-end stack is the artefact your on-call actually needs, stick with Sentry — that’s its core competency and this route won’t replace it. Datadog Error Tracking is the other honest answer if you’re already paying for Datadog logs and want the two joined in one UI.

The pattern that works with what’s there

Because frames don’t survive, put the useful part of the stack where it does survive: the message, plus tags for the fields you’ll filter on.

// report.mjs — one exception reporter for a Node 22 SaaS. No SDK required.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

export async function reportException(err, { request_id, user_id, route }) {
  // Frames are not retained server-side, so fold the top frame into the title.
  const top = String(err.stack ?? "").split("\n")[1]?.trim() ?? "no frame";
  const body = {
    message: `${err.name}: ${err.message} @ ${top}`,
    level: "error",
    tags: { route, request_id, service: process.env.SERVICE_NAME ?? "api" },
    user_id,
    fingerprint: `${err.name}:${route}`,
  };
  const res = await fetch("https://api.infrai.cc/v1/errors/capture", {
    method: "POST", headers, body: JSON.stringify(body),
  });
  if (!res.ok) {
    process.stderr.write(`capture failed ${res.status}\n`);
    return null;
  }
  const { data } = await res.json();
  return data.error_group_id;
}

fingerprint is the lever worth knowing about. Left alone, grouping is derived from the message, so a message containing an order id explodes into one group per order. Setting it explicitly to something stable — error name plus route, above — keeps a real issue as one row.

Logs carry the story the error can’t

The error event tells you that checkout broke and how often. The five lines before it tell you why. Ship those as logs, with the same request id embedded in the text, because q= searches the message field and nothing else.

// trail.mjs — buffered structured logs for the same request. Node 22 ESM.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

export async function shipTrail(request_id, lines) {
  const entries = lines.map(({ level, text, ...rest }) => ({
    message: `[${request_id}] ${text}`,
    level,                                   // debug | info | warning | error | fatal
    service: process.env.SERVICE_NAME ?? "api",
    environment: process.env.NODE_ENV ?? "production",
    attributes: { request_id, ...rest },
  }));

  const res = await fetch("https://api.infrai.cc/v1/logs/ingest", {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ entries }),
  });
  const payload = await res.json();
  if (!res.ok || payload.ok !== true) throw new Error(`ingest ${res.status}`);
  if (payload.data.accepted !== entries.length) {
    process.stderr.write(`dropped ${entries.length - payload.data.accepted} entries\n`);
  }
  return payload.data.accepted;
}

The bracketed prefix isn’t decoration. q= is a substring match on message and nothing else, so the id has to be in the text if you want to find the trail with a one-line grep; the same id in attributes is reachable, but through the filter parameter rather than q=. Belt and braces costs you twelve characters per line.

Triage, end to end

Two calls, both free, both on concrete paths you can run right now.

# 1. Which issues are open, and how big are they?
curl -s "https://api.infrai.cc/v1/errors/groups?limit=5" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

# 2. Pull the request id out of a group's events, then read the surrounding story
#    inside the window the group's last_seen points at.
curl -s "https://api.infrai.cc/v1/logs/search?q=req_8c41&since=2026-07-26T00:00:00Z&limit=20" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A search response looks like this — note that total is the count across pages and next_cursor is a numeric offset returned as a string:

{
  "ok": true,
  "data": {
    "items": [
      { "message": "[req_8c41] gateway timeout after 3000ms", "level": "warning", "service": "checkout" },
      { "message": "[req_8c41] retry 1 of 2", "level": "info", "service": "checkout" }
    ],
    "next_cursor": null,
    "total": 2
  }
}

Where the free tier sits, and what it doesn’t cover

POST /v1/errors/message is free and takes text rather than message — it’s the route for “something notable happened” events that you still want grouped and countable but wouldn’t pay per-event for. GET /v1/logs/search, GET /v1/errors/groups and GET /v1/errors/search are all free reads. Only the two write paths bill.

The trade-off nobody should discover in an incident is retention. Logs sit on a 30-day TTL, so an error group that’s been quietly recurring since spring will outlive the lines that explain it — the group keeps its count and its first-seen date, and the story behind it is gone. Scope the window in the search itself (since and until are honoured, so since=2026-06-26T00:00:00Z really does bound the result set) and archive anything you’ll want at the 90-day mark. Grafana Loki is the better answer if you want a label-based query language over months of retention and don’t mind operating it. OpenTelemetry is worth adopting on the emit side regardless — it keeps this decision reversible, since an OTel collector can fan the same signal at whichever backend you land on.

Where Infrai earns its place is the seam. The key that captures the exception also runs the queue that retries the failed job, the storage that holds the offending payload and the email that tells the customer — one account, one usage view, and per-tenant cost as a query rather than a reconciliation across four vendors.

References

Browse more logs developer guides