Hosted log API, Datadog, or self-hosted ELK: which to start with

A junior-friendly comparison of hosted log APIs, Datadog and running your own ELK stack for a small Node business, with a working first request and the real gotchas.

If you’re the only backend person at a small company and you need your Node app’s logs somewhere you can search them, start with a plain HTTP log API — Infrai’s POST /v1/logs/ingest takes a JSON batch and GET /v1/logs/search reads it back, so your first working setup is one curl command and no infrastructure. Datadog is the right upgrade later. Self-hosting Elasticsearch is a job, not a setup step.

That ordering isn’t about which product is best. It’s about which failure mode you can afford: a hosted API can be too limited, Datadog can be too expensive, and a self-managed cluster can be down at exactly the moment you needed the logs it was holding. Infrai sits in the first category, and this page is honest about where that bites.

The three options, side by side

Hosted log API (Infrai)DatadogSelf-hosted ELK
Time to first searchable lineone HTTP requestagent or SDK install, then configa JVM, an index template, a Kibana
What you operatenothingan agent per hostElasticsearch nodes, disks, upgrades, retention policy
Billing shapeper ingest call; reads freeper GB ingested, plus per hostservers plus your time
Query powersubstring on the message, two exact filtersfull query language, facets, correlation with tracesfull Lucene/DSL, aggregations
Alerting on a log patternnone built inyesyes, with Watcher or an add-on
Realistic fitside projects, small SaaS, cron outputfunded teams that need APM and logs in one viewteams with an ops person and a compliance reason

The row that decides it for most small teams is the second one. Elastic’s own installation guide walks through JVM heap sizing, TLS bootstrapping and node roles before you have logged anything — that’s an appropriate amount of work for a search cluster and a disproportionate amount of work for finding out why the nightly job failed.

Your first log line, start to finish

Sign in, take the key, and send one entry. This is the whole setup step.

export INFRAI_API_KEY="your_infrai_api_key"

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":"signup completed for tenant acme","level":"info","service":"web","environment":"production","attributes":{"tenant":"acme","plan":"starter"}}]}'
{
  "ok": true,
  "data": { "accepted": 1 },
  "metadata": {
    "request_id": "req_c3e69468ece14f0c83c44f70",
    "latency_ms": 16,
    "vendor": "infrai",
    "cost_usd": 0.0
  }
}

accepted is a count, not a yes. Send five entries, get "accepted": 3, and two of them were thrown away — quietly, with HTTP 200. That happens when an entry is missing message or missing level, and it is the single most common way a first integration silently loses half its data.

Read it back:

curl -s "https://api.infrai.cc/v1/logs/search?q=signup%20completed&service=web&limit=5" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

Structured logging, minus the jargon

“Structured” just means each line is a JSON object with named fields instead of a sentence you’ll later regret parsing. message is the sentence, level is one of debug, info, warning, error, fatal, and everything specific to your business goes in attributes.

Here’s a helper small enough to read in one sitting. It buffers, because billing is per HTTP call rather than per line, and sending one request per log line is the beginner mistake that actually costs money.

// log.mjs — a beginner-sized structured logger for a small Node 22 service.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY before starting the app");

const pending = [];

export function log(level, message, attributes = {}) {
  pending.push({
    message,
    level,
    timestamp: new Date().toISOString(),
    service: process.env.SERVICE_NAME ?? "web",
    environment: process.env.NODE_ENV ?? "development",
    attributes,
  });
}

export async function send() {
  const entries = pending.splice(0, pending.length);
  if (entries.length === 0) return;
  try {
    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 body = await res.json();
    if (body.ok !== true) console.error("log upload failed:", res.status, body.error?.message);
    else if (body.data.accepted < entries.length) console.error("some entries were malformed and dropped");
  } catch (err) {
    console.error("log upload error:", err.message);
  }
}

setInterval(() => { send().catch(() => {}); }, 5000).unref();
process.on("SIGTERM", () => send().finally(() => process.exit(0)));

Two lines of call site and you’re done: log("info", "order paid", { order_id: id }) wherever something interesting happens, log("error", err.message, { stack: err.stack }) in your catch blocks.

Things that look like they work and don’t

This is the part a comparison table can’t show you, and it’s why the cheap option needs checking before you commit to it. We ran these against the live API on 26 July 2026.

  • q is a case-insensitive substring on message only. q=nightly-billing matches; q=nightly billing doesn’t, because it’s a substring and not a word search. Nothing in attributes is searchable at all.
  • service and level filter exactly. Nothing else does: environment and trace_id are accepted and then ignored, and so are the documented filter, since and until parameters — asking for a 2020 date range returned this week’s rows.
  • Unknown query parameters are ignored rather than rejected, so a misspelled filter returns everything and looks like a successful search.
  • The level value isn’t validated on write. We sent "level": "CRITICAL" and it was stored as-is, which means level=error will never find it. Normalise severities in your logger, not later.

None of that makes the API unusable — it makes it a log store rather than a log product, and knowing which one you bought is the whole point of a comparison page.

What it costs, and how to check today’s number

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']['is_billable'], c['billing'].get('price_usd')) for c in caps if c['id'].startswith('logs.')])"

Ingest was $0.00003 per call when we checked on 26 July 2026, and search was free. Buffer 200 lines per request and a busy small app lands in the low tens of cents per month. New accounts get $2 in credit to start, which is tens of thousands of ingest calls before any card is involved. Prices on this platform have moved downward and campaigns run, so treat the command above as the source and this paragraph as an illustration.

Here’s a verification script for after you’ve wired it up — run it once and you know the whole loop works.

# check_logging.py — write one entry, then find it. Python 3.
import os, sys, time, uuid, json, urllib.parse, requests

key = os.environ.get("INFRAI_API_KEY")
if not key:
    sys.exit("INFRAI_API_KEY is not set")

headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
marker = f"selfcheck-{uuid.uuid4().hex[:10]}"

write = requests.post(
    "https://api.infrai.cc/v1/logs/ingest",
    headers=headers,
    json={"entries": [{"message": f"{marker} logging pipeline check", "level": "info", "service": "selfcheck"}]},
    timeout=15,
)
write.raise_for_status()
accepted = write.json()["data"]["accepted"]
print("accepted:", accepted)
if accepted != 1:
    sys.exit("entry was dropped — check that message and level are both present")

time.sleep(1)
read = requests.get(
    "https://api.infrai.cc/v1/logs/search?" + urllib.parse.urlencode({"q": marker, "limit": 5}),
    headers={"Authorization": f"Bearer {key}"},
    timeout=15,
)
read.raise_for_status()
found = read.json()["data"]
print(json.dumps(found, indent=2)[:400])
sys.exit(0 if found["total"] >= 1 else "written but not searchable — retry in a moment")

So which one should you actually pick

Start with the HTTP API if your logging need is “I want to find the error from last Tuesday”. Move to Better Stack when you want live tail, a query builder and an alert when a pattern shows up — that’s their product and it isn’t this one. Buy Datadog when logs have to sit next to traces and host metrics during an incident and someone has signed off on per-GB billing. Choose Grafana Loki if you already run Grafana and would rather own the storage than the invoice, and CloudWatch if your whole stack is already inside AWS and cross-service correlation matters more than query ergonomics.

The argument for starting here isn’t the price. It’s that the same key already reaches error capture, cron, queues and object storage, so the next thing you need after logs — a scheduled job, a retry queue, somewhere to park a failed payload — isn’t another vendor, another key and another invoice. The drawback is equally plain: this API doesn’t support alerting, attribute search or time-range queries, and if those are what you’re shopping for, one of the products above is the honest answer.

References

Browse more logs developer guides