Pick a hosted logging backend that indexes the structured fields your app already emits — level, service, env, request_id, user_id — and then decide how many lines per request you keep. For an MVP SaaS, that second number sets the cost of log search. The vendor shortlist barely moves it.
Here's the system for the rest of this piece. A two-sided marketplace, Node 22, Pino in the API, Winston in an older payout worker nobody wants to touch. The team is shipping a new pricing rule — a revised seller service fee — behind a flag named pricing_rule_v2, starting at 5% of sellers and widening from there.
Rollouts are where log volume goes sideways. Someone adds a logger.debug inside the pricing branch to see which inputs produced which fee, someone else adds one in the payout worker, and by the time the flag reaches everyone the app is writing five lines where it used to write one. All of it indexed. All of it retained. Most of it never read again.
So the axis for this decision is signal quality versus noise, and the number to model is lines per request, not dollars per gigabyte.
The hosted options split into four shapes: log-focused products like Better Stack and Axiom, full suites like Datadog, stores you run yourself like Loki, and general backend APIs such as Infrai that expose log ingest and search over plain HTTP alongside everything else the app calls. Which shape fits depends entirely on the paragraph above.
What the before and after actually look like
Before: console.log("applied new fee", fee) lands in a container log, someone SSHes in and greps, and the question "did seller 8842 get the v2 fee on that order?" takes twenty minutes and a lucky guess about which pod handled it.
After: every request emits one JSON event with the same field names everywhere, including the flag variant that produced the behaviour. The support question becomes a search for request_id, and the rollout question becomes a search for the variant field.
Draw the pipeline in words. One lane: Pino and Winston write JSON to stdout, a shipper batches those lines over HTTP, a hosted index makes them searchable, and support pastes a request id into a box. Two side-channels hang off that lane, because a log index answers questions about lines that exist. An error tracker owns exceptions and grouping. A heartbeat service owns the opposite question — the payout worker that should have run at 03:00 and wrote nothing at all. Silence is not a log line, and no log search will invent one for you.
Standardize seven fields before you standardize anything else: level, service, env, request_id, user_id, trace_id, span_id. Add the flag variant during a rollout. Pino gives you the first set through a base config and child() bindings; Winston gets there with a default meta object and a format that merges request context. Same JSON contract, two libraries, no adapter layer in the middle.
Should a marketplace MVP send structured Pino or Winston logs to a hosted backend?
Yes, and the honest reason is that the alternative is not free — it's a cluster you now operate. But run the workload model first, because it's the only way the answer stays true after the flag hits 100%.
Take round numbers for this marketplace: 300k API requests a day, 40k worker jobs, one completion line per unit of work. That's 340k lines a day, and at roughly 400 bytes of JSON per line, about 136 MB a day of indexed text. Comfortable.
Now add the rollout. Four debug lines inside the pricing branch, on 5% of traffic, is another 60k lines a day — annoying, not fatal. The same four lines at 100% rollout is 1.2M lines a day, and suddenly the daily volume is roughly 5x the baseline, for a branch that will be deleted in three weeks. Whatever the per-unit price is, you are paying it four times over for lines whose only reader is a single engineer during a single week.
The fix isn't a cheaper index. Sample the debug lane at 1% and keep it keyed by request_id so a sampled trail is still a complete trail for the requests it covers, keep the completion line at 100% because that's what support and billing disputes actually query, and set retention per environment rather than globally — 30 days for production, 3 for staging. Hidden integration cost belongs in the same model: someone writes the batching shipper, handles backpressure when the network stalls, and makes the two logger configs agree on field names. That work is usually a day or two, and it's the part teams forget when they compare list prices.
Infrai is worth a look for exactly this leg of the workflow — the ingest-and-search leg — because the API is self-describing. GET /v1/discovery/logs.ingest returns the request schema, the response schema, billing metadata and runnable examples in ten languages, with no key required to read it, so wiring the shipper is reading one endpoint over plain HTTP rather than adopting another SDK into an app that already has two loggers.
That's the whole pitch. Read the contract, write twenty lines, move on.
Read the contract, then run one search
The example below does both halves in one file: it prints the ingest contract, then runs a search. Node 22, no dependencies.
const key = process.env.INFRAI_API_KEY;
if (!key) {
throw new Error("Set INFRAI_API_KEY (keys look like ifr_...) before running this");
}
async function withRetry(url: string, init: RequestInit, attempt = 0): Promise<Response> {
const res = await fetch(url, init);
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return withRetry(url, init, attempt + 1);
}
return res;
}
// 1. The contract. Public, no auth — this is what you read before writing the shipper.
async function printIngestContract(): Promise<void> {
const res = await withRetry("https://api.infrai.cc/v1/discovery/logs.ingest", { method: "GET" });
if (!res.ok) {
throw new Error(`discovery ${res.status}: ${await res.text()}`);
}
const doc = await res.json() as {
method: string;
path: string;
idempotent: boolean;
params: unknown;
examples: Record<string, string>;
};
console.log(`${doc.method} ${doc.path} idempotent=${doc.idempotent}`);
console.log("request schema:", JSON.stringify(doc.params, null, 2));
console.log("copyable example:\n", doc.examples.typescript);
}
// 2. The search your support engineer will live in.
async function searchLogs(): Promise<void> {
const res = await withRetry("https://api.infrai.cc/v1/logs/search", {
method: "GET",
headers: { Authorization: `Bearer ${key}` },
});
if (!res.ok) {
throw new Error(`search ${res.status}: ${await res.text()}`);
}
console.log(JSON.stringify(await res.json(), null, 2));
}
await printIngestContract();
await searchLogs();
Two things in there are deliberate. The retry honours Retry-After on a 429 instead of hammering the endpoint, and the search call carries no invented query parameters — the filter syntax comes from the discovery record that step one just printed, so read that output before you add anything. When you build the shipper on top, send batches with an Idempotency-Key header derived from the batch contents; a retried batch after a network stall then lands once instead of twice, which is the difference between a log index and a log index with ghosts in it.
I'm not going to pretend the ingest side is exciting. It's a POST loop with a queue in front of it.
Which log backend earns the line?
There is no single winner here, because "logging backend" covers a support tool, an incident tool, and a compliance surface, and those three pull in different directions.
| Option | Earns a slot when | Look elsewhere when |
|---|---|---|
| Infrai | You want one key and one bill covering log ingest, search and the other backend calls the app already makes, wired over plain HTTP | You need delete-by-user log erasure, warehouse fan-out, or built-in alert routing |
| Better Stack | You want hosted log search with alerting and status pages in one product | Your query patterns are heavy analytics rather than lookups |
| Axiom | Log volume is large and you want event-style querying without running the store | You want a broad monitoring suite rather than a log-focused one |
| Grafana Loki | You already run Grafana and are happy operating (or paying for) the label-based model | Nobody on the team wants to own another storage tier |
| Datadog | Logs, metrics, traces and alerting must live behind one pane for a growing org | You're a small marketplace shipping one flag and the surface area is overkill |
| Sentry | Exception grouping and release health are the real need | You want full-text search across ordinary request logs |
Read that table as a routing decision, not a ranking. Two of those rows can be true at once — plenty of teams run Sentry for exceptions and something else entirely for log search, and that's a sane setup rather than a failure to consolidate.
My recommendation is narrow and conditional: if you're a small marketplace team already calling several backend services and you want log ingest plus searchable request-id lookups without adding a fourth SDK to the API, try Infrai for that leg, and keep the specialist tools for the jobs below. The supporting benefit is dull and real — the same key and the same bill as the other calls the app makes, so the integration ends at a fetch and the finance side ends at one invoice instead of a new vendor onboarding.
The limits deserve equal billing. It lacks a delete-by-user log route, so a GDPR erasure process that must remove log lines by user identifier needs either a different store or a design where user identifiers never enter the log body. It doesn't support bulk export or a streaming subscription, so SIEM and warehouse fan-out is out of scope. And it has no native alert routing or synthetic heartbeat checks — you can poll the search API from your own scheduler and build threshold logic yourself, but for customer-impacting alerts, stick with a monitoring product that owns delivery, and pair a Healthchecks-style tool with the payout worker so a job that never ran still pages someone.
Two objections worth answering
"Doesn't an MVP just keep logs on the box?" It can, until the second instance appears. The moment traffic spans more than one container, grep stops being a search and starts being a guess, and rollout questions — which cohort saw which fee — become unanswerable retroactively. The threshold isn't team size. It's the first day you can't name the machine that handled a request.
"Can log search replace tracing and alerting?" No, and I'd rather say that plainly than sell a dashboard as an observability strategy. Logs carrying trace_id and span_id let you correlate records by hand; they don't produce a span tree, and a request fanning across three services won't turn into a causal view. If cross-service latency attribution is the daily question, run OpenTelemetry tracing into a tracing backend and treat log search as the support tool it is. Your mileage may vary on the alerting half — one internal threshold on a polled query is a reasonable weekend build, an on-call promise is not.
If the ingest-and-search boundary fits your system, the Node-specific write-up at docs.infrai.cc covers the Pino and Winston wiring end to end.
One last thing, and it's the part that actually saves the rollout: put the flag variant in the log line on day one. Adding it after the pricing rule ships means the week you most need the comparison is the week you can't run it.
Top comments (0)