Cheap centralized logging for a small SaaS on Node, Docker and cron

Ship structured JSON logs from Docker containers and cron jobs into one searchable store with Infrai's per-call log API — real costs, safe retries, and where it falls short.

A small SaaS doesn’t need a log cluster. Two routes cover it: POST /v1/logs/ingest on Infrai takes a batch of JSON entries, GET /v1/logs/search reads them back at no charge, and ingest is metered per HTTP request rather than per gigabyte. Batch a few hundred container lines into each request and a month of logs from six containers plus a dozen cron jobs stays in cents.

That per-request meter is why this shape is cheap for a small team, and it also decides how you write the shipper — buffer, or you’re paying for framing overhead on every line.

The meter is the call, not the gigabyte

Most hosted log products bill by volume, which quietly makes your debug statements a budget item. A per-call meter moves the optimisation somewhere much easier to reason about: batch size.

Pricing unitWhat you end up optimisingWhere it hurts a small team
Per GB ingested (the usual hosted model, e.g. Datadog)log volume — you start deleting fieldsone chatty retry loop can double the month
Per host or per containercontainer countsix small Docker services cost like six big ones
Per HTTP call (Infrai logs.ingest)batch sizea line-per-request shipper is hundreds of times more expensive than it needs to be
Self-hosted (Loki, OpenSearch)nothing you pay in cashdisk, retention and upgrades become your on-call problem

Concretely: ingest is $0.00003 per call, verified 26 July 2026. A million log lines shipped 500 to a batch is 2,000 calls — roughly $0.06. New accounts start with $2 of free credit, about 66,666 ingest calls, and search doesn’t draw on it at all. Rates here move downward over time and discount campaigns run, so read today’s figure instead of trusting this paragraph:

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

The structural facts survive any price change: ingest is billable per call, search is free and rate-limited, and the same key already reaches cron, queues, error capture and object storage, so the follow-on step after a failed job isn’t a second vendor. Each billed response also carries its own metadata.cost_usd, which reconciles against /v1/account/usage — so if you ship logs on behalf of tenants, attributing the spend is a sum over your own request log rather than a monthly reconciliation project.

Getting Docker stdout into one place

Containers already write structured JSON to stdout if your app does. The missing piece is a process that tails that stream and forwards it in batches, and for a single-box Compose deployment that process is about sixty lines.

# Tail each service separately so SERVICE lands in the searchable `service` field.
docker compose logs -f --no-log-prefix --tail=0 api | SERVICE=api node ship.mjs &
docker compose logs -f --no-log-prefix --tail=0 worker | SERVICE=worker node ship.mjs &
wait
// ship.mjs — read NDJSON (or plain text) on stdin, batch into /v1/logs/ingest. Node 22 ESM.
import { createInterface } from "node:readline";
import { createHash } from "node:crypto";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) { console.error("INFRAI_API_KEY is not set"); process.exit(1); }

const SERVICE = process.env.SERVICE ?? "app";
const ENVIRONMENT = process.env.NODE_ENV ?? "production";
const MAX_BATCH = Number(process.env.MAX_BATCH ?? 500);
const FLUSH_MS = 3000;
const LEVELS = new Set(["debug", "info", "warning", "error", "fatal"]);

let batch = [];
let timer = null;

function toEntry(line) {
  let parsed = null;
  try { parsed = JSON.parse(line); } catch { /* plain-text line, keep it whole */ }
  const raw = String(parsed?.level ?? parsed?.severity ?? "info").toLowerCase();
  const level = raw === "warn" ? "warning" : raw;
  return {
    message: String(parsed?.message ?? parsed?.msg ?? line),
    level: LEVELS.has(level) ? level : "info",
    timestamp: new Date().toISOString(),
    service: SERVICE,
    environment: ENVIRONMENT,
    attributes: parsed && typeof parsed === "object" ? parsed : { raw: line },
  };
}

async function flush() {
  if (timer) { clearTimeout(timer); timer = null; }
  const entries = batch.splice(0, batch.length);
  if (entries.length === 0) return;
  // Derive the key from the batch, not from the attempt, so all three tries share it.
  const body = JSON.stringify({
    entries,
    idempotency_key: createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 32),
  });
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch("https://api.infrai.cc/v1/logs/ingest", {
        method: "POST",
        headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
        body,
      });
      if (res.status >= 500) { await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); continue; }
      const payload = await res.json();
      if (payload.ok !== true) { console.error("ingest rejected:", JSON.stringify(payload.error)); return; }
      const dropped = entries.length - payload.data.accepted;
      if (dropped > 0) console.error(`ingest dropped ${dropped} entries (missing message or level)`);
      return;
    } catch (err) {
      console.error("ingest failed:", err.message);
      await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
    }
  }
}

createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line", (line) => {
  if (!line.trim()) return;
  batch.push(toEntry(line));
  if (batch.length >= MAX_BATCH) flush();
  else if (!timer) timer = setTimeout(flush, FLUSH_MS).unref();
});

process.on("SIGTERM", () => { flush().finally(() => process.exit(0)); });
process.on("beforeExit", flush);

Two behaviours are worth knowing before this runs unattended.

An entry that’s missing message or level is skipped without an error — you get HTTP 200 with a lower accepted count, which is why the shipper compares the numbers it sent against the number the server admits to having taken, and logs the difference to stderr where your process supervisor will pick it up. And a 5,000-entry request did go through in our testing, at about 3.0s server time for 375 KB, billed as one call, so batches in the low hundreds are the sensible ceiling for a background process that also has to shut down promptly.

Cron jobs: wrap the command, keep the exit code

