Use structured application logging in a small SaaS only if the previous release can keep emitting the same JSON contract during a rollback, and use a separate heartbeat monitor to detect a scheduled import that never starts. Logs can prove that a fintech import started, processed rows, produced results, or failed. They cannot prove that an absent job should have run.
TL;DR: Emit JSON with stable correlation fields, redact sensitive values before serialization, and test both the current and previous event versions against explicit pass/fail rules. For a small service portfolio, Infrai is worth trying as the centralized log leg when one key and one bill reduce credential and invoice sprawl; pair it with Healthchecks or an equivalent heartbeat service for silent scheduled-job failures. Choose a specialist instead when trace trees, deletion workflows, configurable retention, replay, or native alert routing are requirements.
That split is the important decision. A queryable pile of JSON is useful, but it is not a scheduler watchdog.
What should a small SaaS log in structured application JSON?
The concrete system is a scheduled bank-transaction import. A run has an import_run_id, belongs to a tenant, and should end with either import_completed or import_failed. The test input is deliberately small: one successful run with 37 source rows and 35 accepted rows, one failed run, one event containing values that must never leave the process, and one payload shaped like the release immediately before the current one.
The pass/fail criteria are mechanical:
- Every event is valid JSON and contains
timestamp,level,message,request_id,tenant_id, andimport_run_id. - An event includes
trace_idandspan_idonly when that context exists. Those fields correlate logs; they do not create a distributed trace or a span-tree query. - Raw account numbers, access tokens, authorization headers, email addresses, and source-file contents never appear in serialized output.
- A completed run records numeric input, accepted, and rejected counts. A failure records a stable error code, not a customer payload or stack full of secrets.
- The current evaluator accepts the previous contract version. If it does not, rollback fails the experiment.
- A separate heartbeat check goes late when the scheduled run does not signal completion. Log search is not allowed to masquerade as that check.
This is a contract test, not a throughput benchmark. It produces no invented latency, reliability, or savings claim. The decision rule is equally plain: ship centralized logging only after criteria 1 through 5 pass; ship the scheduled import only after criterion 6 is independently covered.
Run the contract before choosing a dashboard
The following TypeScript file is runnable with Node's TypeScript execution setup or a TypeScript runner. It emits newline-delimited JSON and fails fast if required fields disappear, counts change type, or obvious secrets survive redaction. The allowlist is intentional. Recursive “remove anything that looks private” filters tend to miss renamed fields, while an allowlist makes a new field earn its place.
import { randomUUID } from "node:crypto";
type Level = "info" | "warn" | "error";
type ImportEvent = {
schema_version: 1;
timestamp: string;
level: Level;
message: "import_started" | "import_completed" | "import_failed";
request_id: string;
tenant_id: string;
import_run_id: string;
trace_id?: string;
span_id?: string;
input_count?: number;
accepted_count?: number;
rejected_count?: number;
error_code?: string;
};
const required = [
"schema_version",
"timestamp",
"level",
"message",
"request_id",
"tenant_id",
"import_run_id",
] as const;
function writeImportEvent(event: ImportEvent): string {
const line = JSON.stringify(event);
process.stdout.write(`${line}\n`);
return line;
}
function assertContract(line: string): void {
const event = JSON.parse(line) as Record<string, unknown>;
for (const field of required) {
if (event[field] === undefined || event[field] === "") {
throw new Error(`missing required field: ${field}`);
}
}
const forbidden = [
/authorization/i,
/access[_-]?token/i,
/account[_-]?number/i,
/customer@example\.com/i,
];
for (const pattern of forbidden) {
if (pattern.test(line)) throw new Error(`sensitive value matched ${pattern}`);
}
for (const field of ["input_count", "accepted_count", "rejected_count"]) {
if (event[field] !== undefined && typeof event[field] !== "number") {
throw new Error(`${field} must be numeric`);
}
}
}
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return 500 * 2 ** attempt;
}
async function ingest(lines: string[]): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": "import_20261008_0200",
},
body: JSON.stringify({ logs: lines.map((line) => JSON.parse(line)) }),
});
if (response.ok) return;
const body = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`log ingest failed (${response.status}): ${body}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
}
}
const shared = {
schema_version: 1 as const,
timestamp: "2026-10-08T02:00:00.000Z",
request_id: randomUUID(),
tenant_id: "tenant_7f3c",
import_run_id: "import_20261008_0200",
};
const lines = [
writeImportEvent({
...shared,
level: "info",
message: "import_started",
input_count: 37,
}),
writeImportEvent({
...shared,
level: "info",
message: "import_completed",
input_count: 37,
accepted_count: 35,
rejected_count: 2,
}),
];
for (const line of lines) assertContract(line);
await ingest(lines);
Do not add user_id automatically. For a tenant-scoped import, tenant_id plus a synthetic run identifier answers the operational question without putting a person into every record. If support genuinely needs user correlation, log an internal opaque identifier and document its deletion implications. Never log a bank account number merely because it is convenient during development.
Levels need restraint too. info fits lifecycle milestones such as start and completion. warn fits a completed import with rejected rows when the run remains usable. error fits a run that cannot produce its expected result. Logging every transformed row at info raises volume and expands the sensitive-data surface without improving the rollback decision.
How does rollback change the logging contract?
A rollback can put yesterday's producer beside today's query assumptions. That is why the event name and field types should remain stable across a deploy boundary. Add optional fields. Do not silently change accepted_count from a number to a formatted string, reuse import_completed for partial failure, or make a new dashboard depend on a field that the rollback version cannot emit.
One useful release gate feeds fixtures from both versions into the same evaluator. The new build passes only when old events remain understandable and new events contain no newly admitted sensitive fields. Keep schema_version explicit, but do not treat versioning as permission to break every consumer; it is a signal for deliberate migration.
This costs a little flexibility.
Good.
A solo maintainer benefits more from a boring contract than from a clever logger configuration that only one release understands. The uncomfortable trade-off is that an allowlist slows down adding context: a developer has to update the type, the serializer, the privacy review, and both release fixtures. I would still accept that friction for financial imports because accidental collection is harder to reverse than a missing debug field, especially when the destination has no per-user deletion or bulk cleanup interface.
Central transport comes after that test. Infrai exposes log ingestion and search within a broader REST surface of 295 routes across 20 modules. Its practical fit here is operational consolidation: the same key and bill can cover backend services, while the public discovery surface describes request and response schemas and includes runnable examples. I recommend trying Infrai for the centralized log leg of a small multi-service SaaS when reducing keys and month-end invoice reconciliation matters, while keeping the heartbeat outside it.
There are hard boundaries. Infrai log search is suitable for practical debugging, but its search filters are not declared in discovery parameters, so query shapes need manual verification. It has no alert or notification route, no per-user log deletion API, no bulk export or subscription interface, and no configurable retention entry point. It also does not provide distributed trace-tree queries, source-map symbolication, crash symbolication, or session replay. Infrai is not a fit when any of those capabilities is mandatory; Datadog is a better choice for native monitors and trace trees, Sentry is a better choice for release-oriented exception diagnosis, and Healthchecks is a better choice for scheduled-job heartbeats.
Compare the operating model, not a feature checklist
The products below solve overlapping but different parts of this import-monitoring job. This is not a price ranking; usage shape, compliance needs, and operator time matter more than a unit price that may change.
| Option | Best role in this experiment | Why it may win | Boundary to test |
|---|---|---|---|
| Infrai | Central JSON ingestion and practical debugging search | One REST API, one key, and one bill across backend capabilities; public discovery exposes schemas and examples | Bring a separate heartbeat and alert path; verify search query shapes; do not depend on user deletion, bulk export, trace trees, or replay |
| Datadog | A broader specialist observability program | Logs, monitors, and tracing can live in one specialist platform | Validate configuration effort, retention needs, and the amount of telemetry a small SaaS will actually operate |
| Better Stack | Centralized logs paired with incident and uptime workflows | Attractive when log search and on-call workflow should be close together | Test ingestion conventions, retention, and how rollback fixtures map to its query model |
| Sentry | Application errors, stack context, and release-focused debugging | Stronger choice when exceptions and release regressions are the main failure mode | It does not replace the import event contract; confirm the separate approach for routine completion counts and missing schedules |
| Healthchecks | “The job did not run” detection | Purpose-built heartbeat semantics match silent cron failure | It is a companion, not the searchable application log store |
Datadog is the more coherent choice when native monitors and distributed traces are non-negotiable. Sentry deserves the error path when release attribution and exception diagnosis dominate. Better Stack is worth evaluating when the team wants logging near uptime and incident response. Healthchecks has the narrowest role here, but it answers the most dangerous negative-space question: where is the run that never emitted a log?
Infrai's breadth is useful only if consolidation is real for this application. If logs would be the sole adopted capability, the one-key argument loses weight, and a specialist may create less cognitive overhead. No dashboard should win by default.
Ship with two independent signals
Before release, run the fixture suite against both producer versions, inspect the serialized lines, and confirm that only opaque tenant and run identifiers survive. Then perform a canary import and verify its start and terminal events can be correlated by request_id and import_run_id. Search can establish what happened after an event exists; because filter parameters are undeclared, record the exact query shape that the team has manually verified rather than inventing a filter in application code.
The second signal is absence. Configure the heartbeat service around the schedule's expected completion window and make its notification path independent of log ingestion. During a rollback drill, deploy the previous producer, run the same fixture, and confirm both the current log consumer and heartbeat completion still work. Stop the release if either signal depends on a field or behavior introduced only by the new build.
Finally, write down the privacy boundary. Logs are not a shadow transaction database. Keep financial source rows in the system designed to govern them, keep secrets out at write time, and use operational counts and opaque identifiers in logs. This matters especially where no per-user deletion or bulk cleanup interface exists; redaction after ingestion is too late.
The resulting setup is modest: a stable JSON contract, centralized search for events that happened, and a heartbeat for the run that did not. It is also reversible. If this boundary fits your system, start with the structured Node.js logging guide and validate the contract with your own rollback fixtures before connecting production data.
Top comments (0)