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) | Datadog | Self-hosted ELK | |
|---|---|---|---|
| Time to first searchable line | one HTTP request | agent or SDK install, then config | a JVM, an index template, a Kibana |
| What you operate | nothing | an agent per host | Elasticsearch nodes, disks, upgrades, retention policy |
| Billing shape | per ingest call; reads free | per GB ingested, plus per host | servers plus your time |
| Query power | substring on the message, two exact filters | full query language, facets, correlation with traces | full Lucene/DSL, aggregations |
| Alerting on a log pattern | none built in | yes | yes, with Watcher or an add-on |
| Realistic fit | side projects, small SaaS, cron output | funded teams that need APM and logs in one view | teams 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.
qis a case-insensitive substring onmessageonly.q=nightly-billingmatches;q=nightly billingdoesn’t, because it’s a substring and not a word search. Nothing inattributesis searchable at all.serviceandlevelfilter exactly. Nothing else does:environmentandtrace_idare accepted and then ignored, and so are the documentedfilter,sinceanduntilparameters — 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
levelvalue isn’t validated on write. We sent"level": "CRITICAL"and it was stored as-is, which meanslevel=errorwill 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.