Cron output is the classic thing nobody sees until the invoice is wrong. A wrapper turns each run into exactly one log entry with the exit code attached, which costs one call per run.

#!/usr/bin/env bash
# run-job.sh — run a cron command, ship its output and exit status as one entry.
set -uo pipefail

JOB_NAME="${1:?usage: run-job.sh <job-name> <command...>}"
shift
OUT_FILE="$(mktemp)"
PAYLOAD="$(mktemp)"
START=$(date -u +%s)

"$@" >"$OUT_FILE" 2>&1
STATUS=$?
DURATION=$(( $(date -u +%s) - START ))

python3 - "$JOB_NAME" "$STATUS" "$DURATION" "$OUT_FILE" >"$PAYLOAD" <<'PY'
import json, sys
job, status, duration, out_file = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
tail = open(out_file, encoding="utf-8", errors="replace").read()[-4000:]
entry = {
    "message": f"cron {job} exited {status} after {duration}s",
    "level": "error" if status else "info",
    "service": f"cron-{job}",
    "environment": "production",
    "attributes": {"exit_code": status, "duration_s": duration, "output_tail": tail},
}
json.dump({"entries": [entry]}, sys.stdout)
PY

curl -sS -X POST "https://api.infrai.cc/v1/logs/ingest" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @"$PAYLOAD" >/dev/null

rm -f "$OUT_FILE" "$PAYLOAD"
exit "$STATUS"
# crontab -e
INFRAI_API_KEY=your_infrai_api_key
17 3 * * * /opt/app/run-job.sh nightly-billing /usr/bin/node /opt/app/jobs/billing.mjs

Nested structures inside attributes come back verbatim — arrays, nested objects and nulls all survived a round trip — so stashing a stack array or a job summary object there works.

One call per nightly job. That’s the whole cost model for cron.

Retrying a batch you never got an answer for

A shipper spends its whole life in the gap between “the request went out” and “the response came back”. That gap is what idempotency_key is for — it sits alongside entries in the ingest body, and the second send of the same key is a replay rather than a second write.

KEY_ID="batch-2026-07-26-0317-a91c"

curl -sS -X POST "https://api.infrai.cc/v1/logs/ingest" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"idempotency_key\": \"$KEY_ID\", \"entries\": [{\"message\": \"nightly-billing finished\", \"level\": \"info\", \"service\": \"cron-nightly-billing\", \"environment\": \"production\"}]}"

# Send the identical request a second time — the row is not written twice.
curl -sS -X POST "https://api.infrai.cc/v1/logs/ingest" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"idempotency_key\": \"$KEY_ID\", \"entries\": [{\"message\": \"nightly-billing finished\", \"level\": \"info\", \"service\": \"cron-nightly-billing\", \"environment\": \"production\"}]}" \
  | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['data']['accepted'], r['metadata']['idempotent_replay'])"

The important detail is where the key comes from. Derive it from the batch contents, the way ship.mjs does with a SHA-256 prefix, and every attempt at the same batch carries the same key; generate it per attempt and you’ve built an at-least-once pipe with extra steps. Only the retry is deduplicated, not the log line itself — if your application emits the same message twice, you’ll see it twice, which is usually what you want.

Reading it back

curl -s "https://api.infrai.cc/v1/logs/search?service=cron-nightly-billing&level=error&since=2026-07-19T00:00:00Z&until=2026-07-26T00:00:00Z&limit=5" \
  -H "Authorization: Bearer $INFRAI_API_KEY"
{
  "ok": true,
  "data": {
    "items": [
      {
        "message": "cron nightly-billing exited 1 after 42s",
        "level": "error",
        "timestamp": "2026-07-26T03:17:42.106Z",
        "service": "cron-nightly-billing",
        "environment": "production",
        "attributes": { "exit_code": 1, "duration_s": 42, "output_tail": "Error: connect ETIMEDOUT" }
      }
    ],
    "next_cursor": null,
    "total": 1
  }
}

Five parameters carry the weight. since and until bound the window, service and level are exact-match filters, and filter takes the Observation Filter DSL described in the logs reference for anything inside attributes — that’s where an exit_code or an order_id becomes queryable:

curl -sG -X GET "https://api.infrai.cc/v1/logs/search" \
  --data-urlencode 'filter={"attributes.exit_code": 1}' \
  --data-urlencode 'since=2026-07-19T00:00:00Z' \
  --data-urlencode 'limit=20' \
  -H "Authorization: Bearer $INFRAI_API_KEY"

Treat the predicate shape above as illustrative and check the operator list in the reference before you build a UI on it. q, by contrast, is a case-insensitive substring match on message alone — q=nightly-billing hits, q=nightly billing doesn’t — so it’s a grep, not a query language. One shipper-side habit saves an afternoon later: normalise your levels before sending, because warn and warning are two different exact-match values on the read side and a mixed corpus makes level=error look complete when it isn’t. Paging is an opaque next_cursor you hand straight back.

The honest limitation isn’t the filters any more, it’s everything a log product does after the search box: there’s no live tail, no saved query, no alert rule that fires when level=error spikes. You’d be building that on top of a cron job and the error-capture routes.

Where a real log product wins

Buy Better Stack if log search is your day-to-day debugging surface: live tail, a query builder and alert rules on a log pattern are its product, and none of those exist here. Datadog is the right call once logs have to sit beside APM traces and infrastructure metrics in one incident view, and you have the budget for per-GB billing. Grafana Loki is the pick if you already run Grafana and would rather own storage and retention than pay per line.

What you get instead is a boring, cheap pipe with nothing to operate — and the same key that ships these logs also captures errors, publishes to a queue and schedules the next job, so the follow-on work isn’t another account and another invoice.

References

Browse more logs developer guides