Logging vs error tracking vs metrics: which one a small SaaS needs first

Three pillars, three different questions, three very different bills. A beginner-friendly triage for Node backends, with runnable Infrai metrics calls to start from.

Pick by the question you’re trying to answer. Metrics answer “how many, how fast, is it worse than yesterday” — cheap numbers with tags, and on Infrai that’s POST /v1/metrics/report plus a free GET /v1/metrics/query. Error tracking answers “what broke, is it new, how many users hit it”. Logs answer “what exactly happened inside this one request at 14:32”.

Most small SaaS teams buy them in the wrong order. They install a log shipper first because logs feel familiar, discover six months later that the bill scales with how talkative their code is, and still can’t answer whether the API is slower this week than last. Metrics are the pillar that stays cheap as you grow, so start there.

Three questions, three tools

The question in your headPillarShape of the dataHow the bill grows
”Is the error rate climbing? Is p99 latency worse?”Metricsa name, a number, a few tagswith how often you flush, not how busy you are
”What exception is this, is it new, how many people hit it?”Error trackinggrouped exceptions with stack traceswith distinct events
”What happened, in order, in request req_9f2c1?”Logstimestamped text lineswith bytes ingested and indexed

Notice the third column. A counter is a handful of bytes whatever the underlying event was; a log line carrying a serialised request body can be kilobytes. That difference is why metrics-first is the frugal order, not a matter of taste.

Starting with a number

The smallest useful thing is a single call. Try it in a terminal before you touch application code:

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": "api.request.duration_ms",
    "value": 183.4,
    "type": "gauge",
    "tags": {"route": "GET /orders", "env": "production"}
  }'
{
  "ok": true,
  "data": { "accepted": true, "metric_id": "metric_SjIJH5ZPb9PlgkXkWbBSQQ1M" },
  "metadata": { "request_id": "req_ebcb753199bc4c7cb653eece", "latency_ms": 27 }
}

Two field names do all the work. type is counter for things you add up and gauge for things you measure; tags is how you slice later, so put the route, the environment and the service in there and nothing high-cardinality.

Here’s the same idea as a module you’d actually import — Node 22, no dependencies, and it deliberately swallows its own failures:

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 send(name, value, type, tags) {
  try {
    const res = await fetch(`${API}/v1/metrics/report`, {
      method: "POST",
      headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
      body: JSON.stringify({ name, value, type, tags }),
    });
    if (!res.ok) console.error(`metric ${name}: HTTP ${res.status}`);
  } catch (err) {
    console.error(`metric ${name} failed`, err.message);
  }
}

export const increment = (name, tags = {}) => send(name, 1, "counter", tags);
export const observe = (name, ms, tags = {}) => send(name, ms, "gauge", tags);

export function timed(routeName, handler) {
  return async (req, res) => {
    const started = performance.now();
    try {
      await handler(req, res);
      increment("api.request", { route: routeName, outcome: "ok" });
    } catch (err) {
      increment("api.request", { route: routeName, outcome: "error" });
      throw err;
    } finally {
      observe("api.request.duration_ms", performance.now() - started, { route: routeName });
    }
  };
}

Monitoring code that can throw is a liability. Every path in there ends in a console.error, never a rejected promise reaching your handler.

Reading it back, and what that read can’t do

curl -sS "https://api.infrai.cc/v1/metrics/query?name=api.request.duration_ms&agg=p99" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "name": "api.request.duration_ms",
    "agg": "p99",
    "points": [{ "ts": "2026-07-26T00:16:49.254928Z", "value": 183.4 }]
  }
}

Supported aggregations are avg, sum, count, p50 and p99. Ask for something else — max, say — and you get the average back with "agg": null, which is a quiet failure mode worth checking for in a script:

import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import json

key = os.environ["INFRAI_API_KEY"]
params = urllib.parse.urlencode({"name": "api.request.duration_ms", "agg": "p99"})
req = urllib.request.Request(
    f"https://api.infrai.cc/v1/metrics/query?{params}",
    headers={"Authorization": f"Bearer {key}"},
)

try:
    with urllib.request.urlopen(req, timeout=10) as resp:
        body = json.load(resp)
except urllib.error.HTTPError as exc:
    print(f"query failed: HTTP {exc.code}", file=sys.stderr)
    raise SystemExit(1)

data = body["data"]
if data["agg"] is None:
    print("aggregation was not recognised; value is the mean", file=sys.stderr)
points = data["points"]
print(points[0]["value"] if points else "no data yet")

This is also the honest boundary of the metrics pillar. A p99 of 4,200ms tells you something is slow; it will never tell you which query, which tenant, or what the stack looked like. For that you need an exception with a stack trace, or the ordered log lines around it — the same Infrai key reaches error capture and log ingest, so adding the second pillar is a new call rather than a new vendor, but it is still a different pillar and you should expect to add it.

What each pillar costs you

Reads are free and rate-limited. Metric writes are $0.001 per call — that’s POST /v1/metrics/report and POST /v1/metrics/batch, both verified 2026-07-26, and both priced per call rather than per data point, so batching many points into one request is the cheap path. A new account starts with $2 of credit, roughly 1,999 write calls, which is plenty to instrument a side project end to end.

Prices drift downward over time, so read the live figures instead of quoting this page a year from now:

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

When to buy the specialist instead

If you’re already on Kubernetes with someone who knows PromQL, Prometheus scraping your pods costs nothing per call and gives you real range queries and recording rules. If you need distributed tracing across services, OpenTelemetry with a proper backend is the standard and a counters API doesn’t compete. If your problem is a mobile crash reporter with symbolication, or you want one vendor to own dashboards, APM and synthetic checks together, Datadog does that and a small set of REST routes doesn’t.

The limitation to plan around on the metrics side is the read shape: one query returns one aggregate across the retained series, with no from/to window. Trend lines mean bucketing at write time with a tag like tag.day=2026-07-26, or storing daily rollups in your own database.

For a two-person team shipping a Node API, the order that works is metrics first, error tracking second, logs last and cheapest-tier — and keeping all three on one key means the daily digest job, the queue that sends it and the storage for the CSV are already paid for on the same bill.

References

Browse more metrics developer guides