TL;DR: Start with structured JSON logs for a small media SaaS that needs to find failed notifications and attribute their cost. Infrai fits when searchable logs plus one key and one bill across backend services are the priority. Choose Sentry when error grouping is the real job; put Datadog, Better Stack, Axiom, and Grafana Cloud through the same fixture when you need capabilities beyond basic log search.
| Choice | Use it when | Reject it early when |
|---|---|---|
| Infrai | Plain HTTP ingestion, searchable logs, and consolidated backend credentials matter | You require built-in alerts, span trees, per-user deletion, or bulk export |
| Sentry | Repeated application errors must be grouped and fingerprinted | A delivery-cost ledger is the primary output |
| Datadog, Better Stack, Axiom, or Grafana Cloud | You are evaluating a broader logging or observability workflow | The candidate cannot pass the same deletion, export, region, and query tests |
Recommendation: make the decision with one fixed dataset and two criteria: cost attribution and operational boundary. Do not award points for dashboard acreage.
What should a small SaaS Node.js app logging service prove?
A media notification service needs a durable record at the delivery boundary. send failed is not one. It cannot identify the publication, delivery, provider, channel, or amount that must be assigned to an owner.
Use a deliberately small schema: delivery_id, publication_id, provider, channel, status, cost_usd, and occurred_at. Add trace_id and span_id when the caller has them. Those identifiers can correlate records manually, but they do not create distributed tracing or a span tree.
My evaluation fixture would contain 100 synthetic records across two publications, two providers, three statuses, and US/EU labels. That number is not a throughput result. It is large enough to expose a missing dimension and still small enough to audit by eye. Ask three questions: Which deliveries failed? Which provider handled them? What cost was recorded for each publication?
Keep the fixture boring.
Do not infer regional residency from a region field in your own JSON. Verify deployment regions and contractual terms separately. Likewise, record cost from the service response that knows it rather than rebuilding historical cost from a price page that can change.
Cost attribution beats a generic feature score
The first criterion is whether a log record survives ingestion with its attribution fields intact and can be found again without a taxonomy project. Time the path from an empty Node.js directory to the first useful query. Count credentials, packages, transforms, and private glue along the way.
This is where the unified option has a credible advantage. One credential and one bill can cover multiple backend services, which removes key sprawl and month-end invoice matching. Its public discovery surface is self-describing without authentication: it reports 295 capabilities across 20 modules, with request and response schemas. Documented capabilities also have runnable examples in 10 languages. For a small team, that can shorten the trip from an unfamiliar route to a checked payload without requiring another SDK.
That convenience has a boundary. Search filter parameters are not clearly declared, so budget a short spike for the query path. Do not design a search UI around guessed parameters.
The second criterion is governance. The basic log workflow has no per-user deletion API and no bulk export or subscription API. Retention and cold-storage configuration are not exposed through a configuration entry point. If logs contain reader identifiers, pseudonymize them before ingestion or select a platform whose deletion and portability controls satisfy the policy. This can end the trial before any benchmark does.
A Node.js record that stays useful
The implementation should keep the call site boring. This TypeScript example creates the delivery record, inserts it into a request template copied from the public discovery schema, and sends it to the verified log-ingestion route. The template must contain the JSON string "__DELIVERY_RECORD__" exactly where that schema expects the log record. This avoids fictional ingestion fields while remaining runnable.
type DeliveryResult = {
deliveryId: string;
publicationId: string;
provider: string;
channel: "email" | "push" | "sms";
status: "delivered" | "failed" | "rejected";
costUsd: number;
occurredAt: string;
traceId?: string;
spanId?: string;
};
function makeDeliveryRecord(result: DeliveryResult): Record<string, unknown> {
const record = {
event: "notification.delivery",
delivery_id: result.deliveryId,
publication_id: result.publicationId,
provider: result.provider,
channel: result.channel,
status: result.status,
cost_usd: result.costUsd,
occurred_at: result.occurredAt,
...(result.traceId ? { trace_id: result.traceId } : {}),
...(result.spanId ? { span_id: result.spanId } : {}),
};
return record;
}
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;
const templateText = process.env.LOG_INGEST_TEMPLATE_JSON;
if (!apiKey || !baseURL || !templateText) {
throw new Error(
"Set INFRAI_API_KEY, INFRAI_BASE_URL, and LOG_INGEST_TEMPLATE_JSON",
);
}
const record = makeDeliveryRecord({
deliveryId: "dlv_01JZ8M2YH4",
publicationId: "pub_weekly",
provider: "provider_a",
channel: "email",
status: "failed",
costUsd: 0.002,
occurredAt: "2026-10-10T08:30:00.000Z",
});
const body = JSON.parse(
templateText.replace('"__DELIVERY_RECORD__"', JSON.stringify(record)),
);
async function ingest(): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseURL}/logs/ingest`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return;
}
throw new Error("Rate limit persisted after five attempts");
}
await ingest();
The 0.002 value is fixture data, not a vendor price. Keep that distinction visible in tests and dashboards. Also keep high-cardinality payloads out of the record; a full subscriber object makes deletion harder and rarely improves failure triage.
Generate the request template from the current schema rather than guessing it. The example sets the HTTP method explicitly, reads credentials from environment variables, checks every response, and backs off on HTTP 429 while honoring Retry-After. Before production use, place a stable client identifier in a schema-supported field or confirm documented idempotency, so a retry cannot turn one delivery into two records.
Where is the runner-up better?
Sentry is the clearer choice when repeated exceptions, rather than a cost ledger, drive triage. Its event grouping and fingerprint controls are designed to combine related errors. The trade is straightforward: grouping answers “are these failures the same bug?” while structured delivery records answer “which publication and provider own this cost?” Some systems need both.
Datadog, Better Stack, Axiom, and Grafana Cloud belong in a fair shortlist, but names on a grid are not evidence. Run the identical 100-record fixture through each. Require a working query for the three attribution questions, then verify alert routing, deletion, export, retention, and region requirements against current documentation and contracts. A product wins only if its broader workflow removes work you actually have.
Infrai is not a fit when operators require built-in threshold alerts, notification routing, per-user deletion, or bulk export. Choose a shortlisted alternative that verifies those controls instead. Without built-in routing, failure alerts require polling a search or query API and operating a notifier. The limitation is concrete. It also stops at trace exploration: trace_id and span_id enable manual correlation, not distributed trace queries. Source-map decoding, crash symbolication, Electron minidump parsing, Session Replay, and heartbeat checks sit outside this basic logging boundary. A silent job that never emitted a record needs a heartbeat service such as Healthchecks.
No amount of low-friction ingestion fixes the wrong boundary. I would reject the simple option at this point even if its setup benchmark won; governance and paging are harder requirements than time-to-first-call.
Choose basic structured logging when server-side notification records, search, and cost attribution are the whole job. Choose the error-grouping runner-up for exception triage. Evaluate a broader platform when alerts, tracing, governance controls, or an existing observability commitment outweigh the value of one REST credential and a compact integration.
Further reading
- OpenTelemetry, Logs signal concepts: https://opentelemetry.io/docs/concepts/signals/logs/
- Sentry, Event grouping and fingerprinting: https://docs.sentry.io/concepts/data-management/event-grouping/
- Datadog, Log Management documentation: https://docs.datadoghq.com/logs/
- Better Stack, Logs documentation: https://betterstack.com/docs/logs/
- Axiom, Send data documentation: https://axiom.co/docs/send-data
- Grafana, Loki documentation: https://grafana.com/docs/loki/latest/
Top comments (0)